Compare commits

...

116 Commits

Author SHA1 Message Date
cd3ff32f9f refactor: H2 - MySQL 스키마 통일 2025-09-19 19:17:33 +09:00
f92c82a382 fix: 모든 시/도 코드 조회시 MySQL에서 발생하는 ONLY_FULL_GROUP_BY 문제 해결 2025-09-19 19:17:16 +09:00
c2b50c4dd7 fix: 프론트엔드 예약 API에서의 파라미터 오류 수정 2025-09-19 18:49:04 +09:00
2d08a20aff test: Schedule 및 Reservation에서의 변경 사항 테스트 반영 2025-09-19 18:39:51 +09:00
c0df43f840 refactor: 회원 예약 페이지 엔드포인트 수정("/summary" -> "/overview") 2025-09-19 18:36:00 +09:00
1bd2292ea0 refactor: 회원 예약 조회에서의 변경된 스펙 프론트엔드 반영 2025-09-19 18:31:10 +09:00
c64e613a2b refactor: 회원의 예약 상세 조회 정보에 예약자 정보 반영 2025-09-19 18:03:56 +09:00
70b9c58c15 refactor: ReservationSummaryResponse -> ReservationOverviewResponse 클래스명 수정 및 schedule에 추가된 ScheduleOverviewResponse 조회 적용 2025-09-19 18:01:01 +09:00
5a6f7c4763 feat: ScheduleId를 이용한 ScheduleOverview 단건 조회 기능 추가 2025-09-19 18:00:09 +09:00
86aa4c3046 refactor: schedule에서 사용하는 도메인에 매장 필드 추가 및 클래스명 수정(ScheduleWithTheme -> ScheduleOverview) 2025-09-19 17:48:25 +09:00
8c6222237b chore: Optimize Imports 2025-09-18 20:41:13 +09:00
232e7882de refactor: 미사용 코드 제거 2025-09-18 20:39:42 +09:00
0b8963b8c5 test: \@User 어노테이션 처리 방법 변경으로 인해 실패하는 테스트 수정 2025-09-18 20:31:35 +09:00
bea544d0fc test: 매장 API 테스트 추가 2025-09-18 20:28:21 +09:00
6a7e1906d2 refactor: 중복된 \@Public 어노테이션 제거 2025-09-18 20:28:11 +09:00
ed8b48712e test: \@User 어노테이션 처리시 토큰 조회 오류 테스트 케이스 추가 2025-09-18 20:27:59 +09:00
42f0c8bcd3 feat: 테스트용 StoreRegisterRequest Fixture 추가 2025-09-18 20:27:32 +09:00
c7c8e9ddcf feat: 매장 CRUD 관련 Validation 추가 2025-09-18 20:27:27 +09:00
910b19c83a refactor: schedule 스키마에 store_id FK 지정 2025-09-18 20:25:32 +09:00
8a9ec2e216 refactor: 매장 정보 수정 DTO의 null 기본값 지정 2025-09-18 20:25:21 +09:00
08af1c7084 refactor: API 인터페이스 간결화 및 일부 오타 수정 2025-09-18 20:24:39 +09:00
5f546f87da refactor: 미사용 코드 제거 2025-09-18 20:04:01 +09:00
8321356051 test: 테스트에서의 변경사항 PaymentAPITest 반영 2025-09-18 16:25:49 +09:00
be18775271 test: 테스트에서의 변경사항 ReservationApiTest 반영 2025-09-18 16:23:39 +09:00
45813fc04d refactor: TestAuthUtil에서 회원 로그인 시에도 Pair<UserEntity, Token> 반환 수정 및 반영 2025-09-18 16:23:26 +09:00
48b4a7597f refactor: DummyInitializer의 예약 생성에도 storeId 추가 2025-09-18 16:22:39 +09:00
58f7297c48 test: AuthApiTest에서의 일부 오타 수정 2025-09-18 15:44:49 +09:00
638df9f110 test: UserApiTest에 AuthUtil에서의 반환타입 변경사항 반영 2025-09-18 15:42:47 +09:00
4a30cc8c14 feat: 관리자 생성시 type, storeId 충돌 해결 및 반환 타입 수정 2025-09-18 15:42:15 +09:00
522d64cc8a feat: RestAssured 응답을 객체로 변환하는 유틸 추가 2025-09-18 15:41:24 +09:00
f62ac181ee refactor: 테마 API 테스트에 DummyInitializer에서의 변경사항 반영 2025-09-18 15:40:57 +09:00
4331acee31 refactor: 일정 API 테스트 수정 및 관리자 / 비 관리자로 분리 2025-09-18 15:30:45 +09:00
7cfc7a4d9f refactor: schedule 스키마의 기존 unique 제약조건에 storeId 추가 2025-09-18 15:30:09 +09:00
6b0c9709ed refactor: Servlet Filter에서의 MDC 제거 로직 변경 2025-09-18 15:29:39 +09:00
efb7148215 refactor: Fixture에서 발생하는 Unique 제약 조건 해결 및 일부 엔티티 생성 메서드 추가 2025-09-18 15:29:07 +09:00
75f628c991 refactor: dummyinitializer 로직을 Api 호출 -> repository 사용으로 수정 2025-09-18 15:28:15 +09:00
2f8c2a6a55 refactor: audit 확인 서비스 메서드명 수정 2025-09-18 15:27:25 +09:00
89ada4b146 refactor: schedule에서의 존재 조건 확인을 위한 storeId 필드 추가 2025-09-18 15:27:01 +09:00
54648a6c04 fix: equals 판단 실패 해결을 위한ScheduleFactory 내 나노초 제거 추가 2025-09-18 15:26:41 +09:00
b8cf1d6c9d feat: 테스트에 새로 추가된 Store 기능 도입 및 테마 API 테스트 2025-09-17 13:00:54 +09:00
dc37ae6d1a feat: 지금까지 구현된 매장 등 정보를 반영한 프론트엔드 기능 구현 완료 2025-09-17 10:43:22 +09:00
7a6afc7282 feat: 다른 서비스에서 사용할 지역 정보 조회 서비스 로직 추가 2025-09-17 10:42:52 +09:00
7c967defcc refactor: \@AdminOnly의 기본 타입을 STORE -> ALL로 수정 및 필드 오타 수정 2025-09-17 10:42:24 +09:00
7fd278aa43 feat: 매장 관련 CRUD API 추가 2025-09-17 10:42:11 +09:00
0ef47b7f94 refactor: 테마 DTO 및 컨트롤러 권한변 클래스 분리 2025-09-17 10:39:22 +09:00
cf65ccf915 refactor: 입력된 Id 리스트로 테마 조회시 In 쿼리로 변경 2025-09-17 10:37:47 +09:00
cb9125ef1d refactor: ScheduleController 관리자 / 비 관리자 분리 2025-09-17 10:36:38 +09:00
4aacaddcfc refactor: 일정 처리 로직 수정 및 권한별 주석 구분 추가 2025-09-17 10:36:07 +09:00
c7f98c3515 feat: 일정 DTO를 관리자 / 비 관리자로 구분 2025-09-17 10:35:01 +09:00
eec279c76f feat: Repository에 ScheduleWithThemeSummary 조회 메서드 추가 2025-09-17 10:34:35 +09:00
d5037664d7 feat: 일정 생성시 중복된 시간 검증 추가 2025-09-17 10:34:33 +09:00
9c279e1ec2 feat: 일정과 테마 정보를 담고 있는 도메인 객체 생성 2025-09-17 10:33:11 +09:00
b82c975cd0 fix: 오타 수정 2025-09-17 09:31:57 +09:00
6cfa2cbd38 refactor: \@LastModifiedBy 설정 휴먼에러 방지를 위한 ScheduleEntity 생성 전용 팩터리 메서드 추가 2025-09-16 22:06:50 +09:00
4e13735d5f refactor: ScheduleAPI 인터페이스를 각 권한 타입별로 분리 2025-09-16 22:06:06 +09:00
a6d028de45 refactor: 모든 관리자에 해당되는 AdminType.ALL 타입 추가 2025-09-16 22:03:11 +09:00
6ee7aa4339 refactor: ScheduleEntity 에서 updatedBy는 관리자 수정시에만 변경되도록 수정 2025-09-16 11:22:57 +09:00
afedaa21b8 refactor: 회원 / 관리자 PK 기반 MDC 유틸 분리 및 반영 2025-09-16 11:20:38 +09:00
cf3a1488f7 refactor: store 테이블에 status 컬럼 추가 및 Enum 정의 2025-09-15 17:50:29 +09:00
072ca7c457 remove: Room 테이블 제거 및 Schedule 테이블의 roomId -> storeId 수정 2025-09-15 17:49:19 +09:00
163b7991d3 refactor: 일정 관련 프론트엔드 페이지 및 기능 구현 2025-09-15 16:06:14 +09:00
cdf7a98867 refactor: StoreEntity에 'by' audit 정보 추가 2025-09-15 15:51:19 +09:00
2d138ff325 feat: 지역 코드 + 이름이 담긴 새로운 DTO 추가 2025-09-15 15:50:44 +09:00
bb6981666f refactor: Operator가 없으면 우선 Unknown을 반환하도록 수정 2025-09-15 15:50:27 +09:00
b41cddf345 refactor: Audit 관련 DTO 별도 클래스 분리 2025-09-15 15:46:21 +09:00
2481e026eb feat: 매장이 반영된 프론트엔드 관리자 페이지 초안 2025-09-15 15:45:35 +09:00
8cd1084bd8 fix: store 스키마에서 추가되지 않은 contact 컬럼 추가 2025-09-15 14:46:14 +09:00
b839c76a65 refactor: 테마 API에서의 변경 사항 및 HQ / STORE에 따른 어드민 페이지 프론트엔드 반영 2025-09-15 14:38:07 +09:00
78baa271bb refactor: 테마 API 테스트에 iniailize inline fun 반영 2025-09-15 13:33:17 +09:00
cc0316d77a refactor: sql 및 데이터 생성용 테스트 비활성화 2025-09-15 13:32:58 +09:00
747ecbf058 feat: 데이터 초기화 로직 수정(ID가 1이면 생성 -> 조회 후 없으면 생성) 2025-09-15 13:32:33 +09:00
d8fa110f3f refactor: 기존 테스트에 API 권한 변경 사항 반영 2025-09-15 13:31:55 +09:00
5fa5e5c49d refactor: 테스트에서의 초기화 작업 메서드에 가독성을 위한 println 로그 추가 2025-09-15 13:31:45 +09:00
78c699c69a refactor: 테마 API 테스트 권한별 분리 2025-09-15 12:30:45 +09:00
d78199778f refactor: AuthUtil 분리에 따른 RestAssuredUtils 정리 2025-09-15 12:30:08 +09:00
1de8d08cb7 refactor: Fixture의 중복값 문제를 해소하기 위한 랜덤 유틸 추가 2025-09-15 12:29:05 +09:00
228ea32db1 refactor: AuthUtil -> TestAuthUtil 네이밍 수정 및 클래스 분리 2025-09-15 12:28:29 +09:00
5b4df7bef6 refactor: DTO 명명 수정 및 테스트 반영 2025-09-15 12:09:05 +09:00
8205e83b4a refactor: DTO 명명 수정 및 테스트 반영 2025-09-15 12:08:52 +09:00
da88d66505 refactor: 테마 API를 권한별로 분리 2025-09-15 12:08:14 +09:00
06549e8ac1 refactor: Theme 스키마의 isOpen -> isActive 이름 변경 2025-09-15 11:55:07 +09:00
c3eceedea1 test: 테마 API 권한 변경에 따른 테스트 수정 2025-09-14 23:35:22 +09:00
7d2fd3b667 refactor: 기존의 테마 API를 모두 HQ 어드민 권한으로 수정 2025-09-14 23:16:38 +09:00
ecf0d6740a rename: ThemeAPIV2 -> ThemeAPI 클래스명 수정 2025-09-14 23:16:14 +09:00
ccac362551 refactor: \@AdminOnly에 기본 타입(STORE) 지정 - HQ 권한이 필요한 경우에만 2025-09-14 22:45:43 +09:00
d9ef3b0305 refactor: region에서의 미사용 DTO 제거 및 패키지 위치 수정 2025-09-14 22:43:41 +09:00
3ec96f3c35 refactor: API 및 로직 변동사항 테스트 반영 2025-09-14 22:41:33 +09:00
f27ce7cd3a refactor: 예약 취소에서 관리자 타입 체크 기능 제거 2025-09-14 22:41:16 +09:00
aecf499ea5 refactor: UserInterceptor에 관리자 토큰 검증 로직 추가 2025-09-14 22:40:55 +09:00
3d9a4c650e remove: 사용될 것 같지 않다고 판단되는 AdminResolver 및 관련 코드 제거 2025-09-14 22:40:39 +09:00
e3b0693a3c refactor: \@AdminOnly에 관리자 타입(STORE, HQ) 추가 2025-09-14 22:12:06 +09:00
5aa6a6cc2c refactor: 회원 / 관리자 Resolver 분리 2025-09-14 21:32:14 +09:00
c6dd8a977c refactor: 관리자 / 회원 분리에 따른 공통 API에 적용되는 Authenticated 어노테이션 및 인터셉터 제거 2025-09-14 21:29:24 +09:00
c3ab9be6c5 rename: CurrentUserContextResolver -> UserContextResolver 클래스명 수정 2025-09-14 21:20:23 +09:00
dcb4233f5d refactor: Resolver에서 사용하는 회원 어노테이션명 수정(CurrentUser -> User) 및 관리자 전용 Admin 추가 2025-09-14 21:19:31 +09:00
9b13448abd refactor: 기존 테스트에 변경된 인증 API 반영 2025-09-14 21:17:57 +09:00
498e8c8e75 refactor: Admin 변경에 따른 테스트용 유틸 재정의 2025-09-14 21:17:32 +09:00
e1aa032358 refactor: 미사용 인증 API 제거 및 로그인 API 반환 타입 재정의 2025-09-14 21:14:34 +09:00
a021ce8e73 refactor: 회원 / 관리자 로그인시 jwt claim 수정 2025-09-14 21:13:22 +09:00
63251d67ea refactor: 로그인시 사용하는 LoginCredential 회원 / 관리자 분리 및 헬퍼 메서드 추가 2025-09-14 21:13:06 +09:00
9361ea606b refactor: 프론트엔드에서 Admin 인증 Context 분리 및 별도의 로그인 페이지 생성 2025-09-14 21:10:29 +09:00
c33ec686f9 refactor: 관리자 스키마 타입 추가 및 엔티티 수정 2025-09-14 16:37:24 +09:00
18be393252 feat: RoomEntity 추가 2025-09-14 16:22:32 +09:00
993c593944 feat: store 테이블 초기 데이터 생성 코드 추가 2025-09-14 16:21:48 +09:00
12d75d2c66 feat: StoreEntity 추가 2025-09-14 16:21:36 +09:00
f4d7b30452 refactor: DataParser 내 상수화 및 필터링 처리 수정 2025-09-14 16:21:04 +09:00
16c890ae0e fix: store 테이블에서 사업자등록번호 컬럼 길이 수정 2025-09-14 16:08:41 +09:00
55abbdab6a fix: 행정동 제거 테스트 반영
- regionCode에서 dongCode 00000으로 수정
2025-09-14 15:02:18 +09:00
c3cf6e8097 refactor: 회원가입 페이지에서 행정동 입력 제외 2025-09-14 15:00:11 +09:00
7530e1038e feat: Apache POI gradle 의존성 및 행정안전부 인구현황 파일 전처리 유틸 클래스 추가 2025-09-14 14:52:20 +09:00
116dd24e26 refactor: region 스키마에서 행정동 컬럼 제거 & API 반영 2025-09-14 14:50:34 +09:00
407d7e9a5e refactor: 예약 취소 로직 수정
- 일단 await 으로 결제 취소 -> 예약 취소 순으로 진행
- 추후 개선 예정
2025-09-13 21:13:14 +09:00
b3ac4a2da2 fix: 테스트에서의 API endpoint 오타 수정 2025-09-13 21:12:39 +09:00
6fe3945129 feat: TosspayClient에 소요시간 로그 추가 2025-09-13 21:12:28 +09:00
a64371b3d2 feat: 회원가입 페이지에 지역 선택 추가 2025-09-13 21:12:13 +09:00
7db389ae49 feat: 지역 코드 조회 API 및 테스트 추가 2025-09-13 21:09:44 +09:00
133 changed files with 7904 additions and 6904 deletions

View File

@ -79,6 +79,9 @@ dependencies {
// RestAssured // RestAssured
testImplementation("io.rest-assured:rest-assured:5.5.5") testImplementation("io.rest-assured:rest-assured:5.5.5")
testImplementation("io.rest-assured:kotlin-extensions:5.5.5") testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
// etc
implementation("org.apache.poi:poi-ooxml:5.2.3")
} }
tasks.withType<Test> { tasks.withType<Test> {

BIN
data/population.xlsx Normal file

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import type { Difficulty } from '@_api/theme/themeTypes';
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED'; export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
export const ScheduleStatus = { export const ScheduleStatus = {
@ -7,24 +9,11 @@ export const ScheduleStatus = {
BLOCKED: 'BLOCKED' as ScheduleStatus, BLOCKED: 'BLOCKED' as ScheduleStatus,
}; };
export interface AvailableThemeIdListResponse { // Admin
themeIds: string[];
}
export interface ScheduleRetrieveResponse {
id: string;
time: string; // "HH:mm"
status: ScheduleStatus;
}
export interface ScheduleRetrieveListResponse {
schedules: ScheduleRetrieveResponse[];
}
export interface ScheduleCreateRequest { export interface ScheduleCreateRequest {
date: string; // "yyyy-MM-dd" date: string;
time: string; // "HH:mm"
themeId: string; themeId: string;
time: string;
} }
export interface ScheduleCreateResponse { export interface ScheduleCreateResponse {
@ -38,13 +27,29 @@ export interface ScheduleUpdateRequest {
status?: ScheduleStatus; status?: ScheduleStatus;
} }
export interface ScheduleDetailRetrieveResponse { export interface AdminScheduleSummaryResponse {
id: string; id: string,
date: string; // "yyyy-MM-dd" themeName: string,
time: string; // "HH:mm" startFrom: string,
status: ScheduleStatus; endAt: string,
createdAt: string; // or Date status: ScheduleStatus,
createdBy: string; }
updatedAt: string; // or Date
updatedBy: string; export interface AdminScheduleSummaryListResponse {
schedules: AdminScheduleSummaryResponse[];
}
// Public
export interface ScheduleWithThemeResponse {
id: string,
startFrom: string,
endAt: string,
themeId: string,
themeName: string,
themeDifficulty: Difficulty,
status: ScheduleStatus
}
export interface ScheduleWithThemeListResponse {
schedules: ScheduleWithThemeResponse[];
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,11 @@
import {isLoginRequiredError} from '@_api/apiClient'; import { isLoginRequiredError } from '@_api/apiClient';
import {confirmPayment} from '@_api/payment/paymentAPI'; import { confirmPayment } from '@_api/payment/paymentAPI';
import {type PaymentConfirmRequest, PaymentType} 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 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 {
@ -19,7 +19,7 @@ const ReservationStep2Page: React.FC = () => {
const paymentWidgetRef = useRef<any>(null); const paymentWidgetRef = useRef<any>(null);
const paymentMethodsRef = useRef<any>(null); const paymentMethodsRef = useRef<any>(null);
const { reservationId, themeName, date, startAt, price } = location.state || {}; const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
const handleError = (err: any) => { const handleError = (err: any) => {
if (isLoginRequiredError(err)) { if (isLoginRequiredError(err)) {
@ -51,12 +51,12 @@ const ReservationStep2Page: React.FC = () => {
const paymentMethods = paymentWidget.renderPaymentMethods( const paymentMethods = paymentWidget.renderPaymentMethods(
"#payment-method", "#payment-method",
{ value: price }, { value: totalPrice, currency: "KRW" },
{ variantKey: "DEFAULT" } { variantKey: "DEFAULT" }
); );
paymentMethodsRef.current = paymentMethods; paymentMethodsRef.current = paymentMethods;
}; };
}, [reservationId, price, navigate]); }, [reservationId, totalPrice, navigate]);
const handlePayment = () => { const handlePayment = () => {
if (!paymentWidgetRef.current || !reservationId) { if (!paymentWidgetRef.current || !reservationId) {
@ -67,15 +67,16 @@ const ReservationStep2Page: React.FC = () => {
const generateRandomString = () => const generateRandomString = () =>
crypto.randomUUID().replace(/-/g, ''); crypto.randomUUID().replace(/-/g, '');
paymentWidgetRef.current.requestPayment({ paymentWidgetRef.current.requestPayment({
orderId: generateRandomString(), orderId: generateRandomString(),
orderName: `${themeName} 예약 결제`, orderName: `${themeName} 예약 결제`,
amount: price, amount: totalPrice,
}).then((data: any) => { }).then((data: any) => {
const paymentData: PaymentConfirmRequest = { const paymentData: PaymentConfirmRequest = {
paymentKey: data.paymentKey, paymentKey: data.paymentKey,
orderId: data.orderId, orderId: data.orderId,
amount: price, // Use the price from component state instead of widget response amount: totalPrice,
paymentType: data.paymentType || PaymentType.NORMAL, paymentType: data.paymentType || PaymentType.NORMAL,
}; };
@ -87,9 +88,12 @@ const ReservationStep2Page: React.FC = () => {
alert('결제가 완료되었어요!'); alert('결제가 완료되었어요!');
navigate('/reservation/success', { navigate('/reservation/success', {
state: { state: {
themeName, storeName: storeName,
date, themeName: themeName,
startAt, date: date,
time: time,
participantCount: participantCount,
totalPrice: totalPrice,
} }
}); });
}) })
@ -109,10 +113,13 @@ const ReservationStep2Page: React.FC = () => {
<h2 className="page-title"></h2> <h2 className="page-title"></h2>
<div className="step-section"> <div className="step-section">
<h3> </h3> <h3> </h3>
<p><strong>:</strong> {themeName}</p>
<p><strong>:</strong> {formatDate(date)}</p> <p><strong>:</strong> {formatDate(date)}</p>
<p><strong>:</strong> {formatTime(startAt)}</p> <p><strong>:</strong> {time}</p>
<p><strong>:</strong> {price.toLocaleString()}</p> <p><strong>:</strong> {themeName}</p>
<p><strong>:</strong> {storeName}</p>
<p><strong>:</strong> {participantCount}</p>
<p><strong>1 :</strong> {themePrice.toLocaleString()}</p>
<p><strong> :</strong> {totalPrice.toLocaleString()}</p>
</div> </div>
<div className="step-section"> <div className="step-section">
<h3> </h3> <h3> </h3>
@ -121,7 +128,7 @@ const ReservationStep2Page: React.FC = () => {
</div> </div>
<div className="next-step-button-container"> <div className="next-step-button-container">
<button onClick={handlePayment} className="next-step-button"> <button onClick={handlePayment} className="next-step-button">
{price.toLocaleString()} {totalPrice.toLocaleString()}
</button> </button>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,370 @@
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 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> {new Date(detailedStores[store.id].audit.createdAt).toLocaleString()}
</p>
<p>
<strong>:</strong> {new Date(detailedStores[store.id].audit.updatedAt).toLocaleString()}
</p>
<p>
<strong>:</strong> {detailedStores[store.id].audit.createdBy.name}({detailedStores[store.id].audit.createdBy.id})
</p>
<p>
<strong>:</strong> {detailedStores[store.id].audit.updatedBy.name}({detailedStores[store.id].audit.updatedBy.id})
</p>
</div>
</div>
{isEditing && editingStore ? (
<div className="details-form-card">
<div className="form-row">
<div className="form-group"><label
className="form-label"></label><input
type="text" name="name" className="form-input"
value={editingStore.name}
onChange={handleEditChange} /></div>
<div className="form-group"><label
className="form-label"></label><input
type="text" name="address"
className="form-input"
value={editingStore.address}
onChange={handleEditChange} /></div>
<div className="form-group"><label
className="form-label"></label><input
type="text" name="contact"
className="form-input"
value={editingStore.contact}
onChange={handleEditChange} /></div>
</div>
<div className="button-group">
<button className="btn btn-secondary"
onClick={handleCancelEdit}>
</button>
<button className="btn btn-primary"
onClick={() => handleSave(store.id)}>
</button>
</div>
</div>
) : (
<div className="button-group">
<button className="btn btn-danger"
onClick={() => handleDeleteStore(store.id)}>
</button>
<button className="btn btn-primary"
onClick={() => handleEditClick(detailedStores[store.id])}>
</button>
</div>
)}
</div>
) : <p> .</p>}
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default AdminStorePage;

View File

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

View File

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

View File

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

View File

@ -34,6 +34,7 @@
"@_hooks/*": ["src/hooks/*"], "@_hooks/*": ["src/hooks/*"],
"@_pages/*": ["src/pages/*"], "@_pages/*": ["src/pages/*"],
"@_types/*": ["/src/types/*"], "@_types/*": ["/src/types/*"],
"@_util/*": ["src/util/*"]
} }
}, },
"include": ["src"], "include": ["src"],

View File

@ -7,12 +7,11 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.admin.exception.AdminErrorCode import roomescape.admin.exception.AdminErrorCode
import roomescape.admin.exception.AdminException import roomescape.admin.exception.AdminException
import roomescape.admin.infrastructure.persistence.AdminEntity
import roomescape.admin.infrastructure.persistence.AdminRepository import roomescape.admin.infrastructure.persistence.AdminRepository
import roomescape.common.dto.AdminLoginCredentials import roomescape.common.dto.AdminLoginCredentials
import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.AuditConstant
import roomescape.common.dto.OperatorInfo import roomescape.common.dto.OperatorInfo
import roomescape.common.dto.PrincipalType import roomescape.common.dto.toCredentials
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -20,51 +19,32 @@ private val log: KLogger = KotlinLogging.logger {}
class AdminService( class AdminService(
private val adminRepository: AdminRepository, private val adminRepository: AdminRepository,
) { ) {
@Transactional(readOnly = true)
fun findContextById(id: Long): CurrentUserContext {
log.info { "[AdminService.findById] 현재 로그인된 관리자 조회 시작: id=${id}" }
val admin: AdminEntity = findOrThrow(id)
return CurrentUserContext(admin.id, admin.name, PrincipalType.ADMIN).also {
log.info { "[AdminService.findById] 현재 로그인된 관리자 조회 완료: id=${id}" }
}
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findCredentialsByAccount(account: String): AdminLoginCredentials { fun findCredentialsByAccount(account: String): AdminLoginCredentials {
log.info { "[AdminService.findInfoByAccount] 관리자 조회 시작: account=${account}" } log.info { "[AdminService.findCredentialsByAccount] 관리자 조회 시작: account=${account}" }
return adminRepository.findByAccount(account) return adminRepository.findByAccount(account)
?.let { ?.let {
log.info { "[AdminService.findByAccount] 관리자 조회 완료: account=${account}, id=${it.id}" } log.info { "[AdminService.findCredentialsByAccount] 관리자 조회 완료: account=${account}, id=${it.id}" }
AdminLoginCredentials(it.id, it.password, it.permissionLevel) it.toCredentials()
} }
?: run { ?: run {
log.info { "[AdminService.findInfoByAccount] 관리자 조회 실패: account=${account}" } log.info { "[AdminService.findCredentialsByAccount] 관리자 조회 실패: account=${account}" }
throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND) throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND)
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findOperatorById(id: Long): OperatorInfo { fun findOperatorOrUnknown(id: Long): OperatorInfo {
log.info { "[AdminService.findOperatorById] 작업자 정보 조회 시작: id=${id}" } log.info { "[AdminService.findOperatorById] 작업자 정보 조회 시작: id=${id}" }
val admin: AdminEntity = findOrThrow(id) return adminRepository.findByIdOrNull(id)?.let { admin ->
OperatorInfo(admin.id, admin.name).also {
return OperatorInfo(admin.id, admin.name).also {
log.info { "[AdminService.findOperatorById] 작업자 정보 조회 완료: id=${admin.id}, name=${admin.name}" } log.info { "[AdminService.findOperatorById] 작업자 정보 조회 완료: id=${admin.id}, name=${admin.name}" }
} }
} } ?: run {
log.warn { "[AdminService.findOperatorById] 작업자 정보 조회 실패. id=${id}" }
private fun findOrThrow(id: Long): AdminEntity { AuditConstant.UNKNOWN_OPERATOR
log.info { "[AdminService.findOrThrow] 조회 시작: id=${id}" }
return adminRepository.findByIdOrNull(id)
?.also { log.info { "[AdminService.findOrThrow] 조회 완료: id=${id}, name=${it.name}" } }
?: run {
log.info { "[AdminService.findOrThrow] 조회 실패: id=${id}" }
throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND)
} }
} }
} }

View File

@ -10,16 +10,30 @@ import roomescape.common.entity.AuditingBaseEntity
class AdminEntity( class AdminEntity(
id: Long, id: Long,
@Column(unique = true)
val account: String, val account: String,
var password: String, var password: String,
val name: String, val name: String,
@Column(unique = true)
var phone: String, var phone: String,
@Enumerated(value = EnumType.STRING) @Enumerated(value = EnumType.STRING)
var permissionLevel: AdminPermissionLevel val type: AdminType,
@Column(nullable = true)
var storeId: Long? = null,
@Enumerated(value = EnumType.STRING)
var permissionLevel: AdminPermissionLevel
) : AuditingBaseEntity(id) ) : AuditingBaseEntity(id)
enum class AdminType {
HQ,
STORE,
ALL
}
enum class AdminPermissionLevel( enum class AdminPermissionLevel(
val privileges: Set<Privilege> val privileges: Set<Privilege>
) { ) {

View File

@ -11,15 +11,15 @@ import roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.LoginContext import roomescape.auth.web.LoginContext
import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginRequest
import roomescape.auth.web.LoginSuccessResponse import roomescape.auth.web.LoginSuccessResponse
import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.LoginCredentials import roomescape.common.dto.LoginCredentials
import roomescape.common.dto.PrincipalType import roomescape.common.dto.PrincipalType
import roomescape.user.business.UserService import roomescape.user.business.UserService
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
const val CLAIM_ADMIN_TYPE_KEY = "admin_type"
const val CLAIM_PERMISSION_KEY = "permission" const val CLAIM_PERMISSION_KEY = "permission"
const val CLAIM_TYPE_KEY = "principal_type" const val CLAIM_STORE_ID_KEY = "store_id"
@Service @Service
class AuthService( class AuthService(
@ -34,7 +34,6 @@ class AuthService(
context: LoginContext context: LoginContext
): LoginSuccessResponse { ): LoginSuccessResponse {
log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" } log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
val (credentials, extraClaims) = getCredentials(request) val (credentials, extraClaims) = getCredentials(request)
try { try {
@ -44,7 +43,7 @@ class AuthService(
loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context) loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context)
return LoginSuccessResponse(accessToken).also { return credentials.toResponse(accessToken).also {
log.info { "[AuthService.login] 로그인 완료: account=${request.account}, context=${context}" } log.info { "[AuthService.login] 로그인 완료: account=${request.account}, context=${context}" }
} }
@ -65,23 +64,6 @@ class AuthService(
} }
} }
@Transactional(readOnly = true)
fun findContextById(id: Long, type: PrincipalType): CurrentUserContext {
log.info { "[AuthService.checkLogin] 로그인 확인 시작: id=${id}, type=${type}" }
return when (type) {
PrincipalType.ADMIN -> {
adminService.findContextById(id)
}
PrincipalType.USER -> {
userService.findContextById(id)
}
}.also {
log.info { "[AuthService.checkLogin] 로그인 확인 완료: id=${id}, type=${type}" }
}
}
private fun verifyPasswordOrThrow( private fun verifyPasswordOrThrow(
request: LoginRequest, request: LoginRequest,
credentials: LoginCredentials credentials: LoginCredentials
@ -97,15 +79,14 @@ class AuthService(
val credentials: LoginCredentials = when (request.principalType) { val credentials: LoginCredentials = when (request.principalType) {
PrincipalType.ADMIN -> { PrincipalType.ADMIN -> {
adminService.findCredentialsByAccount(request.account).also { adminService.findCredentialsByAccount(request.account).also {
extraClaims.put(CLAIM_PERMISSION_KEY, it.permissionLevel) extraClaims.put(CLAIM_ADMIN_TYPE_KEY, it.type.name)
extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.ADMIN) extraClaims.put(CLAIM_PERMISSION_KEY, it.permissionLevel.name)
it.storeId?.also { storeId -> extraClaims.put(CLAIM_STORE_ID_KEY, storeId.toString()) }
} }
} }
PrincipalType.USER -> { PrincipalType.USER -> {
userService.findCredentialsByAccount(request.account).also { userService.findCredentialsByAccount(request.account)
extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.USER)
}
} }
} }

View File

@ -3,7 +3,6 @@ package roomescape.auth.docs
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import jakarta.validation.Valid import jakarta.validation.Valid
@ -11,42 +10,25 @@ import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginRequest
import roomescape.auth.web.LoginSuccessResponse import roomescape.auth.web.LoginSuccessResponse
import roomescape.auth.web.support.CurrentUser
import roomescape.auth.web.support.Public import roomescape.auth.web.support.Public
import roomescape.auth.web.support.User
import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
@Tag(name = "1. 인증 / 인가 API", description = "로그인, 로그아웃 및 로그인 상태를 확인합니다")
interface AuthAPI { interface AuthAPI {
@Public @Public
@Operation(summary = "로그인") @Operation(summary = "로그인")
@ApiResponses( @ApiResponses(ApiResponse(responseCode = "200"))
ApiResponse(responseCode = "200", description = "로그인 성공시 토큰을 반환합니다."),
)
fun login( fun login(
@Valid @RequestBody loginRequest: LoginRequest, @Valid @RequestBody loginRequest: LoginRequest,
servletRequest: HttpServletRequest servletRequest: HttpServletRequest
): ResponseEntity<CommonApiResponse<LoginSuccessResponse>> ): ResponseEntity<CommonApiResponse<LoginSuccessResponse>>
@Operation(summary = "로그인 상태 확인") @Operation(summary = "로그아웃")
@ApiResponses( @ApiResponses(ApiResponse(responseCode = "200"))
ApiResponse(
responseCode = "200",
description = "입력된 ID / 결과(Boolean)을 반환합니다.",
useReturnTypeSchema = true
),
)
fun checkLogin(
@CurrentUser user: CurrentUserContext
): ResponseEntity<CommonApiResponse<CurrentUserContext>>
@Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "200"),
)
fun logout( fun logout(
@CurrentUser user: CurrentUserContext, @User user: CurrentUserContext,
servletResponse: HttpServletResponse servletResponse: HttpServletResponse
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
} }

View File

@ -6,14 +6,10 @@ import io.jsonwebtoken.Claims
import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.Jwts import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys import io.jsonwebtoken.security.Keys
import org.slf4j.MDC
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import roomescape.auth.business.CLAIM_TYPE_KEY
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import roomescape.auth.exception.AuthException
import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY
import roomescape.common.dto.PrincipalType
import java.util.* import java.util.*
import javax.crypto.SecretKey import javax.crypto.SecretKey
@ -47,21 +43,6 @@ class JwtUtils(
} }
} }
fun extractIdAndType(token: String?): Pair<Long, PrincipalType> {
val id: Long = extractSubject(token)
.also { MDC.put(MDC_PRINCIPAL_ID_KEY, it) }
.toLong()
val type: PrincipalType = extractClaim(token, CLAIM_TYPE_KEY)
?.let { PrincipalType.valueOf(it) }
?: run {
log.info { "[JwtUtils.extractIdAndType] 회원 타입 조회 실패. id=$id" }
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
}
return id to type
}
fun extractSubject(token: String?): String { fun extractSubject(token: String?): String {
if (token.isNullOrBlank()) { if (token.isNullOrBlank()) {
throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND)

View File

@ -3,13 +3,12 @@ package roomescape.auth.web
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import roomescape.auth.business.AuthService import roomescape.auth.business.AuthService
import roomescape.auth.docs.AuthAPI import roomescape.auth.docs.AuthAPI
import roomescape.auth.web.support.CurrentUser import roomescape.auth.web.support.User
import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
@ -29,16 +28,9 @@ class AuthController(
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@GetMapping("/login/check")
override fun checkLogin(
@CurrentUser user: CurrentUserContext,
): ResponseEntity<CommonApiResponse<CurrentUserContext>> {
return ResponseEntity.ok(CommonApiResponse(user))
}
@PostMapping("/logout") @PostMapping("/logout")
override fun logout( override fun logout(
@CurrentUser user: CurrentUserContext, @User user: CurrentUserContext,
servletResponse: HttpServletResponse servletResponse: HttpServletResponse
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
return ResponseEntity.ok().build() return ResponseEntity.ok().build()

View File

@ -1,6 +1,7 @@
package roomescape.auth.web package roomescape.auth.web
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.common.dto.PrincipalType import roomescape.common.dto.PrincipalType
data class LoginContext( data class LoginContext(
@ -19,6 +20,19 @@ data class LoginRequest(
val principalType: PrincipalType val principalType: PrincipalType
) )
data class LoginSuccessResponse( abstract class LoginSuccessResponse {
val accessToken: String abstract val accessToken: String
) abstract val name: String
}
data class UserLoginSuccessResponse(
override val accessToken: String,
override val name: String,
) : LoginSuccessResponse()
data class AdminLoginSuccessResponse(
override val accessToken: String,
override val name: String,
val type: AdminType,
val storeId: Long?,
) : LoginSuccessResponse()

View File

@ -1,10 +1,12 @@
package roomescape.auth.web.support package roomescape.auth.web.support
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.admin.infrastructure.persistence.Privilege import roomescape.admin.infrastructure.persistence.Privilege
@Target(AnnotationTarget.FUNCTION) @Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME)
annotation class AdminOnly( annotation class AdminOnly(
val type: AdminType = AdminType.ALL,
val privilege: Privilege val privilege: Privilege
) )
@ -12,14 +14,10 @@ annotation class AdminOnly(
@Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME)
annotation class UserOnly annotation class UserOnly
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Authenticated
@Target(AnnotationTarget.FUNCTION) @Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME)
annotation class Public annotation class Public
@Target(AnnotationTarget.VALUE_PARAMETER) @Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME)
annotation class CurrentUser annotation class User

View File

@ -8,13 +8,16 @@ import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor import org.springframework.web.servlet.HandlerInterceptor
import roomescape.admin.infrastructure.persistence.AdminPermissionLevel import roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.admin.infrastructure.persistence.Privilege
import roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
import roomescape.auth.business.CLAIM_PERMISSION_KEY import roomescape.auth.business.CLAIM_PERMISSION_KEY
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.AdminOnly
import roomescape.auth.web.support.accessToken import roomescape.auth.web.support.accessToken
import roomescape.common.dto.PrincipalType import roomescape.common.util.MdcPrincipalId
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -30,32 +33,76 @@ class AdminInterceptor(
if (handler !is HandlerMethod) { if (handler !is HandlerMethod) {
return true return true
} }
val annotation: AdminOnly = handler.getMethodAnnotation(AdminOnly::class.java) ?: return true val annotation: AdminOnly = handler.getMethodAnnotation(AdminOnly::class.java) ?: return true
val token: String? = request.accessToken() val token: String? = request.accessToken()
val (id, type) = jwtUtils.extractIdAndType(token)
val permission: AdminPermissionLevel = jwtUtils.extractClaim(token, key = CLAIM_PERMISSION_KEY) try {
?.let { run {
AdminPermissionLevel.valueOf(it) val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalId.set(it) }
} val type: AdminType = validateTypeAndGet(token, annotation.type)
?: run { val permission: AdminPermissionLevel = validatePermissionAndGet(token, annotation.privilege)
if (type != PrincipalType.ADMIN) {
log.warn { "[AdminInterceptor] 회원의 관리자 API 접근: id=${id}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
}
log.warn { "[AdminInterceptor] 토큰에서 이용자 권한이 조회되지 않음: id=${id}" }
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
}
if (!permission.hasPrivilege(annotation.privilege)) { log.info { "[AdminInterceptor] 인증 완료. adminId=$id, type=${type}, permission=${permission}" }
log.warn { "[AdminInterceptor] 관리자 권한 부족: required=${annotation.privilege} / current=${permission}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
} }
log.info { "[AdminInterceptor] 인증 완료. adminId=$id, permission=${permission}" }
return true return true
} catch (e: Exception) {
when (e) {
is AuthException -> { throw e }
else -> {
log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
}
}
}
}
private fun validateTypeAndGet(token: String?, requiredType: AdminType): AdminType {
val typeClaim: String? = jwtUtils.extractClaim(token, key = CLAIM_ADMIN_TYPE_KEY)
/**
* 이전의 id 추출 과정에서 토큰이 유효한지 검증했기 때문에 typeClaim null 이라는 것은
* 회원 토큰일 가능성이 . (관리자 토큰에는 CLAIM_ADMIN_TYPE_KEY 무조건 존재함)
*/
if (typeClaim == null) {
log.warn { "[AdminInterceptor] 관리자 타입 조회 실패: token=${token}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
}
val type = try {
AdminType.valueOf(typeClaim)
} catch (_: IllegalArgumentException) {
log.warn { "[AdminInterceptor] 관리자 타입 변환 실패: token=${token}, typeClaim=${typeClaim}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN)
}
if (requiredType != AdminType.ALL && type != requiredType) {
log.warn { "[AdminInterceptor] 관리자 권한 부족: requiredType=${requiredType} / current=${type}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
}
return type
}
private fun validatePermissionAndGet(token: String?, requiredPrivilege: Privilege): AdminPermissionLevel {
val permissionClaim: String? = jwtUtils.extractClaim(token, key = CLAIM_PERMISSION_KEY)
if (permissionClaim == null) {
log.warn { "[AdminInterceptor] 관리자 권한 조회 실패: token=${token}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN)
}
val permission = try {
AdminPermissionLevel.valueOf(permissionClaim)
} catch (_: IllegalArgumentException) {
log.warn { "[AdminInterceptor] 관리자 권한 변환 실패: token=${token}, permissionClaim=${permissionClaim}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN)
}
if (!permission.hasPrivilege(requiredPrivilege)) {
log.warn { "[AdminInterceptor] 관리자 권한 부족: required=${requiredPrivilege} / current=${permission.privileges}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
}
return permission
} }
} }

View File

@ -1,44 +0,0 @@
package roomescape.auth.web.support.interceptors
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
import roomescape.auth.business.AuthService
import roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.support.Authenticated
import roomescape.auth.web.support.accessToken
private val log: KLogger = KotlinLogging.logger {}
@Component
class AuthenticatedInterceptor(
private val jwtUtils: JwtUtils,
private val authService: AuthService
) : HandlerInterceptor {
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any
): Boolean {
if ((handler !is HandlerMethod) || (handler.getMethodAnnotation(Authenticated::class.java) == null)) {
return true
}
val token: String? = request.accessToken()
val (id, type) = jwtUtils.extractIdAndType(token)
try {
authService.findContextById(id, type)
log.info { "[AuthenticatedInterceptor] 인증 완료. id=$id, type=${type}" }
return true
} catch (e: Exception) {
throw e
}
}
}

View File

@ -7,12 +7,13 @@ import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor import org.springframework.web.servlet.HandlerInterceptor
import roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.support.UserOnly import roomescape.auth.web.support.UserOnly
import roomescape.auth.web.support.accessToken import roomescape.auth.web.support.accessToken
import roomescape.common.dto.PrincipalType import roomescape.common.util.MdcPrincipalId
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -29,16 +30,29 @@ class UserInterceptor(
if ((handler !is HandlerMethod) || (handler.getMethodAnnotation(UserOnly::class.java) == null)) { if ((handler !is HandlerMethod) || (handler.getMethodAnnotation(UserOnly::class.java) == null)) {
return true return true
} }
val token: String? = request.accessToken() val token: String? = request.accessToken()
val (id, type) = jwtUtils.extractIdAndType(token)
if (type != PrincipalType.USER) { try {
log.warn { "[UserInterceptor] 관리자의 회원 API 접근: id=${id}" } val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalId.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) throw AuthException(AuthErrorCode.ACCESS_DENIED)
} }
log.info { "[UserInterceptor] 인증 완료. userId=$id" } log.info { "[UserInterceptor] 인증 완료. userId=$id" }
return true return true
} catch (e: Exception) {
when (e) {
is AuthException -> { throw e }
else -> {
log.warn { "[UserInterceptor] 예상치 못한 예외: message=${e.message}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
}
}
}
} }
} }

View File

@ -9,23 +9,23 @@ import org.springframework.web.bind.support.WebDataBinderFactory
import org.springframework.web.context.request.NativeWebRequest import org.springframework.web.context.request.NativeWebRequest
import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.method.support.ModelAndViewContainer import org.springframework.web.method.support.ModelAndViewContainer
import roomescape.auth.business.AuthService
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.support.CurrentUser import roomescape.auth.web.support.User
import roomescape.auth.web.support.accessToken import roomescape.auth.web.support.accessToken
import roomescape.user.business.UserService
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@Component @Component
class CurrentUserContextResolver( class UserContextResolver(
private val jwtUtils: JwtUtils, private val jwtUtils: JwtUtils,
private val authService: AuthService private val userService: UserService,
) : HandlerMethodArgumentResolver { ) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean { override fun supportsParameter(parameter: MethodParameter): Boolean {
return parameter.hasParameterAnnotation(CurrentUser::class.java) return parameter.hasParameterAnnotation(User::class.java)
} }
override fun resolveArgument( override fun resolveArgument(
@ -38,11 +38,11 @@ class CurrentUserContextResolver(
val token: String? = request.accessToken() val token: String? = request.accessToken()
try { try {
val (id, type) = jwtUtils.extractIdAndType(token) val id: Long = jwtUtils.extractSubject(token).toLong()
return authService.findContextById(id, type) return userService.findContextById(id)
} catch (e: Exception) { } catch (e: Exception) {
log.info { "[MemberIdResolver] 회원 조회 실패. message=${e.message}" } log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" }
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
} }
} }

View File

@ -1,8 +1,10 @@
package roomescape.common.config package roomescape.common.config
import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.* import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer
@ -12,8 +14,6 @@ import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer
import com.fasterxml.jackson.module.kotlin.kotlinModule import com.fasterxml.jackson.module.kotlin.kotlinModule
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import roomescape.common.exception.CommonErrorCode
import roomescape.common.exception.RoomescapeException
import java.time.* import java.time.*
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -57,30 +57,6 @@ class JacksonConfig {
return simpleModule return simpleModule
} }
class LongToStringSerializer : JsonSerializer<Long>() {
override fun serialize(value: Long?, gen: JsonGenerator, serializers: SerializerProvider) {
if (value == null) {
gen.writeNull()
} else {
gen.writeString(value.toString())
}
}
}
class StringToLongDeserializer : JsonDeserializer<Long>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Long? {
val text = p.text
if (text.isNullOrBlank()) {
return null
}
return try {
text.toLong()
} catch (_: NumberFormatException) {
throw RoomescapeException(CommonErrorCode.INVALID_INPUT_VALUE)
}
}
}
class LocalDateTimeSerializer : JsonSerializer<LocalDateTime>() { class LocalDateTimeSerializer : JsonSerializer<LocalDateTime>() {
override fun serialize( override fun serialize(
value: LocalDateTime, value: LocalDateTime,

View File

@ -1,11 +1,10 @@
package roomescape.common.config package roomescape.common.config
import org.slf4j.MDC
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.data.domain.AuditorAware import org.springframework.data.domain.AuditorAware
import org.springframework.data.jpa.repository.config.EnableJpaAuditing import org.springframework.data.jpa.repository.config.EnableJpaAuditing
import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY import roomescape.common.util.MdcPrincipalId
import java.util.* import java.util.*
@Configuration @Configuration
@ -17,13 +16,5 @@ class JpaConfig {
} }
class MdcAuditorAware : AuditorAware<Long> { class MdcAuditorAware : AuditorAware<Long> {
override fun getCurrentAuditor(): Optional<Long> { override fun getCurrentAuditor(): Optional<Long> = MdcPrincipalId.extractAsOptionalLongOrEmpty()
val memberIdStr: String? = MDC.get(MDC_PRINCIPAL_ID_KEY)
if (memberIdStr == null) {
return Optional.empty()
} else {
return Optional.of(memberIdStr.toLong())
}
}
} }

View File

@ -5,25 +5,22 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import roomescape.auth.web.support.interceptors.AdminInterceptor import roomescape.auth.web.support.interceptors.AdminInterceptor
import roomescape.auth.web.support.interceptors.AuthenticatedInterceptor
import roomescape.auth.web.support.interceptors.UserInterceptor import roomescape.auth.web.support.interceptors.UserInterceptor
import roomescape.auth.web.support.resolver.CurrentUserContextResolver import roomescape.auth.web.support.resolver.UserContextResolver
@Configuration @Configuration
class WebMvcConfig( class WebMvcConfig(
private val adminInterceptor: AdminInterceptor, private val adminInterceptor: AdminInterceptor,
private val userInterceptor: UserInterceptor, private val userInterceptor: UserInterceptor,
private val authenticatedInterceptor: AuthenticatedInterceptor, private val userContextResolver: UserContextResolver,
private val currentUserContextResolver: CurrentUserContextResolver
) : WebMvcConfigurer { ) : WebMvcConfigurer {
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) { override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
resolvers.add(currentUserContextResolver) resolvers.add(userContextResolver)
} }
override fun addInterceptors(registry: InterceptorRegistry) { override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(adminInterceptor) registry.addInterceptor(adminInterceptor)
registry.addInterceptor(userInterceptor) registry.addInterceptor(userInterceptor)
registry.addInterceptor(authenticatedInterceptor)
} }
} }

View File

@ -0,0 +1,22 @@
package roomescape.common.dto
import java.time.LocalDateTime
object AuditConstant {
val UNKNOWN_OPERATOR = OperatorInfo(
id = 0,
name = "unknown"
)
}
data class OperatorInfo(
val id: Long,
val name: String,
)
data class AuditInfo(
val createdAt: LocalDateTime,
val createdBy: OperatorInfo,
val updatedAt: LocalDateTime,
val updatedBy: OperatorInfo,
)

View File

@ -1,36 +1,69 @@
package roomescape.common.dto package roomescape.common.dto
import roomescape.admin.infrastructure.persistence.AdminEntity
import roomescape.admin.infrastructure.persistence.AdminPermissionLevel import roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.auth.web.AdminLoginSuccessResponse
import roomescape.auth.web.LoginSuccessResponse
import roomescape.auth.web.UserLoginSuccessResponse
import roomescape.user.infrastructure.persistence.UserEntity
const val MDC_PRINCIPAL_ID_KEY: String = "principal_id"
abstract class LoginCredentials { abstract class LoginCredentials {
abstract val id: Long abstract val id: Long
abstract val password: String abstract val password: String
abstract val name: String
abstract fun toResponse(accessToken: String): LoginSuccessResponse
} }
data class AdminLoginCredentials( data class AdminLoginCredentials(
override val id: Long, override val id: Long,
override val password: String, override val password: String,
val permissionLevel: AdminPermissionLevel override val name: String,
) : LoginCredentials() val type: AdminType,
val storeId: Long?,
val permissionLevel: AdminPermissionLevel,
) : LoginCredentials() {
override fun toResponse(accessToken: String) = AdminLoginSuccessResponse(
accessToken = accessToken,
name = name,
type = type,
storeId = storeId
)
}
fun AdminEntity.toCredentials() = AdminLoginCredentials(
id = this.id,
password = this.password,
name = this.name,
type = this.type,
storeId = this.storeId,
permissionLevel = this.permissionLevel
)
data class UserLoginCredentials( data class UserLoginCredentials(
override val id: Long, override val id: Long,
override val password: String, override val password: String,
) : LoginCredentials() override val name: String,
) : LoginCredentials() {
override fun toResponse(accessToken: String) = UserLoginSuccessResponse(
accessToken = accessToken,
name = name
)
}
data class CurrentUserContext( fun UserEntity.toCredentials() = UserLoginCredentials(
val id: Long, id = this.id,
val name: String, password = this.password,
val type: PrincipalType name = this.name,
); )
enum class PrincipalType { enum class PrincipalType {
USER, ADMIN USER, ADMIN
} }
data class OperatorInfo( data class CurrentUserContext(
val id: Long, val id: Long,
val name: String val name: String,
) )

View File

@ -2,8 +2,7 @@ package roomescape.common.log
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.slf4j.MDC import roomescape.common.util.MdcPrincipalId
import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY
enum class LogType { enum class LogType {
INCOMING_HTTP_REQUEST, INCOMING_HTTP_REQUEST,
@ -34,7 +33,7 @@ class ApiLogMessageConverter(
controllerPayload: Map<String, Any>, controllerPayload: Map<String, Any>,
): String { ): String {
val payload: MutableMap<String, Any> = commonRequestPayload(LogType.CONTROLLER_INVOKED, request) val payload: MutableMap<String, Any> = commonRequestPayload(LogType.CONTROLLER_INVOKED, request)
val memberId: Long? = MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLong() val memberId: Long? = MdcPrincipalId.extractAsLongOrNull()
if (memberId != null) payload["principal_id"] = memberId else payload["principal_id"] = "NONE" if (memberId != null) payload["principal_id"] = memberId else payload["principal_id"] = "NONE"
payload.putAll(controllerPayload) payload.putAll(controllerPayload)
@ -48,7 +47,7 @@ class ApiLogMessageConverter(
payload["endpoint"] = request.endpoint payload["endpoint"] = request.endpoint
payload["status_code"] = request.httpStatus payload["status_code"] = request.httpStatus
MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLongOrNull() MdcPrincipalId.extractAsLongOrNull()
?.let { payload["principal_id"] = it } ?.let { payload["principal_id"] = it }
?: run { payload["principal_id"] = "NONE" } ?: run { payload["principal_id"] = "NONE" }

View File

@ -9,6 +9,7 @@ import org.slf4j.MDC
import org.springframework.web.filter.OncePerRequestFilter import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.web.util.ContentCachingRequestWrapper import org.springframework.web.util.ContentCachingRequestWrapper
import org.springframework.web.util.ContentCachingResponseWrapper import org.springframework.web.util.ContentCachingResponseWrapper
import roomescape.common.util.MdcPrincipalId
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -33,7 +34,7 @@ class HttpRequestLoggingFilter(
cachedResponse.copyBodyToResponse() cachedResponse.copyBodyToResponse()
} finally { } finally {
MDC.remove("startTime") MDC.remove("startTime")
MDC.remove("member_id") MdcPrincipalId.clear()
} }
} }
} }

View File

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

View File

@ -7,7 +7,7 @@ import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.CurrentUser import roomescape.auth.web.support.User
import roomescape.auth.web.support.UserOnly import roomescape.auth.web.support.UserOnly
import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
@ -18,18 +18,17 @@ import roomescape.payment.web.PaymentCreateResponse
interface PaymentAPI { interface PaymentAPI {
@UserOnly @UserOnly
@Operation(summary = "결제 승인", tags = ["로그인이 필요한 API"]) @Operation(summary = "결제 승인")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun confirmPayment( fun confirmPayment(
@RequestParam(required = true) reservationId: Long, @RequestParam(required = true) reservationId: Long,
@Valid @RequestBody request: PaymentConfirmRequest @Valid @RequestBody request: PaymentConfirmRequest
): ResponseEntity<CommonApiResponse<PaymentCreateResponse>> ): ResponseEntity<CommonApiResponse<PaymentCreateResponse>>
@UserOnly @Operation(summary = "결제 취소")
@Operation(summary = "결제 취소", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun cancelPayment( fun cancelPayment(
@CurrentUser user: CurrentUserContext, @User user: CurrentUserContext,
@Valid @RequestBody request: PaymentCancelRequest @Valid @RequestBody request: PaymentCancelRequest
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
} }

View File

@ -29,13 +29,13 @@ class TosspayClient(
orderId: String, orderId: String,
amount: Int, amount: Int,
): PaymentClientConfirmResponse { ): PaymentClientConfirmResponse {
val startTime = System.currentTimeMillis()
log.info { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" } log.info { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" }
return confirmClient.request(paymentKey, orderId, amount) return confirmClient.request(paymentKey, orderId, amount)
.also { .also {
log.info { "[TosspayClient.confirm] 결제 승인 완료: response=$it" } log.info { "[TosspayClient.confirm] 결제 승인 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" }
} }
} }
fun cancel( fun cancel(
@ -43,10 +43,11 @@ class TosspayClient(
amount: Int, amount: Int,
cancelReason: String cancelReason: String
): PaymentClientCancelResponse { ): PaymentClientCancelResponse {
val startTime = System.currentTimeMillis()
log.info { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" } log.info { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" }
return cancelClient.request(paymentKey, amount, cancelReason).also { return cancelClient.request(paymentKey, amount, cancelReason).also {
log.info { "[TosspayClient.cancel] 결제 취소 완료: response=$it" } log.info { "[TosspayClient.cancel] 결제 취소 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" }
} }
} }
} }

View File

@ -2,12 +2,8 @@ package roomescape.payment.web
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.*
import org.springframework.web.bind.annotation.RequestBody import roomescape.auth.web.support.User
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import roomescape.auth.web.support.CurrentUser
import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.payment.business.PaymentService import roomescape.payment.business.PaymentService
@ -31,7 +27,7 @@ class PaymentController(
@PostMapping("/cancel") @PostMapping("/cancel")
override fun cancelPayment( override fun cancelPayment(
@CurrentUser user: CurrentUserContext, @User user: CurrentUserContext,
@Valid @RequestBody request: PaymentCancelRequest @Valid @RequestBody request: PaymentCancelRequest
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
paymentService.cancel(user.id, request) paymentService.cancel(user.id, request)

View File

@ -0,0 +1,73 @@
package roomescape.region.business
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.region.exception.RegionErrorCode
import roomescape.region.exception.RegionException
import roomescape.region.infrastructure.persistence.RegionRepository
import roomescape.region.web.*
private val log: KLogger = KotlinLogging.logger {}
@Service
class RegionService(
private val regionRepository: RegionRepository
) {
@Transactional(readOnly = true)
fun readAllSido(): SidoListResponse {
log.info { "[RegionService.readAllSido] 모든 시/도 조회 시작" }
val result: List<Pair<String, String>> = regionRepository.readAllSido()
if (result.isEmpty()) {
log.warn { "[RegionService.readAllSido] 시/도 조회 실패" }
throw RegionException(RegionErrorCode.SIDO_CODE_NOT_FOUND)
}
return SidoListResponse(result.map { SidoResponse(code = it.first, name = it.second) }).also {
log.info { "[RegionService.readAllSido] ${it.sidoList.size}개의 시/도 조회 완료" }
}
}
@Transactional(readOnly = true)
fun findSigunguBySido(sidoCode: String): SigunguListResponse {
log.info { "[RegionService.findSigunguBySido] 시/군/구 조회 시작: sidoCode=${sidoCode}" }
val result: List<Pair<String, String>> = regionRepository.findAllSigunguBySido(sidoCode)
if (result.isEmpty()) {
log.warn { "[RegionService.findSigunguBySido] 시/군/구 조회 실패: sidoCode=${sidoCode}" }
throw RegionException(RegionErrorCode.SIGUNGU_CODE_NOT_FOUND)
}
return SigunguListResponse(result.map { SigunguResponse(code = it.first, name = it.second) }).also {
log.info { "[RegionService.findSigunguBySido] sidoCode=${sidoCode}${it.sigunguList.size}개의 시/군/구 조회 완료" }
}
}
@Transactional(readOnly = true)
fun findRegionCode(sidoCode: String, sigunguCode: String): RegionCodeResponse {
log.info { "[RegionService.findRegionCode] 지역 코드 조회 시작: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
return regionRepository.findRegionCode(sidoCode, sigunguCode)?.let {
log.info { "[RegionService.findRegionCode] 지역 코드 조회 완료: code=${it} sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
RegionCodeResponse(it)
} ?: run {
log.warn { "[RegionService.findRegionCode] 지역 코드 조회 실패: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
throw RegionException(RegionErrorCode.REGION_CODE_NOT_FOUND)
}
}
@Transactional(readOnly = true)
fun findRegionInfo(regionCode: String): RegionInfoResponse {
log.info { "[RegionService.findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" }
return regionRepository.findByCode(regionCode)?.let {
log.info { "[RegionService.findRegionInfo] 지역 정보 조회 완료: code=${it} regionCode=${regionCode}" }
RegionInfoResponse(it.code, it.sidoName, it.sigunguName)
} ?: run {
log.warn { "[RegionService.findRegionInfo] 지역 정보 조회 실패: regionCode=${regionCode}" }
throw RegionException(RegionErrorCode.REGION_CODE_NOT_FOUND)
}
}
}

View File

@ -0,0 +1,35 @@
package roomescape.region.docs
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.Public
import roomescape.common.dto.response.CommonApiResponse
import roomescape.region.web.RegionCodeResponse
import roomescape.region.web.SidoListResponse
import roomescape.region.web.SigunguListResponse
interface RegionAPI {
@Public
@Operation(summary = "지역 코드 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findRegionCode(
@RequestParam(name = "sidoCode", required = true) sidoCode: String,
@RequestParam(name = "sigunguCode", required = true) sigunguCode: String,
): ResponseEntity<CommonApiResponse<RegionCodeResponse>>
@Public
@Operation(summary = "모든 시 / 도 목록 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun readAllSido(): ResponseEntity<CommonApiResponse<SidoListResponse>>
@Public
@Operation(summary = "모든 시 / 군 / 구 목록 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findAllSigunguBySido(
@RequestParam(required = true) sidoCode: String
): ResponseEntity<CommonApiResponse<SigunguListResponse>>
}

View File

@ -0,0 +1,21 @@
package roomescape.region.exception
import org.springframework.http.HttpStatus
import roomescape.common.exception.ErrorCode
import roomescape.common.exception.RoomescapeException
class RegionException(
override val errorCode: RegionErrorCode,
override val message: String = errorCode.message
) : RoomescapeException(errorCode, message)
enum class RegionErrorCode(
override val httpStatus: HttpStatus,
override val errorCode: String,
override val message: String
) : ErrorCode {
REGION_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "지역 코드를 찾을 수 없어요."),
SIDO_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "R002", "시/도 를 찾을 수 없어요."),
SIGUNGU_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "R003", "시/군/구 를 찾을 수 없어요."),
DONG_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "R004", "행정동을 찾을 수 없어요."),
}

View File

@ -3,16 +3,15 @@ package roomescape.region.infrastructure.persistence
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.Id import jakarta.persistence.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
@Entity @Entity
@Table(name = "region") @Table(name = "region", uniqueConstraints = [UniqueConstraint(columnNames = ["sidoCode", "sigunguCode"])])
class RegionEntity( class RegionEntity(
@Id @Id
val code: String, val code: String,
val sidoCode: String, val sidoCode: String,
val sigunguCode: String, val sigunguCode: String,
val dongCode: String,
val sidoName: String, val sidoName: String,
val sigunguName: String, val sigunguName: String,
val dongName: String,
) )

View File

@ -1,5 +1,56 @@
package roomescape.region.infrastructure.persistence package roomescape.region.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
interface RegionRepository : JpaRepository<RegionEntity, String> interface RegionRepository : JpaRepository<RegionEntity, String> {
@Query(
"""
SELECT DISTINCT
new kotlin.Pair(r.sidoCode, r.sidoName)
FROM
RegionEntity r
ORDER BY
r.sidoName
"""
)
fun readAllSido(): List<Pair<String, String>>
@Query(
"""
SELECT
new kotlin.Pair(r.sigunguCode, r.sigunguName)
FROM
RegionEntity r
WHERE
r.sidoCode = :sidoCode
GROUP BY
r.sigunguCode
ORDER BY
r.sigunguName
"""
)
fun findAllSigunguBySido(
@Param("sidoCode") sidoCode: String
): List<Pair<String, String>>
@Query(
"""
SELECT
r.code
FROM
RegionEntity r
WHERE
r.sidoCode = :sidoCode
AND r.sigunguCode = :sigunguCode
"""
)
fun findRegionCode(
@Param("sidoCode") sidoCode: String,
@Param("sigunguCode") sigunguCode: String,
): String?
fun findByCode(regionCode: String): RegionEntity?
}

View File

@ -0,0 +1,42 @@
package roomescape.region.web
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import roomescape.common.dto.response.CommonApiResponse
import roomescape.region.business.RegionService
import roomescape.region.docs.RegionAPI
@RestController
@RequestMapping("/regions")
class RegionController(
private val regionService: RegionService
) : RegionAPI {
@GetMapping("/code")
override fun findRegionCode(
@RequestParam(name = "sidoCode", required = true) sidoCode: String,
@RequestParam(name = "sigunguCode", required = true) sigunguCode: String,
): ResponseEntity<CommonApiResponse<RegionCodeResponse>> {
val response = regionService.findRegionCode(sidoCode, sigunguCode)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/sido")
override fun readAllSido(): ResponseEntity<CommonApiResponse<SidoListResponse>> {
val response = regionService.readAllSido()
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/sigungu")
override fun findAllSigunguBySido(
@RequestParam(required = true) sidoCode: String
): ResponseEntity<CommonApiResponse<SigunguListResponse>> {
val response = regionService.findSigunguBySido(sidoCode)
return ResponseEntity.ok(CommonApiResponse(response))
}
}

View File

@ -0,0 +1,29 @@
package roomescape.region.web
data class SidoResponse(
val code: String,
val name: String,
)
data class SidoListResponse(
val sidoList: List<SidoResponse>
)
data class SigunguResponse(
val code: String,
val name: String,
)
data class SigunguListResponse(
val sigunguList: List<SigunguResponse>
)
data class RegionCodeResponse(
val code: String
)
data class RegionInfoResponse(
val code: String,
val sidoName: String,
val sigunguName: String,
)

View File

@ -8,10 +8,7 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next import roomescape.common.config.next
import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.PrincipalType
import roomescape.common.util.DateUtils import roomescape.common.util.DateUtils
import roomescape.user.business.UserService
import roomescape.user.web.UserContactResponse
import roomescape.payment.business.PaymentService import roomescape.payment.business.PaymentService
import roomescape.payment.web.PaymentWithDetailResponse import roomescape.payment.web.PaymentWithDetailResponse
import roomescape.reservation.exception.ReservationErrorCode import roomescape.reservation.exception.ReservationErrorCode
@ -20,10 +17,11 @@ import roomescape.reservation.infrastructure.persistence.*
import roomescape.reservation.web.* import roomescape.reservation.web.*
import roomescape.schedule.business.ScheduleService import roomescape.schedule.business.ScheduleService
import roomescape.schedule.infrastructure.persistence.ScheduleStatus import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.schedule.web.ScheduleSummaryResponse import roomescape.schedule.web.ScheduleOverviewResponse
import roomescape.schedule.web.ScheduleUpdateRequest import roomescape.schedule.web.ScheduleUpdateRequest
import roomescape.theme.business.ThemeService import roomescape.theme.business.ThemeService
import roomescape.theme.web.ThemeInfoResponse import roomescape.user.business.UserService
import roomescape.user.web.UserContactResponse
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
@ -53,7 +51,7 @@ class ReservationService(
val reservation: ReservationEntity = request.toEntity(id = tsidFactory.next(), userId = user.id) val reservation: ReservationEntity = request.toEntity(id = tsidFactory.next(), userId = user.id)
return PendingReservationCreateResponse(reservationRepository.save(reservation).id) return PendingReservationCreateResponse(reservationRepository.save(reservation).id)
.also { "[ReservationService.createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } .also { log.info { "[ReservationService.createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } }
} }
@Transactional @Transactional
@ -91,7 +89,7 @@ class ReservationService(
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findUserSummaryReservation(user: CurrentUserContext): ReservationSummaryListResponse { fun findAllUserReservationOverview(user: CurrentUserContext): ReservationOverviewListResponse {
log.info { "[ReservationService.findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" } log.info { "[ReservationService.findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" }
val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn( val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn(
@ -99,17 +97,9 @@ class ReservationService(
statuses = listOf(ReservationStatus.CONFIRMED, ReservationStatus.CANCELED) statuses = listOf(ReservationStatus.CONFIRMED, ReservationStatus.CANCELED)
) )
return ReservationSummaryListResponse(reservations.map { return ReservationOverviewListResponse(reservations.map {
val schedule: ScheduleSummaryResponse = scheduleService.findSummaryById(it.scheduleId) val schedule: ScheduleOverviewResponse = scheduleService.findScheduleOverviewById(it.scheduleId)
val theme: ThemeInfoResponse = themeService.findSummaryById(schedule.themeId) it.toOverviewResponse(schedule)
ReservationSummaryResponse(
id = it.id,
themeName = theme.name,
date = schedule.date,
startAt = schedule.time,
status = it.status
)
}).also { }).also {
log.info { "[ReservationService.findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" } log.info { "[ReservationService.findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" }
} }
@ -164,7 +154,7 @@ class ReservationService(
reservation: ReservationEntity, reservation: ReservationEntity,
cancelReason: String cancelReason: String
) { ) {
if (user.type != PrincipalType.ADMIN && reservation.userId != user.id) { if (reservation.userId != user.id) {
log.warn { "[ReservationService.createCanceledPayment] 예약자 본인 또는 관리자가 아닌 회원의 취소 요청: reservationId=${reservation.id}, userId=${user.id}" } log.warn { "[ReservationService.createCanceledPayment] 예약자 본인 또는 관리자가 아닌 회원의 취소 요청: reservationId=${reservation.id}, userId=${user.id}" }
throw ReservationException(ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION) throw ReservationException(ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION)
} }
@ -175,7 +165,7 @@ class ReservationService(
canceledBy = user.id, canceledBy = user.id,
cancelReason = cancelReason, cancelReason = cancelReason,
canceledAt = LocalDateTime.now(), canceledAt = LocalDateTime.now(),
status = CanceledReservationStatus.PROCESSING status = CanceledReservationStatus.COMPLETED
).also { ).also {
canceledReservationRepository.save(it) canceledReservationRepository.save(it)
} }
@ -183,7 +173,7 @@ class ReservationService(
private fun validateCanCreate(request: PendingReservationCreateRequest) { private fun validateCanCreate(request: PendingReservationCreateRequest) {
val schedule = scheduleService.findSummaryById(request.scheduleId) val schedule = scheduleService.findSummaryById(request.scheduleId)
val theme = themeService.findSummaryById(schedule.themeId) val theme = themeService.findInfoById(schedule.themeId)
reservationValidator.validateCanCreate(schedule, theme, request) reservationValidator.validateCanCreate(schedule, theme, request)
} }

View File

@ -8,9 +8,8 @@ import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.Authenticated
import roomescape.auth.web.support.CurrentUser
import roomescape.auth.web.support.Public import roomescape.auth.web.support.Public
import roomescape.auth.web.support.User
import roomescape.auth.web.support.UserOnly import roomescape.auth.web.support.UserOnly
import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
@ -19,46 +18,43 @@ import roomescape.reservation.web.*
interface ReservationAPI { interface ReservationAPI {
@Public @Public
@Operation(summary = "결제 대기 예약 저장", tags = ["로그인이 필요한 API"]) @Operation(summary = "가장 많이 예약된 테마 ID 조회")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findMostReservedThemeIds( fun findMostReservedThemeIds(
@RequestParam count: Int @RequestParam count: Int
): ResponseEntity<CommonApiResponse<MostReservedThemeIdListResponse>> ): ResponseEntity<CommonApiResponse<MostReservedThemeIdListResponse>>
@UserOnly @Operation(summary = "결제 전 임시 예약 저장")
@Operation(summary = "결제 대기 예약 저장", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun createPendingReservation( fun createPendingReservation(
@CurrentUser user: CurrentUserContext, @User user: CurrentUserContext,
@Valid @RequestBody request: PendingReservationCreateRequest @Valid @RequestBody request: PendingReservationCreateRequest
): ResponseEntity<CommonApiResponse<PendingReservationCreateResponse>> ): ResponseEntity<CommonApiResponse<PendingReservationCreateResponse>>
@UserOnly @UserOnly
@Operation(summary = "예약 확정", tags = ["로그인이 필요한 API"]) @Operation(summary = "결제 후 임시 예약 확정")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200"))
fun confirmReservation( fun confirmReservation(
@PathVariable("id") id: Long @PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
@Authenticated @Operation(summary = "예약 취소")
@Operation(summary = "예약 취소", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200"))
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun cancelReservation( fun cancelReservation(
@CurrentUser user: CurrentUserContext, @User user: CurrentUserContext,
@PathVariable id: Long, @PathVariable id: Long,
@Valid @RequestBody request: ReservationCancelRequest @Valid @RequestBody request: ReservationCancelRequest
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
@UserOnly @Operation(summary = "회원별 예약 요약 목록 조회")
@Operation(summary = "회원별 예약 요약 목록 조회", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findOverviewByUser(
fun findSummaryByMemberId( @User user: CurrentUserContext,
@CurrentUser user: CurrentUserContext, ): ResponseEntity<CommonApiResponse<ReservationOverviewListResponse>>
): ResponseEntity<CommonApiResponse<ReservationSummaryListResponse>>
@UserOnly @UserOnly
@Operation(summary = "특정 예약에 대한 상세 조회", tags = ["로그인이 필요한 API"]) @Operation(summary = "특정 예약에 대한 상세 조회")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findDetailById( fun findDetailById(
@PathVariable("id") id: Long @PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<ReservationDetailResponse>> ): ResponseEntity<CommonApiResponse<ReservationDetailResponse>>

View File

@ -3,7 +3,7 @@ package roomescape.reservation.web
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import roomescape.auth.web.support.CurrentUser import roomescape.auth.web.support.User
import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.business.ReservationService import roomescape.reservation.business.ReservationService
@ -26,7 +26,7 @@ class ReservationController(
@PostMapping("/pending") @PostMapping("/pending")
override fun createPendingReservation( override fun createPendingReservation(
@CurrentUser user: CurrentUserContext, @User user: CurrentUserContext,
@Valid @RequestBody request: PendingReservationCreateRequest @Valid @RequestBody request: PendingReservationCreateRequest
): ResponseEntity<CommonApiResponse<PendingReservationCreateResponse>> { ): ResponseEntity<CommonApiResponse<PendingReservationCreateResponse>> {
val response = reservationService.createPendingReservation(user, request) val response = reservationService.createPendingReservation(user, request)
@ -45,7 +45,7 @@ class ReservationController(
@PostMapping("/{id}/cancel") @PostMapping("/{id}/cancel")
override fun cancelReservation( override fun cancelReservation(
@CurrentUser user: CurrentUserContext, @User user: CurrentUserContext,
@PathVariable id: Long, @PathVariable id: Long,
@Valid @RequestBody request: ReservationCancelRequest @Valid @RequestBody request: ReservationCancelRequest
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
@ -54,11 +54,11 @@ class ReservationController(
return ResponseEntity.ok().body(CommonApiResponse()) return ResponseEntity.ok().body(CommonApiResponse())
} }
@GetMapping("/summary") @GetMapping("/overview")
override fun findSummaryByMemberId( override fun findOverviewByUser(
@CurrentUser user: CurrentUserContext, @User user: CurrentUserContext,
): ResponseEntity<CommonApiResponse<ReservationSummaryListResponse>> { ): ResponseEntity<CommonApiResponse<ReservationOverviewListResponse>> {
val response = reservationService.findUserSummaryReservation(user) val response = reservationService.findAllUserReservationOverview(user)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }

View File

@ -1,10 +1,11 @@
package roomescape.reservation.web package roomescape.reservation.web
import jakarta.validation.constraints.NotEmpty import jakarta.validation.constraints.NotEmpty
import roomescape.user.web.UserContactResponse
import roomescape.payment.web.PaymentWithDetailResponse import roomescape.payment.web.PaymentWithDetailResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.schedule.web.ScheduleOverviewResponse
import roomescape.user.web.UserContactResponse
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
@ -34,20 +35,49 @@ data class PendingReservationCreateResponse(
val id: Long val id: Long
) )
data class ReservationSummaryResponse( data class ReservationOverviewResponse(
val id: Long, val id: Long,
val storeName: String,
val themeName: String, val themeName: String,
val date: LocalDate, val date: LocalDate,
val startAt: LocalTime, val startFrom: LocalTime,
val endAt: LocalTime,
val status: ReservationStatus val status: ReservationStatus
) )
data class ReservationSummaryListResponse( fun ReservationEntity.toOverviewResponse(
val reservations: List<ReservationSummaryResponse> schedule: ScheduleOverviewResponse
) = ReservationOverviewResponse(
id = this.id,
storeName = schedule.storeName,
themeName = schedule.themeName,
date = schedule.date,
startFrom = schedule.startFrom,
endAt = schedule.endAt,
status = this.status
)
data class ReservationOverviewListResponse(
val reservations: List<ReservationOverviewResponse>
)
data class ReserverInfo(
val name: String,
val contact: String,
val participantCount: Short,
val requirement: String
)
fun ReservationEntity.toReserverInfo() = ReserverInfo(
name = this.reserverName,
contact = this.reserverContact,
participantCount = this.participantCount,
requirement = this.requirement
) )
data class ReservationDetailResponse( data class ReservationDetailResponse(
val id: Long, val id: Long,
val reserver: ReserverInfo,
val user: UserContactResponse, val user: UserContactResponse,
val applicationDateTime: LocalDateTime, val applicationDateTime: LocalDateTime,
val payment: PaymentWithDetailResponse?, val payment: PaymentWithDetailResponse?,
@ -59,6 +89,7 @@ fun ReservationEntity.toReservationDetailRetrieveResponse(
): ReservationDetailResponse { ): ReservationDetailResponse {
return ReservationDetailResponse( return ReservationDetailResponse(
id = this.id, id = this.id,
reserver = this.toReserverInfo(),
user = user, user = user,
applicationDateTime = this.createdAt, applicationDateTime = this.createdAt,
payment = payment, payment = payment,

View File

@ -9,8 +9,12 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.admin.business.AdminService import roomescape.admin.business.AdminService
import roomescape.common.config.next import roomescape.common.config.next
import roomescape.common.dto.AuditInfo
import roomescape.common.dto.OperatorInfo
import roomescape.schedule.business.domain.ScheduleOverview
import roomescape.schedule.exception.ScheduleErrorCode import roomescape.schedule.exception.ScheduleErrorCode
import roomescape.schedule.infrastructure.persistence.ScheduleEntity import roomescape.schedule.infrastructure.persistence.ScheduleEntity
import roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory
import roomescape.schedule.infrastructure.persistence.ScheduleRepository import roomescape.schedule.infrastructure.persistence.ScheduleRepository
import roomescape.schedule.infrastructure.persistence.ScheduleStatus import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.schedule.web.* import roomescape.schedule.web.*
@ -18,6 +22,15 @@ import java.time.LocalDate
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
/**
* Structure:
* - Public: 모두가 접근 가능
* - User: 회원(로그인된 사용자) 사용 가능
* - All-Admin: 모든 관리자가 사용 가능
* - Store-Admin: 매장 관리자만 사용 가능
* - Other-Service: 다른 서비스에서 호출하는 메서드
* - Common: 공통 메서드
*/
@Service @Service
class ScheduleService( class ScheduleService(
private val scheduleRepository: ScheduleRepository, private val scheduleRepository: ScheduleRepository,
@ -25,73 +38,25 @@ class ScheduleService(
private val tsidFactory: TsidFactory, private val tsidFactory: TsidFactory,
private val adminService: AdminService private val adminService: AdminService
) { ) {
// ========================================
// Public (인증 불필요)
// ========================================
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findThemesByDate(date: LocalDate): AvailableThemeIdListResponse { fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse {
log.info { "[ScheduleService.findThemesByDate] 동일한 날짜의 모든 테마 조회: date=$date" } log.info { "[ScheduleService.getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" }
return AvailableThemeIdListResponse(scheduleRepository.findAllUniqueThemeIdByDate(date)) val schedules: List<ScheduleOverview> =
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date)
return schedules.toResponse()
.also { .also {
log.info { "[ScheduleService.findThemesByDate] date=${date}${it.themeIds.size}개 테마 조회 완료" } log.info { "[ScheduleService.getStoreScheduleByDate] storeId=${storeId}, date=$date${it.schedules.size}개 일정 조회 완료" }
}
}
@Transactional(readOnly = true)
fun findSchedules(date: LocalDate, themeId: Long): ScheduleListByDateResponse {
log.info { "[ScheduleService.findSchedules] 동일한 날짜와 테마인 모든 일정 조회: date=${date}, themeId=${themeId}" }
return scheduleRepository.findAllByDateAndThemeId(date, themeId)
.toListResponse()
.also {
log.info { "[ScheduleService.findSchedules] date=${date}, themeId=${themeId}${it.schedules.size}개 일정 조회 완료" }
}
}
@Transactional(readOnly = true)
fun findDetail(id: Long): ScheduleDetailResponse {
log.info { "[ScheduleService.findDetail] 일정 상세 정보조회 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id)
val createdBy = adminService.findOperatorById(schedule.createdBy)
val updatedBy = adminService.findOperatorById(schedule.updatedBy)
return schedule.toDetailResponse(createdBy, updatedBy)
.also {
log.info { "[ScheduleService.findDetail] 일정 상세 조회 완료: id=$id" }
}
}
@Transactional(readOnly = true)
fun findSummaryById(id: Long): ScheduleSummaryResponse {
log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 시작 : id=$id" }
return findOrThrow(id).toSummaryResponse()
.also {
log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 완료: id=$id" }
}
}
@Transactional
fun createSchedule(request: ScheduleCreateRequest): ScheduleCreateResponse {
log.info { "[ScheduleService.createSchedule] 일정 생성 시작: date=${request.date}, time=${request.time}, themeId=${request.themeId}" }
scheduleValidator.validateCanCreate(request)
val schedule = ScheduleEntity(
id = tsidFactory.next(),
date = request.date,
time = request.time,
themeId = request.themeId,
status = ScheduleStatus.AVAILABLE
)
return ScheduleCreateResponse(scheduleRepository.save(schedule).id)
.also {
log.info { "[ScheduleService.createSchedule] 일정 생성 완료: id=${it.id}" }
} }
} }
// ========================================
// User (회원 로그인 필요)
// ========================================
@Transactional @Transactional
fun holdSchedule(id: Long) { fun holdSchedule(id: Long) {
val schedule: ScheduleEntity = findOrThrow(id) val schedule: ScheduleEntity = findOrThrow(id)
@ -104,6 +69,64 @@ class ScheduleService(
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE) throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE)
} }
// ========================================
// All-Admin (본사, 매장 모두 사용가능)
// ========================================
@Transactional(readOnly = true)
fun searchSchedules(storeId: Long, date: LocalDate?, themeId: Long?): AdminScheduleSummaryListResponse {
log.info { "[ScheduleService.searchSchedules] 일정 검색 시작: storeId=$storeId, date=$date, themeId=$themeId" }
val searchDate = date ?: LocalDate.now()
val schedules: List<ScheduleOverview> =
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, searchDate)
.filter { (themeId == null) || (it.themeId == themeId) }
.sortedBy { it.time }
return schedules.toAdminSummaryListResponse()
.also {
log.info { "[ScheduleService.searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" }
}
}
@Transactional(readOnly = true)
fun findScheduleAudit(id: Long): AuditInfo {
log.info { "[ScheduleService.findDetail] 일정 감사 정보 조회 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id)
val createdBy: OperatorInfo = adminService.findOperatorOrUnknown(schedule.createdBy)
val updatedBy: OperatorInfo = adminService.findOperatorOrUnknown(schedule.updatedBy)
return AuditInfo(schedule.createdAt, createdBy, schedule.updatedAt, updatedBy)
.also { log.info { "[ScheduleService.findDetail] 일정 감사 정보 조회 완료: id=$id" } }
}
// ========================================
// Store-Admin (매장 관리자 로그인 필요)
// ========================================
@Transactional
fun createSchedule(storeId: Long, request: ScheduleCreateRequest): ScheduleCreateResponse {
log.info { "[ScheduleService.createSchedule] 일정 생성 시작: storeId=${storeId}, date=${request.date}, time=${request.time}, themeId=${request.themeId}" }
scheduleValidator.validateCanCreate(storeId, request)
val schedule = ScheduleEntityFactory.create(
id = tsidFactory.next(),
date = request.date,
time = request.time,
storeId = storeId,
themeId = request.themeId
).also {
scheduleRepository.save(it)
}
return ScheduleCreateResponse(schedule.id)
.also {
log.info { "[ScheduleService.createSchedule] 일정 생성 완료: id=${it.id}" }
}
}
@Transactional @Transactional
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) { fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" } log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
@ -113,14 +136,11 @@ class ScheduleService(
return return
} }
val schedule: ScheduleEntity = findOrThrow(id) val schedule: ScheduleEntity = findOrThrow(id).also {
scheduleValidator.validateCanUpdate(it, request)
}
scheduleValidator.validateCanUpdate(schedule, request) schedule.modifyIfNotNull(request.time, request.status).also {
schedule.modifyIfNotNull(
request.time,
request.status
).also {
log.info { "[ScheduleService.updateSchedule] 일정 수정 완료: id=$id, request=${request}" } log.info { "[ScheduleService.updateSchedule] 일정 수정 완료: id=$id, request=${request}" }
} }
} }
@ -129,15 +149,41 @@ class ScheduleService(
fun deleteSchedule(id: Long) { fun deleteSchedule(id: Long) {
log.info { "[ScheduleService.deleteSchedule] 일정 삭제 시작: id=$id" } log.info { "[ScheduleService.deleteSchedule] 일정 삭제 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id) val schedule: ScheduleEntity = findOrThrow(id).also {
scheduleValidator.validateCanDelete(it)
scheduleValidator.validateCanDelete(schedule) }
scheduleRepository.delete(schedule).also { scheduleRepository.delete(schedule).also {
log.info { "[ScheduleService.deleteSchedule] 일정 삭제 완료: id=$id" } log.info { "[ScheduleService.deleteSchedule] 일정 삭제 완료: id=$id" }
} }
} }
// ========================================
// Other-Service (API 없이 다른 서비스에서 호출)
// ========================================
@Transactional(readOnly = true)
fun findSummaryById(id: Long): ScheduleSummaryResponse {
log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 시작 : id=$id" }
return findOrThrow(id).toSummaryResponse()
.also {
log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 완료: id=$id" }
}
}
@Transactional(readOnly = true)
fun findScheduleOverviewById(id: Long): ScheduleOverviewResponse {
val overview: ScheduleOverview = scheduleRepository.findOverviewByIdOrNull(id) ?: run {
log.warn { "[ScheduleService.findScheduleOverview] 일정 개요 조회 실패: id=$id" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
}
return overview.toOverviewResponse()
}
// ========================================
// Common (공통 메서드)
// ========================================
private fun findOrThrow(id: Long): ScheduleEntity { private fun findOrThrow(id: Long): ScheduleEntity {
log.info { "[ScheduleService.findOrThrow] 일정 조회 시작: id=$id" } log.info { "[ScheduleService.findOrThrow] 일정 조회 시작: id=$id" }

View File

@ -33,20 +33,21 @@ class ScheduleValidator(
val date: LocalDate = schedule.date val date: LocalDate = schedule.date
val time: LocalTime = request.time ?: schedule.time val time: LocalTime = request.time ?: schedule.time
validateDateTime(date, time) validateNotInPast(date, time)
} }
fun validateCanCreate(request: ScheduleCreateRequest) { fun validateCanCreate(storeId: Long, request: ScheduleCreateRequest) {
val date: LocalDate = request.date val date: LocalDate = request.date
val time: LocalTime = request.time val time: LocalTime = request.time
val themeId: Long = request.themeId val themeId: Long = request.themeId
validateAlreadyExists(date, themeId, time) validateAlreadyExists(storeId, date, themeId, time)
validateDateTime(date, time) validateNotInPast(date, time)
validateTimeNotConflict(storeId, request.date, request.themeId, request.time)
} }
private fun validateAlreadyExists(date: LocalDate, themeId: Long, time: LocalTime) { private fun validateAlreadyExists(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime) {
if (scheduleRepository.existsByDateAndThemeIdAndTime(date, themeId, time)) { if (scheduleRepository.existsDuplicate(storeId, date, themeId, time)) {
log.info { log.info {
"[ScheduleValidator.validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}" "[ScheduleValidator.validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}"
} }
@ -54,7 +55,7 @@ class ScheduleValidator(
} }
} }
private fun validateDateTime(date: LocalDate, time: LocalTime) { private fun validateNotInPast(date: LocalDate, time: LocalTime) {
val dateTime = LocalDateTime.of(date, time) val dateTime = LocalDateTime.of(date, time)
if (dateTime.isBefore(LocalDateTime.now())) { if (dateTime.isBefore(LocalDateTime.now())) {
@ -64,4 +65,13 @@ class ScheduleValidator(
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME) throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
} }
} }
private fun validateTimeNotConflict(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime) {
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date, themeId)
.firstOrNull { it.containsTime(time) }
?.let {
log.info { "[ScheduleValidator.validateTimeNotConflict] 시간이 겹치는 일정 존재: conflictSchedule(Id=${it.id}, time=${it.time}~${it.getEndAt()})" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_TIME_CONFLICT)
}
}
} }

View File

@ -0,0 +1,30 @@
package roomescape.schedule.business.domain
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.theme.infrastructure.persistence.Difficulty
import java.time.LocalDate
import java.time.LocalTime
class ScheduleOverview(
val id: Long,
val storeId: Long,
val storeName: String,
val date: LocalDate,
val time: LocalTime,
val themeId: Long,
val themeName: String,
val themeDifficulty: Difficulty,
val themeAvailableMinutes: Short,
val status: ScheduleStatus
) {
fun getEndAt(): LocalTime {
return time.plusMinutes(themeAvailableMinutes.toLong())
}
fun containsTime(targetTime: LocalTime): Boolean {
val startFrom = this.time
val endAt = getEndAt()
return targetTime >= startFrom && targetTime < endAt
}
}

View File

@ -9,76 +9,73 @@ import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.admin.infrastructure.persistence.Privilege import roomescape.admin.infrastructure.persistence.Privilege
import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.AdminOnly
import roomescape.auth.web.support.Public import roomescape.auth.web.support.Public
import roomescape.auth.web.support.UserOnly import roomescape.auth.web.support.UserOnly
import roomescape.common.dto.AuditInfo
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.schedule.web.* import roomescape.schedule.web.*
import java.time.LocalDate import java.time.LocalDate
interface ScheduleAPI { interface AdminScheduleAPI {
@Public @AdminOnly(privilege = Privilege.READ_SUMMARY)
@Operation(summary = "입력된 날짜에 가능한 테마 목록 조회") @Operation(summary = "관리자 페이지에서 일정 요약 목록 조회")
@ApiResponses(ApiResponse(responseCode = "200", description = "입력된 날짜에 가능한 테마 목록 조회", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findAvailableThemes( fun searchSchedules(
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate @PathVariable("storeId") storeId: Long,
): ResponseEntity<CommonApiResponse<AvailableThemeIdListResponse>> @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate?,
@RequestParam(required = false) themeId: Long?,
@Public ): ResponseEntity<CommonApiResponse<AdminScheduleSummaryListResponse>>
@Operation(summary = "입력된 날짜, 테마에 대한 모든 시간 조회")
@ApiResponses(
ApiResponse(
responseCode = "200",
description = "입력된 날짜, 테마에 대한 모든 시간 조회",
useReturnTypeSchema = true
)
)
fun findAllTime(
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate,
@RequestParam("themeId") themeId: Long
): ResponseEntity<CommonApiResponse<ScheduleListByDateResponse>>
@UserOnly
@Operation(summary = "일정을 Hold 상태로 변경", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(
responseCode = "200",
description = "일정을 Hold 상태로 변경하여 중복 예약 방지",
useReturnTypeSchema = true
)
)
fun holdSchedule(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>>
@AdminOnly(privilege = Privilege.READ_DETAIL) @AdminOnly(privilege = Privilege.READ_DETAIL)
@Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "관지라 페이지에서 특정 예약의 일정 상세 조회")
@ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findScheduleDetail( fun findScheduleAudit(
@PathVariable("id") id: Long @PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<ScheduleDetailResponse>> ): ResponseEntity<CommonApiResponse<AuditInfo>>
@AdminOnly(privilege = Privilege.CREATE) @AdminOnly(type = AdminType.STORE, privilege = Privilege.CREATE)
@Operation(summary = "일정 생성", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "일정 생성")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun createSchedule( fun createSchedule(
@PathVariable("storeId") storeId: Long,
@Valid @RequestBody request: ScheduleCreateRequest @Valid @RequestBody request: ScheduleCreateRequest
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>> ): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>>
@AdminOnly(privilege = Privilege.UPDATE) @AdminOnly(type = AdminType.STORE, privilege = Privilege.UPDATE)
@Operation(summary = "일정 수정", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "일정 수정")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200"))
fun updateSchedule( fun updateSchedule(
@PathVariable("id") id: Long, @PathVariable("id") id: Long,
@Valid @RequestBody request: ScheduleUpdateRequest @Valid @RequestBody request: ScheduleUpdateRequest
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
@AdminOnly(privilege = Privilege.DELETE) @AdminOnly(type = AdminType.STORE, privilege = Privilege.DELETE)
@Operation(summary = "일정 삭제", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "일정 삭제")
@ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "204"))
fun deleteSchedule( fun deleteSchedule(
@PathVariable("id") id: Long @PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
} }
interface UserScheduleAPI {
@UserOnly
@Operation(summary = "중복 방지를 위해 일정을 Hold 상태로 변경")
@ApiResponses(ApiResponse(responseCode = "200"))
fun holdSchedule(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>>
}
interface PublicScheduleAPI {
@Public
@Operation(summary = "특정 날짜 + 매장의 모든 일정 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun getStoreSchedulesByDate(
@PathVariable("storeId") storeId: Long,
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate
): ResponseEntity<CommonApiResponse<ScheduleWithThemeListResponse>>
}

View File

@ -12,5 +12,6 @@ enum class ScheduleErrorCode(
SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "S002", "이미 동일한 일정이 있어요."), SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "S002", "이미 동일한 일정이 있어요."),
PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."), PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."),
SCHEDULE_IN_USE(HttpStatus.CONFLICT, "S004", "예약이 진행중이거나 완료된 일정은 삭제할 수 없어요."), SCHEDULE_IN_USE(HttpStatus.CONFLICT, "S004", "예약이 진행중이거나 완료된 일정은 삭제할 수 없어요."),
SCHEDULE_NOT_AVAILABLE(HttpStatus.CONFLICT, "S005", "예약이 완료되었거나 예약할 수 없는 일정이에요.") SCHEDULE_NOT_AVAILABLE(HttpStatus.CONFLICT, "S005", "예약이 완료되었거나 예약할 수 없는 일정이에요."),
SCHEDULE_TIME_CONFLICT(HttpStatus.CONFLICT, "S006", "시간이 겹치는 다른 일정이 있어요.")
} }

View File

@ -1,22 +1,43 @@
package roomescape.schedule.infrastructure.persistence package roomescape.schedule.infrastructure.persistence
import jakarta.persistence.* import jakarta.persistence.*
import roomescape.common.entity.AuditingBaseEntity import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import roomescape.common.entity.PersistableBaseEntity
import roomescape.common.util.MdcPrincipalId
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
@Entity @Entity
@Table(name = "schedule", uniqueConstraints = [UniqueConstraint(columnNames = ["date", "time", "theme_id"])]) @EntityListeners(AuditingEntityListener::class)
@Table(name = "schedule", uniqueConstraints = [UniqueConstraint(columnNames = ["storeId", "date", "time", "theme_id"])])
class ScheduleEntity( class ScheduleEntity(
id: Long, id: Long,
var date: LocalDate, var date: LocalDate,
var time: LocalTime, var time: LocalTime,
val storeId: Long,
var themeId: Long, var themeId: Long,
@Enumerated(value = EnumType.STRING) @Enumerated(value = EnumType.STRING)
var status: ScheduleStatus var status: ScheduleStatus,
) : AuditingBaseEntity(id) { ) : PersistableBaseEntity(id) {
@Column(updatable = false)
@CreatedDate
lateinit var createdAt: LocalDateTime
@Column(updatable = false)
@CreatedBy
var createdBy: Long = 0L
@Column
@LastModifiedDate
lateinit var updatedAt: LocalDateTime
var updatedBy: Long = 0L
fun modifyIfNotNull( fun modifyIfNotNull(
time: LocalTime?, time: LocalTime?,
@ -24,11 +45,29 @@ class ScheduleEntity(
) { ) {
time?.let { this.time = it } time?.let { this.time = it }
status?.let { this.status = it } status?.let { this.status = it }
updateLastModifiedBy()
} }
fun hold() { fun hold() {
this.status = ScheduleStatus.HOLD this.status = ScheduleStatus.HOLD
} }
fun updateLastModifiedBy() {
MdcPrincipalId.extractAsLongOrNull()?.also { this.updatedBy = it }
}
}
object ScheduleEntityFactory {
fun create(id: Long, date: LocalDate, time: LocalTime, storeId: Long, themeId: Long): ScheduleEntity {
return ScheduleEntity(
id = id,
date = date,
time = time.withSecond(0).withNano(0),
storeId = storeId,
themeId = themeId,
status = ScheduleStatus.AVAILABLE
).apply { this.updateLastModifiedBy() }
}
} }
enum class ScheduleStatus { enum class ScheduleStatus {

View File

@ -2,23 +2,80 @@ package roomescape.schedule.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import roomescape.schedule.business.domain.ScheduleOverview
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> { interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
fun findAllByDate(date: LocalDate): List<ScheduleEntity> @Query("""
SELECT
fun findAllByDateAndThemeId(date: LocalDate, themeId: Long): List<ScheduleEntity> COUNT(s) > 0
FROM
fun existsByDateAndThemeIdAndTime(date: LocalDate, themeId: Long, time: LocalTime): Boolean ScheduleEntity s
WHERE
s.storeId = :storeId
AND s.date = :date
AND s.themeId = :themeId
AND s.time = :time
""")
fun existsDuplicate(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime): Boolean
@Query( @Query(
""" """
SELECT DISTINCT s.themeId SELECT
FROM ScheduleEntity s new roomescape.schedule.business.domain.ScheduleOverview(
WHERE s.date = :date s._id,
st._id,
st.name,
s.date,
s.time,
t._id,
t.name,
t.difficulty,
t.availableMinutes,
s.status
)
FROM
ScheduleEntity s
JOIN
ThemeEntity t ON t._id = s.themeId
JOIN
StoreEntity st ON st._id = s.storeId
WHERE
s.storeId = :storeId
AND s.date = :date
AND (:themeId IS NULL OR s.themeId = :themeId)
""" """
) )
fun findAllUniqueThemeIdByDate(date: LocalDate): List<Long> fun findStoreSchedulesWithThemeByDate(
storeId: Long,
date: LocalDate,
themeId: Long? = null
): List<ScheduleOverview>
@Query("""
SELECT
new roomescape.schedule.business.domain.ScheduleOverview(
s._id,
st._id,
st.name,
s.date,
s.time,
t._id,
t.name,
t.difficulty,
t.availableMinutes,
s.status
)
FROM
ScheduleEntity s
JOIN
ThemeEntity t ON t._id = s.themeId
JOIN
StoreEntity st ON st._id = s.storeId
WHERE
s._id = :id
""")
fun findOverviewByIdOrNull(id: Long): ScheduleOverview?
} }

View File

@ -0,0 +1,66 @@
package roomescape.schedule.web
import jakarta.validation.Valid
import org.springframework.format.annotation.DateTimeFormat
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import roomescape.common.dto.AuditInfo
import roomescape.common.dto.response.CommonApiResponse
import roomescape.schedule.business.ScheduleService
import roomescape.schedule.docs.AdminScheduleAPI
import java.time.LocalDate
@RestController
@RequestMapping("/admin")
class AdminScheduleController(
private val scheduleService: ScheduleService,
) : AdminScheduleAPI {
@GetMapping("/stores/{storeId}/schedules")
override fun searchSchedules(
@PathVariable("storeId") storeId: Long,
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate?,
@RequestParam(required = false) themeId: Long?,
): ResponseEntity<CommonApiResponse<AdminScheduleSummaryListResponse>> {
val response = scheduleService.searchSchedules(storeId, date, themeId)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/schedules/{id}/audits")
override fun findScheduleAudit(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<AuditInfo>> {
val response = scheduleService.findScheduleAudit(id)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping("/stores/{storeId}/schedules")
override fun createSchedule(
@PathVariable("storeId") storeId: Long,
@Valid @RequestBody request: ScheduleCreateRequest
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>> {
val response = scheduleService.createSchedule(storeId, request)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PatchMapping("/schedules/{id}")
override fun updateSchedule(
@PathVariable("id") id: Long,
@Valid @RequestBody request: ScheduleUpdateRequest
): ResponseEntity<CommonApiResponse<Unit>> {
scheduleService.updateSchedule(id, request)
return ResponseEntity.ok(CommonApiResponse(Unit))
}
@DeleteMapping("/schedules/{id}")
override fun deleteSchedule(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>> {
scheduleService.deleteSchedule(id)
return ResponseEntity.noContent().build()
}
}

View File

@ -0,0 +1,55 @@
package roomescape.schedule.web
import roomescape.schedule.business.domain.ScheduleOverview
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import java.time.LocalDate
import java.time.LocalTime
// ========================================
// All-Admin DTO (본사 + 매장)
// ========================================
data class AdminScheduleSummaryResponse(
val id: Long,
val themeName: String,
val startFrom: LocalTime,
val endAt: LocalTime,
val status: ScheduleStatus,
)
fun ScheduleOverview.toAdminSummaryResponse() = AdminScheduleSummaryResponse(
id = this.id,
themeName = this.themeName,
startFrom = this.time,
endAt = this.getEndAt(),
status = this.status
)
data class AdminScheduleSummaryListResponse(
val schedules: List<AdminScheduleSummaryResponse>
)
fun List<ScheduleOverview>.toAdminSummaryListResponse() = AdminScheduleSummaryListResponse(
this.map { it.toAdminSummaryResponse() }
)
// ========================================
// Store Admin DTO (매장)
// ========================================
data class ScheduleCreateRequest(
val date: LocalDate,
val time: LocalTime,
val themeId: Long
)
data class ScheduleCreateResponse(
val id: Long
)
data class ScheduleUpdateRequest(
val time: LocalTime? = null,
val status: ScheduleStatus? = null
) {
fun isAllParamsNull(): Boolean {
return time == null && status == null
}
}

View File

@ -1,57 +1,20 @@
package roomescape.schedule.web package roomescape.schedule.web
import jakarta.validation.Valid
import org.springframework.format.annotation.DateTimeFormat import org.springframework.format.annotation.DateTimeFormat
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.schedule.business.ScheduleService import roomescape.schedule.business.ScheduleService
import roomescape.schedule.docs.ScheduleAPI import roomescape.schedule.docs.PublicScheduleAPI
import roomescape.schedule.docs.UserScheduleAPI
import java.time.LocalDate import java.time.LocalDate
@RestController @RestController
@RequestMapping("/schedules")
class ScheduleController( class ScheduleController(
private val scheduleService: ScheduleService private val scheduleService: ScheduleService
) : ScheduleAPI { ) : UserScheduleAPI, PublicScheduleAPI {
@GetMapping("/themes")
override fun findAvailableThemes(
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate
): ResponseEntity<CommonApiResponse<AvailableThemeIdListResponse>> {
val response = scheduleService.findThemesByDate(date)
return ResponseEntity.ok(CommonApiResponse(response)) @PostMapping("/schedules/{id}/hold")
}
@GetMapping
override fun findAllTime(
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate,
@RequestParam("themeId") themeId: Long
): ResponseEntity<CommonApiResponse<ScheduleListByDateResponse>> {
val response = scheduleService.findSchedules(date, themeId)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/{id}")
override fun findScheduleDetail(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<ScheduleDetailResponse>> {
val response = scheduleService.findDetail(id)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping
override fun createSchedule(
@Valid @RequestBody request: ScheduleCreateRequest
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>> {
val response = scheduleService.createSchedule(request)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PatchMapping("/{id}/hold")
override fun holdSchedule( override fun holdSchedule(
@PathVariable("id") id: Long @PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
@ -60,22 +23,13 @@ class ScheduleController(
return ResponseEntity.ok(CommonApiResponse()) return ResponseEntity.ok(CommonApiResponse())
} }
@PatchMapping("/{id}") @GetMapping("/stores/{storeId}/schedules")
override fun updateSchedule( override fun getStoreSchedulesByDate(
@PathVariable("id") id: Long, @PathVariable("storeId") storeId: Long,
@Valid @RequestBody request: ScheduleUpdateRequest @RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<ScheduleWithThemeListResponse>> {
scheduleService.updateSchedule(id, request) val response = scheduleService.getStoreScheduleByDate(storeId, date)
return ResponseEntity.ok(CommonApiResponse(Unit)) return ResponseEntity.ok(CommonApiResponse(response))
}
@DeleteMapping("/{id}")
override fun deleteSchedule(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>> {
scheduleService.deleteSchedule(id)
return ResponseEntity.noContent().build()
} }
} }

View File

@ -1,71 +1,46 @@
package roomescape.schedule.web package roomescape.schedule.web
import roomescape.common.dto.OperatorInfo import roomescape.schedule.business.domain.ScheduleOverview
import roomescape.schedule.infrastructure.persistence.ScheduleEntity import roomescape.schedule.infrastructure.persistence.ScheduleEntity
import roomescape.schedule.infrastructure.persistence.ScheduleStatus import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.theme.infrastructure.persistence.Difficulty
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
data class AvailableThemeIdListResponse( // ========================================
val themeIds: List<Long> // Public (인증 불필요)
) // ========================================
data class ScheduleWithThemeResponse(
data class ScheduleByDateResponse(
val id: Long, val id: Long,
val time: LocalTime, val startFrom: LocalTime,
val endAt: LocalTime,
val themeId: Long,
val themeName: String,
val themeDifficulty: Difficulty,
val status: ScheduleStatus val status: ScheduleStatus
) )
data class ScheduleListByDateResponse( fun ScheduleOverview.toResponse() = ScheduleWithThemeResponse(
val schedules: List<ScheduleByDateResponse>
)
fun List<ScheduleEntity>.toListResponse() = ScheduleListByDateResponse(
this.map { ScheduleByDateResponse(it.id, it.time, it.status) }
)
data class ScheduleCreateRequest(
val date: LocalDate,
val time: LocalTime,
val themeId: Long
)
data class ScheduleCreateResponse(
val id: Long
)
data class ScheduleUpdateRequest(
val time: LocalTime? = null,
val status: ScheduleStatus? = null
) {
fun isAllParamsNull(): Boolean {
return time == null && status == null
}
}
data class ScheduleDetailResponse(
val id: Long,
val date: LocalDate,
val time: LocalTime,
val status: ScheduleStatus,
val createdAt: LocalDateTime,
val createdBy: OperatorInfo,
val updatedAt: LocalDateTime,
val updatedBy: OperatorInfo,
)
fun ScheduleEntity.toDetailResponse(createdBy: OperatorInfo, updatedBy: OperatorInfo) = ScheduleDetailResponse(
id = this.id, id = this.id,
date = this.date, startFrom = this.time,
time = this.time, endAt = this.getEndAt(),
status = this.status, themeId = this.themeId,
createdAt = this.createdAt, themeName = this.themeName,
createdBy = createdBy, themeDifficulty = this.themeDifficulty,
updatedAt = this.updatedAt, status = this.status
updatedBy = updatedBy
) )
data class ScheduleWithThemeListResponse(
val schedules: List<ScheduleWithThemeResponse>
)
fun List<ScheduleOverview>.toResponse() = ScheduleWithThemeListResponse(
this.map { it.toResponse() }
)
// ========================================
// Other-Service (API 없이 다른 서비스에서 호출)
// ========================================
data class ScheduleSummaryResponse( data class ScheduleSummaryResponse(
val date: LocalDate, val date: LocalDate,
val time: LocalTime, val time: LocalTime,
@ -79,3 +54,25 @@ fun ScheduleEntity.toSummaryResponse() = ScheduleSummaryResponse(
themeId = this.themeId, themeId = this.themeId,
status = this.status status = this.status
) )
data class ScheduleOverviewResponse(
val id: Long,
val storeId: Long,
val storeName: String,
val date: LocalDate,
val startFrom: LocalTime,
val endAt: LocalTime,
val themeId: Long,
val themeName: String,
)
fun ScheduleOverview.toOverviewResponse() = ScheduleOverviewResponse(
id = this.id,
storeId = this.storeId,
storeName = this.storeName,
date = this.date,
startFrom = this.time,
endAt = this.getEndAt(),
themeId = this.themeId,
themeName = this.themeName,
)

View File

@ -0,0 +1,139 @@
package roomescape.store.business
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.admin.business.AdminService
import roomescape.common.config.next
import roomescape.common.dto.AuditInfo
import roomescape.region.business.RegionService
import roomescape.store.exception.StoreErrorCode
import roomescape.store.exception.StoreException
import roomescape.store.infrastructure.persistence.StoreEntity
import roomescape.store.infrastructure.persistence.StoreRepository
import roomescape.store.infrastructure.persistence.StoreStatus
import roomescape.store.web.*
private val log: KLogger = KotlinLogging.logger {}
@Service
class StoreService(
private val storeRepository: StoreRepository,
private val storeValidator: StoreValidator,
private val adminService: AdminService,
private val regionService: RegionService,
private val tsidFactory: TsidFactory,
) {
@Transactional(readOnly = true)
fun getDetail(id: Long): DetailStoreResponse {
log.info { "[StoreService.getDetail] 매장 상세 조회 시작: id=${id}" }
val store: StoreEntity = findOrThrow(id)
val region = regionService.findRegionInfo(store.regionCode)
val audit = getAuditInfo(store)
return store.toDetailResponse(region, audit)
.also { log.info { "[StoreService.getDetail] 매장 상세 조회 완료: id=${id}" } }
}
@Transactional
fun register(request: StoreRegisterRequest): StoreRegisterResponse {
log.info { "[StoreService.register] 매장 등록 시작: name=${request.name}" }
storeValidator.validateCanRegister(request)
val store = StoreEntity(
id = tsidFactory.next(),
name = request.name,
address = request.address,
contact = request.contact,
businessRegNum = request.businessRegNum,
regionCode = request.regionCode,
status = StoreStatus.ACTIVE,
).also {
storeRepository.save(it)
}
return StoreRegisterResponse(store.id).also {
log.info { "[StoreService.register] 매장 등록 완료: id=${store.id}, name=${request.name}" }
}
}
@Transactional
fun update(id: Long, request: StoreUpdateRequest) {
log.info { "[StoreService.update] 매장 수정 시작: id=${id}, request=${request}" }
storeValidator.validateCanUpdate(request)
findOrThrow(id).apply {
this.modifyIfNotNull(request.name, request.address, request.contact)
}.also {
log.info { "[StoreService.update] 매장 수정 완료: id=${id}" }
}
}
@Transactional
fun disableById(id: Long) {
log.info { "[StoreService.inactive] 매장 비활성화 시작: id=${id}" }
findOrThrow(id).apply {
this.disable()
}.also {
log.info { "[StoreService.inactive] 매장 비활성화 완료: id=${id}" }
}
}
@Transactional(readOnly = true)
fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): SimpleStoreListResponse {
log.info { "[StoreService.getAllActiveStores] 전체 매장 조회 시작" }
val regionCode: String? = when {
sidoCode == null && sigunguCode != null -> throw StoreException(StoreErrorCode.SIDO_CODE_REQUIRED)
sidoCode != null -> "${sidoCode}${sigunguCode ?: ""}"
else -> null
}
return storeRepository.findAllActiveStoresByRegion(regionCode).toSimpleListResponse()
.also { log.info { "[StoreService.getAllActiveStores] 전체 매장 조회 완료: total=${it.stores.size}" } }
}
@Transactional(readOnly = true)
fun findStoreInfo(id: Long): StoreInfoResponse {
log.info { "[StoreService.findStoreInfo] 매장 정보 조회 시작: id=${id}" }
val store: StoreEntity = findOrThrow(id)
return store.toInfoResponse()
.also { log.info { "[StoreService.findStoreInfo] 매장 정보 조회 완료: id=${id}" } }
}
private fun getAuditInfo(store: StoreEntity): AuditInfo {
log.info { "[StoreService.getAuditInfo] 감사 정보 조회 시작: storeId=${store.id}" }
val createdBy = adminService.findOperatorOrUnknown(store.createdBy)
val updatedBy = adminService.findOperatorOrUnknown(store.updatedBy)
return AuditInfo(
createdAt = store.createdAt,
createdBy = createdBy,
updatedAt = store.updatedAt,
updatedBy = updatedBy
).also {
log.info { "[StoreService.getAuditInfo] 감사 정보 조회 완료: storeId=${store.id}" }
}
}
private fun findOrThrow(id: Long): StoreEntity {
log.info { "[StoreService.findOrThrow] 매장 조회 시작: id=${id}" }
return storeRepository.findActiveStoreById(id)
?.also {
log.info { "[StoreService.findOrThrow] 매장 조회 완료: id=${id}" }
}
?: run {
log.warn { "[StoreService.findOrThrow] 매장 조회 실패: id=${id}" }
throw StoreException(StoreErrorCode.STORE_NOT_FOUND)
}
}
}

View File

@ -0,0 +1,59 @@
package roomescape.store.business
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.store.exception.StoreErrorCode
import roomescape.store.exception.StoreException
import roomescape.store.infrastructure.persistence.StoreRepository
import roomescape.store.web.StoreRegisterRequest
import roomescape.store.web.StoreUpdateRequest
private val log: KLogger = KotlinLogging.logger {}
@Component
class StoreValidator(
private val storeRepository: StoreRepository
) {
fun validateCanRegister(request: StoreRegisterRequest) {
validateDuplicateNameExist(request.name)
validateDuplicateContactExist(request.contact)
validateDuplicateAddressExist(request.address)
validateDuplicateBusinessRegNumExist(request.businessRegNum)
}
fun validateCanUpdate(request: StoreUpdateRequest) {
request.name?.let { validateDuplicateNameExist(it) }
request.contact?.let { validateDuplicateContactExist(it) }
request.address?.let { validateDuplicateAddressExist(it) }
}
private fun validateDuplicateNameExist(name: String) {
if (storeRepository.existsByName(name)) {
log.info { "[StoreValidator.validateDuplicateNameExist] 이름 중복: name=${name}" }
throw StoreException(StoreErrorCode.STORE_NAME_DUPLICATED)
}
}
private fun validateDuplicateContactExist(contact: String) {
if (storeRepository.existsByContact(contact)) {
log.info { "[StoreValidator.validateDuplicateContact] 연락처 중복: contact=${contact}" }
throw StoreException(StoreErrorCode.STORE_CONTACT_DUPLICATED)
}
}
private fun validateDuplicateAddressExist(address: String) {
if (storeRepository.existsByAddress(address)) {
log.info { "[StoreValidator.validateDuplicateAddress] 주소 중복: address=${address}" }
throw StoreException(StoreErrorCode.STORE_ADDRESS_DUPLICATED)
}
}
private fun validateDuplicateBusinessRegNumExist(businessRegNum: String) {
if (storeRepository.existsByBusinessRegNum(businessRegNum)) {
log.info { "[StoreValidator.validateDuplicateBusinessRegNum] 사업자번호 중복: businessRegNum=${businessRegNum}" }
throw StoreException(StoreErrorCode.STORE_BUSINESS_REG_NUM_DUPLICATED)
}
}
}

View File

@ -0,0 +1,64 @@
package roomescape.store.docs
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.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.admin.infrastructure.persistence.Privilege
import roomescape.auth.web.support.AdminOnly
import roomescape.auth.web.support.Public
import roomescape.common.dto.response.CommonApiResponse
import roomescape.store.web.*
interface AdminStoreAPI {
@AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_DETAIL)
@Operation(summary = "특정 매장의 상세 정보 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findStoreDetail(
@PathVariable id: Long
): ResponseEntity<CommonApiResponse<DetailStoreResponse>>
@AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE)
@Operation(summary = "매장 등록")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun registerStore(
@Valid @RequestBody request: StoreRegisterRequest
): ResponseEntity<CommonApiResponse<StoreRegisterResponse>>
@AdminOnly(type = AdminType.STORE, privilege = Privilege.UPDATE)
@Operation(summary = "매장 정보 수정")
@ApiResponses(ApiResponse(responseCode = "200"))
fun updateStore(
@PathVariable id: Long,
@Valid @RequestBody request: StoreUpdateRequest
): ResponseEntity<CommonApiResponse<Unit>>
@AdminOnly(type = AdminType.HQ, privilege = Privilege.DELETE)
@Operation(summary = "매장 비활성화")
@ApiResponses(ApiResponse(responseCode = "204"))
fun disableStore(
@PathVariable id: Long
): ResponseEntity<CommonApiResponse<Unit>>
}
interface PublicStoreAPI {
@Public
@Operation(summary = "모든 매장의 id / 이름 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun getStores(
@RequestParam(value = "sido", required = false) sidoCode: String?,
@RequestParam(value = "sigungu", required = false) sigunguCode: String?
): ResponseEntity<CommonApiResponse<SimpleStoreListResponse>>
@Public
@Operation(summary = "특정 매장의 정보 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun getStoreInfo(
@PathVariable id: Long
): ResponseEntity<CommonApiResponse<StoreInfoResponse>>
}

View File

@ -0,0 +1,23 @@
package roomescape.store.exception
import org.springframework.http.HttpStatus
import roomescape.common.exception.ErrorCode
import roomescape.common.exception.RoomescapeException
class StoreException(
override val errorCode: StoreErrorCode,
override val message: String = errorCode.message
) : RoomescapeException(errorCode, message)
enum class StoreErrorCode(
override val httpStatus: HttpStatus,
override val errorCode: String,
override val message: String
) : ErrorCode {
STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "ST001", "매장을 찾을 수 없어요."),
SIDO_CODE_REQUIRED(HttpStatus.BAD_REQUEST, "ST002", "시/도 정보를 찾을 수 없어요. 다시 시도해주세요."),
STORE_NAME_DUPLICATED(HttpStatus.CONFLICT, "ST003", "이름이 같은 매장이 있어요."),
STORE_CONTACT_DUPLICATED(HttpStatus.CONFLICT, "ST004", "연락처가 같은 매장이 있어요."),
STORE_ADDRESS_DUPLICATED(HttpStatus.CONFLICT, "ST005", "주소가 같은 매장이 있어요."),
STORE_BUSINESS_REG_NUM_DUPLICATED(HttpStatus.CONFLICT, "ST006", "사업자번호가 같은 매장이 있어요."),
}

View File

@ -0,0 +1,48 @@
package roomescape.store.infrastructure.persistence
import jakarta.persistence.*
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import roomescape.common.entity.AuditingBaseEntity
@Entity
@EntityListeners(AuditingEntityListener::class)
@Table(name = "store")
class StoreEntity(
id: Long,
@Column(unique = false)
var name: String,
@Column(unique = false)
var address: String,
@Column(unique = false)
var contact: String,
@Column(unique = false)
val businessRegNum: String,
var regionCode: String,
@Enumerated(value = EnumType.STRING)
var status: StoreStatus
) : AuditingBaseEntity(id) {
fun modifyIfNotNull(
name: String?,
address: String?,
contact: String?,
) {
name?.let { this.name = it }
address?.let { this.address = it }
contact?.let { this.contact = it }
}
fun disable() {
this.status = StoreStatus.DISABLED
}
}
enum class StoreStatus {
ACTIVE,
DISABLED
}

View File

@ -0,0 +1,38 @@
package roomescape.store.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
interface StoreRepository : JpaRepository<StoreEntity, Long> {
@Query(
"""
SELECT
s
FROM
StoreEntity s
WHERE
s._id = :id
AND s.status = roomescape.store.infrastructure.persistence.StoreStatus.ACTIVE
"""
)
fun findActiveStoreById(id: Long): StoreEntity?
@Query(
"""
SELECT
s
FROM
StoreEntity s
WHERE
s.status = roomescape.store.infrastructure.persistence.StoreStatus.ACTIVE
AND (:regionCode IS NULL OR s.regionCode LIKE :regionCode%)
"""
)
fun findAllActiveStoresByRegion(regionCode: String?): List<StoreEntity>
fun existsByName(name: String): Boolean
fun existsByContact(contact: String): Boolean
fun existsByAddress(address: String): Boolean
fun existsByBusinessRegNum(businessRegNum: String): Boolean
}

View File

@ -0,0 +1,52 @@
package roomescape.store.web
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import roomescape.common.dto.response.CommonApiResponse
import roomescape.store.business.StoreService
import roomescape.store.docs.AdminStoreAPI
@RestController
@RequestMapping("/admin/stores")
class AdminStoreController(
private val storeService: StoreService
) : AdminStoreAPI {
@GetMapping("/{id}/detail")
override fun findStoreDetail(
@PathVariable id: Long
): ResponseEntity<CommonApiResponse<DetailStoreResponse>> {
val response: DetailStoreResponse = storeService.getDetail(id)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping
override fun registerStore(
@Valid @RequestBody request: StoreRegisterRequest
): ResponseEntity<CommonApiResponse<StoreRegisterResponse>> {
val response: StoreRegisterResponse = storeService.register(request)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PatchMapping("/{id}")
override fun updateStore(
@PathVariable id: Long,
@Valid @RequestBody request: StoreUpdateRequest
): ResponseEntity<CommonApiResponse<Unit>> {
storeService.update(id, request)
return ResponseEntity.ok(CommonApiResponse())
}
@PostMapping("/{id}/disable")
override fun disableStore(
@PathVariable id: Long,
): ResponseEntity<CommonApiResponse<Unit>> {
storeService.disableById(id)
return ResponseEntity.ok(CommonApiResponse())
}
}

View File

@ -0,0 +1,46 @@
package roomescape.store.web
import roomescape.common.dto.AuditInfo
import roomescape.region.web.RegionInfoResponse
import roomescape.store.infrastructure.persistence.StoreEntity
data class StoreRegisterRequest(
val name: String,
val address: String,
val contact: String,
val businessRegNum: String,
val regionCode: String
)
data class StoreRegisterResponse(
val id: Long
)
data class StoreUpdateRequest(
val name: String? = null,
val address: String? = null,
val contact: String? = null,
)
data class DetailStoreResponse(
val id: Long,
val name: String,
val address: String,
val contact: String,
val businessRegNum: String,
val region: RegionInfoResponse,
val audit: AuditInfo
)
fun StoreEntity.toDetailResponse(
region: RegionInfoResponse,
audit: AuditInfo
) = DetailStoreResponse(
id = this.id,
name = this.name,
address = this.address,
contact = this.contact,
businessRegNum = this.businessRegNum,
region = region,
audit = audit,
)

View File

@ -0,0 +1,35 @@
package roomescape.store.web
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import roomescape.common.dto.response.CommonApiResponse
import roomescape.store.business.StoreService
import roomescape.store.docs.PublicStoreAPI
@RestController
class StoreController(
private val storeService: StoreService
) : PublicStoreAPI {
@GetMapping("/stores")
override fun getStores(
@RequestParam(value = "sido", required = false) sidoCode: String?,
@RequestParam(value = "sigungu", required = false) sigunguCode: String?
): ResponseEntity<CommonApiResponse<SimpleStoreListResponse>> {
val response = storeService.getAllActiveStores(sidoCode, sigunguCode)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/stores/{id}")
override fun getStoreInfo(
@PathVariable id: Long
): ResponseEntity<CommonApiResponse<StoreInfoResponse>> {
val response = storeService.findStoreInfo(id)
return ResponseEntity.ok(CommonApiResponse(response))
}
}

View File

@ -0,0 +1,32 @@
package roomescape.store.web
import roomescape.store.infrastructure.persistence.StoreEntity
data class SimpleStoreResponse(
val id: Long,
val name: String
)
data class SimpleStoreListResponse(
val stores: List<SimpleStoreResponse>
)
fun List<StoreEntity>.toSimpleListResponse() = SimpleStoreListResponse(
stores = this.map { SimpleStoreResponse(id = it.id, name = it.name) }
)
data class StoreInfoResponse(
val id: Long,
val name: String,
val address: String,
val contact: String,
val businessRegNum: String
)
fun StoreEntity.toInfoResponse() = StoreInfoResponse(
id = this.id,
name = this.name,
address = this.address,
contact = this.contact,
businessRegNum = this.businessRegNum
)

View File

@ -8,6 +8,7 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.admin.business.AdminService import roomescape.admin.business.AdminService
import roomescape.common.config.next import roomescape.common.config.next
import roomescape.common.dto.AuditInfo
import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
@ -16,6 +17,13 @@ import roomescape.theme.web.*
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
/**
* Structure:
* - Public: 모두가 접근 가능한 메서드
* - Store Admin: 매장 관리자가 사용하는 메서드
* - HQ Admin: 본사 관리자가 사용하는 메서드
* - Common: 공통 메서드
*/
@Service @Service
class ThemeService( class ThemeService(
private val themeRepository: ThemeRepository, private val themeRepository: ThemeRepository,
@ -23,34 +31,30 @@ class ThemeService(
private val tsidFactory: TsidFactory, private val tsidFactory: TsidFactory,
private val adminService: AdminService private val adminService: AdminService
) { ) {
// ========================================
// Public (인증 불필요)
// ========================================
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findThemesByIds(request: ThemeIdListResponse): ThemeInfoListResponse { fun findInfoById(id: Long): ThemeInfoResponse {
log.info { "[ThemeService.findById] 테마 조회 시작: id=$id" }
return findOrThrow(id).toInfoResponse()
.also { log.info { "[ThemeService.findById] 테마 조회 완료: id=$id" } }
}
@Transactional(readOnly = true)
fun findAllInfosByIds(request: ThemeIdListRequest): ThemeInfoListResponse {
log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" } log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" }
val result: MutableList<ThemeEntity> = mutableListOf() val result: List<ThemeEntity> = themeRepository.findAllByIdIn(request.themeIds)
for (id in request.themeIds) { return result.toInfoListResponse().also {
val theme: ThemeEntity? = themeRepository.findByIdOrNull(id)
if (theme == null) {
log.warn { "[ThemeService.findThemesByIds] id=${id} 인 테마 조회 실패" }
continue
}
result.add(theme)
}
return result.toListResponse().also {
log.info { "[ThemeService.findThemesByIds] ${it.themes.size} / ${request.themeIds.size} 개 테마 조회 완료" } log.info { "[ThemeService.findThemesByIds] ${it.themes.size} / ${request.themeIds.size} 개 테마 조회 완료" }
} }
} }
@Transactional(readOnly = true) // ========================================
fun findThemesForReservation(): ThemeInfoListResponse { // HQ Admin (본사)
log.info { "[ThemeService.findThemesForReservation] 예약 페이지에서의 테마 목록 조회 시작" } // ========================================
return themeRepository.findOpenedThemes()
.toListResponse()
.also { log.info { "[ThemeService.findThemesForReservation] ${it.themes.size}개 테마 조회 완료" } }
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findAdminThemes(): AdminThemeSummaryListResponse { fun findAdminThemes(): AdminThemeSummaryListResponse {
log.info { "[ThemeService.findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" } log.info { "[ThemeService.findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
@ -66,32 +70,24 @@ class ThemeService(
val theme: ThemeEntity = findOrThrow(id) val theme: ThemeEntity = findOrThrow(id)
val createdBy = adminService.findOperatorById(theme.createdBy) val createdBy = adminService.findOperatorOrUnknown(theme.createdBy)
val updatedBy = adminService.findOperatorById(theme.updatedBy) val updatedBy = adminService.findOperatorOrUnknown(theme.updatedBy)
val audit = AuditInfo(theme.createdAt, createdBy, theme.updatedAt, updatedBy)
return theme.toAdminThemeDetailResponse(createdBy, updatedBy) return theme.toAdminThemeDetailResponse(audit)
.also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } } .also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } }
} }
@Transactional(readOnly = true)
fun findSummaryById(id: Long): ThemeInfoResponse {
log.info { "[ThemeService.findById] 테마 조회 시작: id=$id" }
return findOrThrow(id).toSummaryResponse()
.also { log.info { "[ThemeService.findById] 테마 조회 완료: id=$id" } }
}
@Transactional @Transactional
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponseV2 { fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse {
log.info { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" } log.info { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" }
themeValidator.validateCanCreate(request) themeValidator.validateCanCreate(request)
val theme: ThemeEntity = themeRepository.save( val theme: ThemeEntity = request.toEntity(id = tsidFactory.next())
request.toEntity(tsidFactory.next()) .also { themeRepository.save(it) }
)
return ThemeCreateResponseV2(theme.id).also { return ThemeCreateResponse(theme.id).also {
log.info { "[ThemeService.createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" } log.info { "[ThemeService.createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" }
} }
} }
@ -131,12 +127,29 @@ class ThemeService(
request.availableMinutes, request.availableMinutes,
request.expectedMinutesFrom, request.expectedMinutesFrom,
request.expectedMinutesTo, request.expectedMinutesTo,
request.isOpen, request.isActive,
).also { ).also {
log.info { "[ThemeService.updateTheme] 테마 수정 완료: id=$id, request=${request}" } log.info { "[ThemeService.updateTheme] 테마 수정 완료: id=$id, request=${request}" }
} }
} }
// ========================================
// Store Admin (매장)
// ========================================
@Transactional(readOnly = true)
fun findActiveThemes(): SimpleActiveThemeListResponse {
log.info { "[ThemeService.findActiveThemes] open 상태인 모든 테마 조회 시작" }
return themeRepository.findActiveThemes()
.toSimpleActiveThemeResponse()
.also {
log.info { "[ThemeService.findActiveThemes] ${it.themes.size}개 테마 조회 완료" }
}
}
// ========================================
// Common (공통 메서드)
// ========================================
private fun findOrThrow(id: Long): ThemeEntity { private fun findOrThrow(id: Long): ThemeEntity {
log.info { "[ThemeService.findOrThrow] 테마 조회 시작: id=$id" } log.info { "[ThemeService.findOrThrow] 테마 조회 시작: id=$id" }

View File

@ -3,55 +3,60 @@ package roomescape.theme.docs
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.admin.infrastructure.persistence.Privilege import roomescape.admin.infrastructure.persistence.Privilege
import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.AdminOnly
import roomescape.auth.web.support.Public import roomescape.auth.web.support.Public
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.theme.web.* import roomescape.theme.web.*
@Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.") interface AdminThemeAPI {
interface ThemeAPIV2 { @AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_SUMMARY)
@Operation(summary = "모든 테마 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun getAdminThemeSummaries(): ResponseEntity<CommonApiResponse<AdminThemeSummaryListResponse>>
@AdminOnly(privilege = Privilege.READ_SUMMARY) @AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_DETAIL)
@Operation(summary = "모든 테마 조회", description = "관리자 페이지에서 요약된 테마 목록을 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 상세 조회")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findAdminThemes(): ResponseEntity<CommonApiResponse<AdminThemeSummaryListResponse>>
@AdminOnly(privilege = Privilege.READ_DETAIL)
@Operation(summary = "테마 상세 조회", description = "해당 테마의 상세 정보를 조회합니다.", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity<CommonApiResponse<AdminThemeDetailResponse>> fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity<CommonApiResponse<AdminThemeDetailResponse>>
@AdminOnly(privilege = Privilege.CREATE) @AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE)
@Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 추가")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity<CommonApiResponse<ThemeCreateResponseV2>> fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity<CommonApiResponse<ThemeCreateResponse>>
@AdminOnly(privilege = Privilege.DELETE) @AdminOnly(type = AdminType.HQ, privilege = Privilege.DELETE)
@Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 삭제")
@ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "204"))
fun deleteTheme(@PathVariable id: Long): ResponseEntity<CommonApiResponse<Unit>> fun deleteTheme(@PathVariable id: Long): ResponseEntity<CommonApiResponse<Unit>>
@AdminOnly(privilege = Privilege.UPDATE) @AdminOnly(type = AdminType.HQ, privilege = Privilege.UPDATE)
@Operation(summary = "테마 수정", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 수정")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200"))
fun updateTheme( fun updateTheme(
@PathVariable id: Long, @PathVariable id: Long,
@Valid @RequestBody themeUpdateRequest: ThemeUpdateRequest @Valid @RequestBody request: ThemeUpdateRequest
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
@AdminOnly(privilege = Privilege.READ_SUMMARY)
@Operation(summary = "현재 활성화 상태인 테마 ID + 이름 목록 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun getActiveThemes(): ResponseEntity<CommonApiResponse<SimpleActiveThemeListResponse>>
}
interface PublicThemeAPI {
@Public @Public
@Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.") @Operation(summary = "입력된 모든 ID에 대한 테마 정보 조회")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>> fun findThemeInfosByIds(request: ThemeIdListRequest): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>>
@Public @Public
@Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.") @Operation(summary = "입력된 테마 ID에 대한 정보 조회")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findThemesByIds(request: ThemeIdListResponse): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>> fun findThemeInfoById(@PathVariable id: Long): ResponseEntity<CommonApiResponse<ThemeInfoResponse>>
} }

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