[#44] 매장 기능 도입 #45

Merged
pricelees merged 116 commits from feat/#44 into main 2025-09-20 03:15:06 +00:00
133 changed files with 7904 additions and 6904 deletions

View File

@ -79,6 +79,9 @@ dependencies {
// RestAssured
testImplementation("io.rest-assured:rest-assured:5.5.5")
testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
// etc
implementation("org.apache.poi:poi-ooxml:5.2.3")
}
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 AdminRoute from './components/AdminRoute';
import Layout from './components/Layout';
import { AdminAuthProvider } from './context/AdminAuthContext';
import { AuthProvider } from './context/AuthContext';
import AdminLayout from './pages/admin/AdminLayout';
import AdminLoginPage from './pages/admin/AdminLoginPage';
import AdminPage from './pages/admin/AdminPage';
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
import AdminStorePage from './pages/admin/AdminStorePage';
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
import AdminThemePage from './pages/admin/AdminThemePage';
import HomePage from '@_pages/HomePage';
@ -16,26 +18,28 @@ import ReservationStep2Page from '@_pages/ReservationStep2Page';
import ReservationSuccessPage from '@_pages/ReservationSuccessPage';
import SignupPage from '@_pages/SignupPage';
const AdminRoutes = () => (
<AdminLayout>
<Routes>
<Route path="/" element={<AdminPage />} />
<Route path="/theme" element={<AdminThemePage />} />
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
<Route path="/schedule" element={<AdminSchedulePage />} />
</Routes>
</AdminLayout>
);
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/admin/*" element={
<AdminRoute>
<AdminRoutes />
</AdminRoute>
<AdminAuthProvider>
<Routes>
<Route path="/login" element={<AdminLoginPage />} />
<Route path="/*" element={
<AdminLayout>
<Routes>
<Route path="/" element={<AdminPage />} />
<Route path="/theme" element={<AdminThemePage />} />
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
<Route path="/store" element={<AdminStorePage />} />
<Route path="/schedule" element={<AdminSchedulePage />} />
</Routes>
</AdminLayout>
} />
</Routes>
</AdminAuthProvider>
} />
<Route path="/*" element={
<Layout>

View File

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

View File

@ -1,19 +1,33 @@
import apiClient from '@_api/apiClient';
import type {CurrentUserContext, LoginRequest, LoginSuccessResponse} from './authTypes';
import {
type AdminLoginSuccessResponse,
type LoginRequest,
PrincipalType,
type UserLoginSuccessResponse,
} from './authTypes';
export const login = async (data: LoginRequest): Promise<LoginSuccessResponse> => {
const response = await apiClient.post<LoginSuccessResponse>('/auth/login', data, false);
localStorage.setItem('accessToken', response.accessToken);
return response;
export const userLogin = async (
data: Omit<LoginRequest, 'principalType'>,
): Promise<UserLoginSuccessResponse> => {
return await apiClient.post<UserLoginSuccessResponse>(
'/auth/login',
{ ...data, principalType: PrincipalType.USER },
);
};
export const checkLogin = async (): Promise<CurrentUserContext> => {
return await apiClient.get<CurrentUserContext>('/auth/login/check', true);
export const adminLogin = async (
data: Omit<LoginRequest, 'principalType'>,
): Promise<AdminLoginSuccessResponse> => {
return await apiClient.adminPost<AdminLoginSuccessResponse>(
'/auth/login',
{ ...data, principalType: PrincipalType.ADMIN },
);
};
export const logout = async (): Promise<void> => {
await apiClient.post('/auth/logout', {}, true);
localStorage.removeItem('accessToken');
await apiClient.post('/auth/logout', {});
};
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 const AdminType = {
HQ: 'HQ',
STORE: 'STORE',
} as const;
export type AdminType = typeof AdminType[keyof typeof AdminType];
export interface LoginRequest {
account: string,
password: string;
@ -13,6 +20,15 @@ export interface LoginRequest {
export interface LoginSuccessResponse {
accessToken: string;
name: string;
}
export interface UserLoginSuccessResponse extends LoginSuccessResponse {
}
export interface AdminLoginSuccessResponse extends LoginSuccessResponse {
type: AdminType;
storeId: string | null;
}
export interface CurrentUserContext {

View File

@ -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,
PendingReservationCreateResponse,
ReservationDetailRetrieveResponse,
ReservationSummaryRetrieveListResponse
ReservationOverviewListResponse
} from './reservationTypes';
export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise<PendingReservationCreateResponse> => {
@ -17,11 +17,11 @@ export const confirmReservation = async (reservationId: string): Promise<void> =
export const cancelReservation = async (id: string, cancelReason: string): Promise<void> => {
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason }, true);
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason });
};
export const fetchSummaryByMember = async (): Promise<ReservationSummaryRetrieveListResponse> => {
return await apiClient.get<ReservationSummaryRetrieveListResponse>('/reservations/summary');
export const fetchAllOverviewByUser = async (): Promise<ReservationOverviewListResponse> => {
return await apiClient.get<ReservationOverviewListResponse>('/reservations/overview');
}
export const fetchDetailById = async (reservationId: string): Promise<ReservationDetailRetrieveResponse> => {
@ -29,5 +29,5 @@ export const fetchDetailById = async (reservationId: string): Promise<Reservatio
}
export const fetchMostReservedThemeIds = async (count: number = 10): Promise<MostReservedThemeIdListResponse> => {
return await apiClient.get<MostReservedThemeIdListResponse>(`/reservations/popular-themes?count=${count}`, 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 {UserContactRetrieveResponse} from "@_api/user/userTypes";
export interface ReservationData {
scheduleId: string;
store: {
id: string;
name: string;
}
theme: {
id: string;
name: string;
price: number;
minParticipants: number;
maxParticipants: number;
}
date: string; // "yyyy-MM-dd"
startFrom: string; // "HH:mm ~ HH:mm"
endAt: string;
}
export const ReservationStatus = {
PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED',
@ -28,30 +46,38 @@ export interface PendingReservationCreateResponse {
id: string
}
export interface ReservationSummaryRetrieveResponse {
export interface ReservationOverviewResponse {
id: string;
storeName: string;
themeName: string;
date: string;
startAt: string;
startFrom: string;
endAt: string;
status: ReservationStatus;
}
export interface ReservationSummaryRetrieveListResponse {
reservations: ReservationSummaryRetrieveResponse[];
export interface ReservationOverviewListResponse {
reservations: ReservationOverviewResponse[];
}
export interface ReserverInfo {
name: string;
contact: string;
participantCount: number;
requirement: string;
}
export interface ReservationDetailRetrieveResponse {
id: string;
reserver: ReserverInfo;
user: UserContactRetrieveResponse;
applicationDateTime: string;
payment: PaymentRetrieveResponse;
}
export interface ReservationDetail {
id: string;
themeName: string;
date: string;
startAt: string;
overview: ReservationOverviewResponse;
reserver: ReserverInfo;
user: UserContactRetrieveResponse;
applicationDateTime: string;
payment?: PaymentRetrieveResponse;

View File

@ -1,37 +1,49 @@
import apiClient from '../apiClient';
import type {
AvailableThemeIdListResponse,
ScheduleCreateRequest,
ScheduleCreateResponse,
ScheduleDetailRetrieveResponse,
ScheduleRetrieveListResponse,
ScheduleUpdateRequest
} from './scheduleTypes';
import apiClient from "@_api/apiClient";
import type { AdminScheduleSummaryListResponse, ScheduleCreateRequest, ScheduleCreateResponse, ScheduleStatus, ScheduleUpdateRequest, ScheduleWithThemeListResponse } from "./scheduleTypes";
import type { AuditInfo } from "@_api/common/commonTypes";
export const findAvailableThemesByDate = async (date: string): Promise<AvailableThemeIdListResponse> => {
return await apiClient.get<AvailableThemeIdListResponse>(`/schedules/themes?date=${date}`);
};
// admin
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> => {
return await apiClient.get<ScheduleRetrieveListResponse>(`/schedules?date=${date}&themeId=${themeId}`);
};
export const findScheduleById = async (id: string): Promise<ScheduleDetailRetrieveResponse> => {
return await apiClient.get<ScheduleDetailRetrieveResponse>(`/schedules/${id}`);
if (date && date.trim() !== '') {
queryParams.push(`date=${date}`);
}
export const createSchedule = async (request: ScheduleCreateRequest): Promise<ScheduleCreateResponse> => {
return await apiClient.post<ScheduleCreateResponse>('/schedules', request);
if (themeId && themeId.trim() !== '') {
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 fetchScheduleAudit = async (scheduleId: string): Promise<AuditInfo> => {
return await apiClient.adminGet<AuditInfo>(`/admin/schedules/${scheduleId}/audits`);
}
export const createSchedule = async (storeId: string, request: ScheduleCreateRequest): Promise<ScheduleCreateResponse> => {
return await apiClient.adminPost<ScheduleCreateResponse>(`/admin/stores/${storeId}/schedules`, request);
};
export const 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> => {
await apiClient.del(`/schedules/${id}`);
return await apiClient.adminDel<void>(`/admin/schedules/${id}`);
};
// public
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 const ScheduleStatus = {
@ -7,24 +9,11 @@ export const ScheduleStatus = {
BLOCKED: 'BLOCKED' as ScheduleStatus,
};
export interface AvailableThemeIdListResponse {
themeIds: string[];
}
export interface ScheduleRetrieveResponse {
id: string;
time: string; // "HH:mm"
status: ScheduleStatus;
}
export interface ScheduleRetrieveListResponse {
schedules: ScheduleRetrieveResponse[];
}
// Admin
export interface ScheduleCreateRequest {
date: string; // "yyyy-MM-dd"
time: string; // "HH:mm"
date: string;
themeId: string;
time: string;
}
export interface ScheduleCreateResponse {
@ -38,13 +27,29 @@ export interface ScheduleUpdateRequest {
status?: ScheduleStatus;
}
export interface ScheduleDetailRetrieveResponse {
id: string;
date: string; // "yyyy-MM-dd"
time: string; // "HH:mm"
status: ScheduleStatus;
createdAt: string; // or Date
createdBy: string;
updatedAt: string; // or Date
updatedBy: string;
export interface AdminScheduleSummaryResponse {
id: string,
themeName: string,
startFrom: string,
endAt: string,
status: ScheduleStatus,
}
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 type {
AdminThemeDetailRetrieveResponse,
AdminThemeSummaryRetrieveListResponse,
AdminThemeDetailResponse,
AdminThemeSummaryListResponse,
SimpleActiveThemeListResponse,
ThemeCreateRequest,
ThemeCreateResponse,
ThemeIdListResponse,
ThemeInfoListResponse,
ThemeInfoResponse,
ThemeUpdateRequest
} from './themeTypes';
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryRetrieveListResponse> => {
return await apiClient.get<AdminThemeSummaryRetrieveListResponse>('/admin/themes');
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryListResponse> => {
return await apiClient.adminGet<AdminThemeSummaryListResponse>('/admin/themes');
};
export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailRetrieveResponse> => {
return await apiClient.get<AdminThemeDetailRetrieveResponse>(`/admin/themes/${id}`);
export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailResponse> => {
return await apiClient.adminGet<AdminThemeDetailResponse>(`/admin/themes/${id}`);
};
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> => {
await apiClient.patch<any>(`/admin/themes/${id}`, themeData);
await apiClient.adminPatch<any>(`/admin/themes/${id}`, themeData);
};
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> => {
return await apiClient.get<ThemeInfoListResponse>('/themes');
export const fetchActiveThemes = async (): Promise<SimpleActiveThemeListResponse> => {
return await apiClient.adminGet<SimpleActiveThemeListResponse>('/admin/themes/active');
};
export const findThemesByIds = async (request: ThemeIdListResponse): Promise<ThemeInfoListResponse> => {
return await apiClient.post<ThemeInfoListResponse>('/themes/retrieve', request);
export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise<ThemeInfoListResponse> => {
return await apiClient.post<ThemeInfoListResponse>('/themes/batch', request);
};
export const fetchThemeById = async (id: string): Promise<ThemeInfoResponse> => {
return await apiClient.get<ThemeInfoResponse>(`/themes/${id}`);
}

View File

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

View File

@ -2,9 +2,9 @@ import apiClient from "@_api/apiClient";
import type {UserContactRetrieveResponse, UserCreateRequest, UserCreateResponse} from "./userTypes";
export const signup = async (data: UserCreateRequest): Promise<UserCreateResponse> => {
return await apiClient.post('/users', data, false);
return await apiClient.post('/users', data);
};
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 {type LoginRequest, type LoginSuccessResponse, PrincipalType} from '@_api/auth/authTypes';
import { logout as apiLogout, userLogin as apiLogin } from '@_api/auth/authAPI';
import { type LoginRequest, type UserLoginSuccessResponse } from '@_api/auth/authTypes';
import React, { createContext, type ReactNode, useContext, useEffect, useState } from 'react';
interface AuthContextType {
loggedIn: boolean;
userName: string | null;
type: PrincipalType | null;
loading: boolean;
login: (data: LoginRequest) => Promise<LoginSuccessResponse>;
login: (data: Omit<LoginRequest, 'principalType'>) => Promise<UserLoginSuccessResponse>;
logout: () => Promise<void>;
checkLogin: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@ -17,33 +15,33 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [loggedIn, setLoggedIn] = useState(false);
const [userName, setUserName] = useState<string | null>(null);
const [type, setType] = useState<PrincipalType | null>(null);
const [loading, setLoading] = useState(true); // Add loading state
const checkLogin = async () => {
try {
const response = await apiCheckLogin();
setLoggedIn(true);
setUserName(response.name);
setType(response.type);
} catch (error) {
setLoggedIn(false);
setUserName(null);
setType(null);
localStorage.removeItem('accessToken');
} finally {
setLoading(false); // Set loading to false after check is complete
}
};
const [loading, setLoading] = useState(true);
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 response = await apiLogin({ ...data });
const login = async (data: Omit<LoginRequest, 'principalType'>) => {
const response = await apiLogin(data);
localStorage.setItem('accessToken', response.accessToken);
localStorage.setItem('userName', response.name);
setLoggedIn(true);
setType(data.principalType);
setUserName(response.name);
return response;
};
@ -51,15 +49,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
try {
await apiLogout();
} finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('userName');
setLoggedIn(false);
setUserName(null);
setType(null);
localStorage.removeItem('accessToken');
}
};
return (
<AuthContext.Provider value={{ loggedIn, userName, type, loading, login, logout, checkLogin }}>
<AuthContext.Provider value={{ loggedIn, userName, loading, login, logout }}>
{children}
</AuthContext.Provider>
);

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import {confirmReservation} from '@_api/reservation/reservationAPI';
import '@_css/reservation-v2-1.css';
import React, { useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
import { formatDate } from 'src/util/DateTimeFormatter';
declare global {
interface Window {
@ -19,7 +19,7 @@ const ReservationStep2Page: React.FC = () => {
const paymentWidgetRef = useRef<any>(null);
const paymentMethodsRef = useRef<any>(null);
const { reservationId, themeName, date, startAt, price } = location.state || {};
const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
@ -51,12 +51,12 @@ const ReservationStep2Page: React.FC = () => {
const paymentMethods = paymentWidget.renderPaymentMethods(
"#payment-method",
{ value: price },
{ value: totalPrice, currency: "KRW" },
{ variantKey: "DEFAULT" }
);
paymentMethodsRef.current = paymentMethods;
};
}, [reservationId, price, navigate]);
}, [reservationId, totalPrice, navigate]);
const handlePayment = () => {
if (!paymentWidgetRef.current || !reservationId) {
@ -67,15 +67,16 @@ const ReservationStep2Page: React.FC = () => {
const generateRandomString = () =>
crypto.randomUUID().replace(/-/g, '');
paymentWidgetRef.current.requestPayment({
orderId: generateRandomString(),
orderName: `${themeName} 예약 결제`,
amount: price,
amount: totalPrice,
}).then((data: any) => {
const paymentData: PaymentConfirmRequest = {
paymentKey: data.paymentKey,
orderId: data.orderId,
amount: price, // Use the price from component state instead of widget response
amount: totalPrice,
paymentType: data.paymentType || PaymentType.NORMAL,
};
@ -87,9 +88,12 @@ const ReservationStep2Page: React.FC = () => {
alert('결제가 완료되었어요!');
navigate('/reservation/success', {
state: {
themeName,
date,
startAt,
storeName: storeName,
themeName: themeName,
date: date,
time: time,
participantCount: participantCount,
totalPrice: totalPrice,
}
});
})
@ -109,10 +113,13 @@ const ReservationStep2Page: React.FC = () => {
<h2 className="page-title"></h2>
<div className="step-section">
<h3> </h3>
<p><strong>:</strong> {themeName}</p>
<p><strong>:</strong> {formatDate(date)}</p>
<p><strong>:</strong> {formatTime(startAt)}</p>
<p><strong>:</strong> {price.toLocaleString()}</p>
<p><strong>:</strong> {time}</p>
<p><strong>:</strong> {themeName}</p>
<p><strong>:</strong> {storeName}</p>
<p><strong>:</strong> {participantCount}</p>
<p><strong>1 :</strong> {themePrice.toLocaleString()}</p>
<p><strong> :</strong> {totalPrice.toLocaleString()}</p>
</div>
<div className="step-section">
<h3> </h3>
@ -121,7 +128,7 @@ const ReservationStep2Page: React.FC = () => {
</div>
<div className="next-step-button-container">
<button onClick={handlePayment} className="next-step-button">
{price.toLocaleString()}
{totalPrice.toLocaleString()}
</button>
</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 { Link, useLocation } from 'react-router-dom';
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
import { formatDate } from 'src/util/DateTimeFormatter';
const ReservationSuccessPage: React.FC = () => {
const location = useLocation();
const { themeName, date, startAt } = (location.state as {
themeName: string;
date: string;
startAt: string;
}) || {};
const { storeName, themeName, date, time, participantCount, totalPrice } = location.state || {};
const formattedDate = formatDate(date)
const formattedTime = formatTime(startAt);
const formattedDate = date ? formatDate(date) : '';
return (
<div className="reservation-v21-container">
@ -20,9 +15,12 @@ const ReservationSuccessPage: React.FC = () => {
<h2 className="page-title"> !</h2>
<div className="step-section">
<h3> </h3>
<p><strong>:</strong> {storeName}</p>
<p><strong>:</strong> {themeName}</p>
<p><strong>:</strong> {formattedDate}</p>
<p><strong>:</strong> {formattedTime}</p>
<p><strong>:</strong> {time}</p>
<p><strong>:</strong> {participantCount}</p>
<p><strong> :</strong> {totalPrice?.toLocaleString()}</p>
</div>
<div className="success-page-actions">
<Link to="/my-reservation" className="action-button">

View File

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

View File

@ -1,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';
interface AdminLayoutProps {
@ -6,6 +8,23 @@ interface AdminLayoutProps {
}
const AdminLayout: React.FC<AdminLayoutProps> = ({ children }) => {
const { isAdmin, loading } = useAdminAuth();
const navigate = useNavigate();
useEffect(() => {
if (!loading && !isAdmin) {
navigate('/admin/login');
}
}, [isAdmin, loading, navigate]);
if (loading) {
return <div>Loading...</div>;
}
if (!isAdmin) {
return null;
}
return (
<>
<AdminNavbar />

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,16 +10,30 @@ import roomescape.common.entity.AuditingBaseEntity
class AdminEntity(
id: Long,
@Column(unique = true)
val account: String,
var password: String,
val name: String,
@Column(unique = true)
var phone: 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)
enum class AdminType {
HQ,
STORE,
ALL
}
enum class AdminPermissionLevel(
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.LoginRequest
import roomescape.auth.web.LoginSuccessResponse
import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.LoginCredentials
import roomescape.common.dto.PrincipalType
import roomescape.user.business.UserService
private val log: KLogger = KotlinLogging.logger {}
const val CLAIM_ADMIN_TYPE_KEY = "admin_type"
const val CLAIM_PERMISSION_KEY = "permission"
const val CLAIM_TYPE_KEY = "principal_type"
const val CLAIM_STORE_ID_KEY = "store_id"
@Service
class AuthService(
@ -34,7 +34,6 @@ class AuthService(
context: LoginContext
): LoginSuccessResponse {
log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
val (credentials, extraClaims) = getCredentials(request)
try {
@ -44,7 +43,7 @@ class AuthService(
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}" }
}
@ -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(
request: LoginRequest,
credentials: LoginCredentials
@ -97,15 +79,14 @@ class AuthService(
val credentials: LoginCredentials = when (request.principalType) {
PrincipalType.ADMIN -> {
adminService.findCredentialsByAccount(request.account).also {
extraClaims.put(CLAIM_PERMISSION_KEY, it.permissionLevel)
extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.ADMIN)
extraClaims.put(CLAIM_ADMIN_TYPE_KEY, it.type.name)
extraClaims.put(CLAIM_PERMISSION_KEY, it.permissionLevel.name)
it.storeId?.also { storeId -> extraClaims.put(CLAIM_STORE_ID_KEY, storeId.toString()) }
}
}
PrincipalType.USER -> {
userService.findCredentialsByAccount(request.account).also {
extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.USER)
}
userService.findCredentialsByAccount(request.account)
}
}

View File

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

View File

@ -6,14 +6,10 @@ import io.jsonwebtoken.Claims
import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.slf4j.MDC
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import roomescape.auth.business.CLAIM_TYPE_KEY
import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException
import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY
import roomescape.common.dto.PrincipalType
import java.util.*
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 {
if (token.isNullOrBlank()) {
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.HttpServletResponse
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.RequestMapping
import org.springframework.web.bind.annotation.RestController
import roomescape.auth.business.AuthService
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.response.CommonApiResponse
@ -29,16 +28,9 @@ class AuthController(
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/login/check")
override fun checkLogin(
@CurrentUser user: CurrentUserContext,
): ResponseEntity<CommonApiResponse<CurrentUserContext>> {
return ResponseEntity.ok(CommonApiResponse(user))
}
@PostMapping("/logout")
override fun logout(
@CurrentUser user: CurrentUserContext,
@User user: CurrentUserContext,
servletResponse: HttpServletResponse
): ResponseEntity<CommonApiResponse<Unit>> {
return ResponseEntity.ok().build()

View File

@ -1,6 +1,7 @@
package roomescape.auth.web
import jakarta.servlet.http.HttpServletRequest
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.common.dto.PrincipalType
data class LoginContext(
@ -19,6 +20,19 @@ data class LoginRequest(
val principalType: PrincipalType
)
data class LoginSuccessResponse(
val accessToken: String
)
abstract class LoginSuccessResponse {
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
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.admin.infrastructure.persistence.Privilege
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class AdminOnly(
val type: AdminType = AdminType.ALL,
val privilege: Privilege
)
@ -12,14 +14,10 @@ annotation class AdminOnly(
@Retention(AnnotationRetention.RUNTIME)
annotation class UserOnly
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Authenticated
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Public
@Target(AnnotationTarget.VALUE_PARAMETER)
@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.servlet.HandlerInterceptor
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.exception.AuthErrorCode
import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.support.AdminOnly
import roomescape.auth.web.support.accessToken
import roomescape.common.dto.PrincipalType
import roomescape.common.util.MdcPrincipalId
private val log: KLogger = KotlinLogging.logger {}
@ -30,32 +33,76 @@ class AdminInterceptor(
if (handler !is HandlerMethod) {
return true
}
val annotation: AdminOnly = handler.getMethodAnnotation(AdminOnly::class.java) ?: return true
val token: String? = request.accessToken()
val (id, type) = jwtUtils.extractIdAndType(token)
val permission: AdminPermissionLevel = jwtUtils.extractClaim(token, key = CLAIM_PERMISSION_KEY)
?.let {
AdminPermissionLevel.valueOf(it)
}
?: run {
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)
}
try {
run {
val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalId.set(it) }
val type: AdminType = validateTypeAndGet(token, annotation.type)
val permission: AdminPermissionLevel = validatePermissionAndGet(token, annotation.privilege)
if (!permission.hasPrivilege(annotation.privilege)) {
log.warn { "[AdminInterceptor] 관리자 권한 부족: required=${annotation.privilege} / current=${permission}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
log.info { "[AdminInterceptor] 인증 완료. adminId=$id, type=${type}, permission=${permission}" }
}
log.info { "[AdminInterceptor] 인증 완료. adminId=$id, permission=${permission}" }
return true
} catch (e: Exception) {
when (e) {
is AuthException -> { throw e }
else -> {
log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
}
}
}
}
private fun validateTypeAndGet(token: String?, requiredType: AdminType): AdminType {
val typeClaim: String? = jwtUtils.extractClaim(token, key = CLAIM_ADMIN_TYPE_KEY)
/**
* 이전의 id 추출 과정에서 토큰이 유효한지 검증했기 때문에 typeClaim null 이라는 것은
* 회원 토큰일 가능성이 . (관리자 토큰에는 CLAIM_ADMIN_TYPE_KEY 무조건 존재함)
*/
if (typeClaim == null) {
log.warn { "[AdminInterceptor] 관리자 타입 조회 실패: token=${token}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
}
val type = try {
AdminType.valueOf(typeClaim)
} catch (_: IllegalArgumentException) {
log.warn { "[AdminInterceptor] 관리자 타입 변환 실패: token=${token}, typeClaim=${typeClaim}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN)
}
if (requiredType != AdminType.ALL && type != requiredType) {
log.warn { "[AdminInterceptor] 관리자 권한 부족: requiredType=${requiredType} / current=${type}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
}
return type
}
private fun validatePermissionAndGet(token: String?, requiredPrivilege: Privilege): AdminPermissionLevel {
val permissionClaim: String? = jwtUtils.extractClaim(token, key = CLAIM_PERMISSION_KEY)
if (permissionClaim == null) {
log.warn { "[AdminInterceptor] 관리자 권한 조회 실패: token=${token}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN)
}
val permission = try {
AdminPermissionLevel.valueOf(permissionClaim)
} catch (_: IllegalArgumentException) {
log.warn { "[AdminInterceptor] 관리자 권한 변환 실패: token=${token}, permissionClaim=${permissionClaim}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN)
}
if (!permission.hasPrivilege(requiredPrivilege)) {
log.warn { "[AdminInterceptor] 관리자 권한 부족: required=${requiredPrivilege} / current=${permission.privileges}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
}
return permission
}
}

View File

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

View File

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

View File

@ -1,8 +1,10 @@
package roomescape.common.config
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.*
import com.fasterxml.jackson.databind.DeserializationFeature
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.datatype.jsr310.JavaTimeModule
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 org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import roomescape.common.exception.CommonErrorCode
import roomescape.common.exception.RoomescapeException
import java.time.*
import java.time.format.DateTimeFormatter
@ -57,30 +57,6 @@ class JacksonConfig {
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>() {
override fun serialize(
value: LocalDateTime,

View File

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

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.WebMvcConfigurer
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.resolver.CurrentUserContextResolver
import roomescape.auth.web.support.resolver.UserContextResolver
@Configuration
class WebMvcConfig(
private val adminInterceptor: AdminInterceptor,
private val userInterceptor: UserInterceptor,
private val authenticatedInterceptor: AuthenticatedInterceptor,
private val currentUserContextResolver: CurrentUserContextResolver
private val userContextResolver: UserContextResolver,
) : WebMvcConfigurer {
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
resolvers.add(currentUserContextResolver)
resolvers.add(userContextResolver)
}
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(adminInterceptor)
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
import roomescape.admin.infrastructure.persistence.AdminEntity
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 val id: Long
abstract val password: String
abstract val name: String
abstract fun toResponse(accessToken: String): LoginSuccessResponse
}
data class AdminLoginCredentials(
override val id: Long,
override val password: String,
val permissionLevel: AdminPermissionLevel
) : LoginCredentials()
override val name: String,
val type: AdminType,
val storeId: Long?,
val permissionLevel: AdminPermissionLevel,
) : LoginCredentials() {
override fun toResponse(accessToken: String) = AdminLoginSuccessResponse(
accessToken = accessToken,
name = name,
type = type,
storeId = storeId
)
}
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(
override val id: Long,
override val password: String,
) : LoginCredentials()
override val name: String,
) : LoginCredentials() {
override fun toResponse(accessToken: String) = UserLoginSuccessResponse(
accessToken = accessToken,
name = name
)
}
data class CurrentUserContext(
val id: Long,
val name: String,
val type: PrincipalType
);
fun UserEntity.toCredentials() = UserLoginCredentials(
id = this.id,
password = this.password,
name = this.name,
)
enum class PrincipalType {
USER, ADMIN
}
data class OperatorInfo(
data class CurrentUserContext(
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 jakarta.servlet.http.HttpServletRequest
import org.slf4j.MDC
import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY
import roomescape.common.util.MdcPrincipalId
enum class LogType {
INCOMING_HTTP_REQUEST,
@ -34,7 +33,7 @@ class ApiLogMessageConverter(
controllerPayload: Map<String, Any>,
): String {
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"
payload.putAll(controllerPayload)
@ -48,7 +47,7 @@ class ApiLogMessageConverter(
payload["endpoint"] = request.endpoint
payload["status_code"] = request.httpStatus
MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLongOrNull()
MdcPrincipalId.extractAsLongOrNull()
?.let { payload["principal_id"] = it }
?: 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.util.ContentCachingRequestWrapper
import org.springframework.web.util.ContentCachingResponseWrapper
import roomescape.common.util.MdcPrincipalId
private val log: KLogger = KotlinLogging.logger {}
@ -33,7 +34,7 @@ class HttpRequestLoggingFilter(
cachedResponse.copyBodyToResponse()
} finally {
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.web.bind.annotation.RequestBody
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.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse
@ -18,18 +18,17 @@ import roomescape.payment.web.PaymentCreateResponse
interface PaymentAPI {
@UserOnly
@Operation(summary = "결제 승인", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
@Operation(summary = "결제 승인")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun confirmPayment(
@RequestParam(required = true) reservationId: Long,
@Valid @RequestBody request: PaymentConfirmRequest
): ResponseEntity<CommonApiResponse<PaymentCreateResponse>>
@UserOnly
@Operation(summary = "결제 취소", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
@Operation(summary = "결제 취소")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun cancelPayment(
@CurrentUser user: CurrentUserContext,
@User user: CurrentUserContext,
@Valid @RequestBody request: PaymentCancelRequest
): ResponseEntity<CommonApiResponse<Unit>>
}

View File

@ -29,13 +29,13 @@ class TosspayClient(
orderId: String,
amount: Int,
): PaymentClientConfirmResponse {
val startTime = System.currentTimeMillis()
log.info { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" }
return confirmClient.request(paymentKey, orderId, amount)
.also {
log.info { "[TosspayClient.confirm] 결제 승인 완료: response=$it" }
log.info { "[TosspayClient.confirm] 결제 승인 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" }
}
}
fun cancel(
@ -43,10 +43,11 @@ class TosspayClient(
amount: Int,
cancelReason: String
): PaymentClientCancelResponse {
val startTime = System.currentTimeMillis()
log.info { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" }
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 org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
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 org.springframework.web.bind.annotation.*
import roomescape.auth.web.support.User
import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse
import roomescape.payment.business.PaymentService
@ -31,7 +27,7 @@ class PaymentController(
@PostMapping("/cancel")
override fun cancelPayment(
@CurrentUser user: CurrentUserContext,
@User user: CurrentUserContext,
@Valid @RequestBody request: PaymentCancelRequest
): ResponseEntity<CommonApiResponse<Unit>> {
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.Id
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
@Entity
@Table(name = "region")
@Table(name = "region", uniqueConstraints = [UniqueConstraint(columnNames = ["sidoCode", "sigunguCode"])])
class RegionEntity(
@Id
val code: String,
val sidoCode: String,
val sigunguCode: String,
val dongCode: String,
val sidoName: String,
val sigunguName: String,
val dongName: String,
)

View File

@ -1,5 +1,56 @@
package roomescape.region.infrastructure.persistence
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 roomescape.common.config.next
import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.PrincipalType
import roomescape.common.util.DateUtils
import roomescape.user.business.UserService
import roomescape.user.web.UserContactResponse
import roomescape.payment.business.PaymentService
import roomescape.payment.web.PaymentWithDetailResponse
import roomescape.reservation.exception.ReservationErrorCode
@ -20,10 +17,11 @@ import roomescape.reservation.infrastructure.persistence.*
import roomescape.reservation.web.*
import roomescape.schedule.business.ScheduleService
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.schedule.web.ScheduleSummaryResponse
import roomescape.schedule.web.ScheduleOverviewResponse
import roomescape.schedule.web.ScheduleUpdateRequest
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.LocalDateTime
@ -53,7 +51,7 @@ class ReservationService(
val reservation: ReservationEntity = request.toEntity(id = tsidFactory.next(), userId = user.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
@ -91,7 +89,7 @@ class ReservationService(
}
@Transactional(readOnly = true)
fun findUserSummaryReservation(user: CurrentUserContext): ReservationSummaryListResponse {
fun findAllUserReservationOverview(user: CurrentUserContext): ReservationOverviewListResponse {
log.info { "[ReservationService.findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" }
val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn(
@ -99,17 +97,9 @@ class ReservationService(
statuses = listOf(ReservationStatus.CONFIRMED, ReservationStatus.CANCELED)
)
return ReservationSummaryListResponse(reservations.map {
val schedule: ScheduleSummaryResponse = scheduleService.findSummaryById(it.scheduleId)
val theme: ThemeInfoResponse = themeService.findSummaryById(schedule.themeId)
ReservationSummaryResponse(
id = it.id,
themeName = theme.name,
date = schedule.date,
startAt = schedule.time,
status = it.status
)
return ReservationOverviewListResponse(reservations.map {
val schedule: ScheduleOverviewResponse = scheduleService.findScheduleOverviewById(it.scheduleId)
it.toOverviewResponse(schedule)
}).also {
log.info { "[ReservationService.findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" }
}
@ -164,7 +154,7 @@ class ReservationService(
reservation: ReservationEntity,
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}" }
throw ReservationException(ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION)
}
@ -175,7 +165,7 @@ class ReservationService(
canceledBy = user.id,
cancelReason = cancelReason,
canceledAt = LocalDateTime.now(),
status = CanceledReservationStatus.PROCESSING
status = CanceledReservationStatus.COMPLETED
).also {
canceledReservationRepository.save(it)
}
@ -183,7 +173,7 @@ class ReservationService(
private fun validateCanCreate(request: PendingReservationCreateRequest) {
val schedule = scheduleService.findSummaryById(request.scheduleId)
val theme = themeService.findSummaryById(schedule.themeId)
val theme = themeService.findInfoById(schedule.themeId)
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.RequestBody
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.User
import roomescape.auth.web.support.UserOnly
import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse
@ -19,46 +18,43 @@ import roomescape.reservation.web.*
interface ReservationAPI {
@Public
@Operation(summary = "결제 대기 예약 저장", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
@Operation(summary = "가장 많이 예약된 테마 ID 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findMostReservedThemeIds(
@RequestParam count: Int
): ResponseEntity<CommonApiResponse<MostReservedThemeIdListResponse>>
@UserOnly
@Operation(summary = "결제 대기 예약 저장", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
@Operation(summary = "결제 전 임시 예약 저장")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun createPendingReservation(
@CurrentUser user: CurrentUserContext,
@User user: CurrentUserContext,
@Valid @RequestBody request: PendingReservationCreateRequest
): ResponseEntity<CommonApiResponse<PendingReservationCreateResponse>>
@UserOnly
@Operation(summary = "예약 확정", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
@Operation(summary = "결제 후 임시 예약 확정")
@ApiResponses(ApiResponse(responseCode = "200"))
fun confirmReservation(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>>
@Authenticated
@Operation(summary = "예약 취소", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
@Operation(summary = "예약 취소")
@ApiResponses(ApiResponse(responseCode = "200"))
fun cancelReservation(
@CurrentUser user: CurrentUserContext,
@User user: CurrentUserContext,
@PathVariable id: Long,
@Valid @RequestBody request: ReservationCancelRequest
): ResponseEntity<CommonApiResponse<Unit>>
@UserOnly
@Operation(summary = "회원별 예약 요약 목록 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findSummaryByMemberId(
@CurrentUser user: CurrentUserContext,
): ResponseEntity<CommonApiResponse<ReservationSummaryListResponse>>
@Operation(summary = "회원별 예약 요약 목록 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findOverviewByUser(
@User user: CurrentUserContext,
): ResponseEntity<CommonApiResponse<ReservationOverviewListResponse>>
@UserOnly
@Operation(summary = "특정 예약에 대한 상세 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
@Operation(summary = "특정 예약에 대한 상세 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findDetailById(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<ReservationDetailResponse>>

View File

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

View File

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

View File

@ -9,8 +9,12 @@ 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.common.dto.OperatorInfo
import roomescape.schedule.business.domain.ScheduleOverview
import roomescape.schedule.exception.ScheduleErrorCode
import roomescape.schedule.infrastructure.persistence.ScheduleEntity
import roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory
import roomescape.schedule.infrastructure.persistence.ScheduleRepository
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.schedule.web.*
@ -18,6 +22,15 @@ import java.time.LocalDate
private val log: KLogger = KotlinLogging.logger {}
/**
* Structure:
* - Public: 모두가 접근 가능
* - User: 회원(로그인된 사용자) 사용 가능
* - All-Admin: 모든 관리자가 사용 가능
* - Store-Admin: 매장 관리자만 사용 가능
* - Other-Service: 다른 서비스에서 호출하는 메서드
* - Common: 공통 메서드
*/
@Service
class ScheduleService(
private val scheduleRepository: ScheduleRepository,
@ -25,73 +38,25 @@ class ScheduleService(
private val tsidFactory: TsidFactory,
private val adminService: AdminService
) {
// ========================================
// Public (인증 불필요)
// ========================================
@Transactional(readOnly = true)
fun findThemesByDate(date: LocalDate): AvailableThemeIdListResponse {
log.info { "[ScheduleService.findThemesByDate] 동일한 날짜의 모든 테마 조회: date=$date" }
fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse {
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 {
log.info { "[ScheduleService.findThemesByDate] date=${date}${it.themeIds.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}" }
log.info { "[ScheduleService.getStoreScheduleByDate] storeId=${storeId}, date=$date${it.schedules.size}개 일정 조회 완료" }
}
}
// ========================================
// User (회원 로그인 필요)
// ========================================
@Transactional
fun holdSchedule(id: Long) {
val schedule: ScheduleEntity = findOrThrow(id)
@ -104,6 +69,64 @@ class ScheduleService(
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
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
@ -113,14 +136,11 @@ class ScheduleService(
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}" }
}
}
@ -129,15 +149,41 @@ class ScheduleService(
fun deleteSchedule(id: Long) {
log.info { "[ScheduleService.deleteSchedule] 일정 삭제 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id)
scheduleValidator.validateCanDelete(schedule)
val schedule: ScheduleEntity = findOrThrow(id).also {
scheduleValidator.validateCanDelete(it)
}
scheduleRepository.delete(schedule).also {
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 {
log.info { "[ScheduleService.findOrThrow] 일정 조회 시작: id=$id" }

View File

@ -33,20 +33,21 @@ class ScheduleValidator(
val date: LocalDate = schedule.date
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 time: LocalTime = request.time
val themeId: Long = request.themeId
validateAlreadyExists(date, themeId, time)
validateDateTime(date, time)
validateAlreadyExists(storeId, date, themeId, time)
validateNotInPast(date, time)
validateTimeNotConflict(storeId, request.date, request.themeId, request.time)
}
private fun validateAlreadyExists(date: LocalDate, themeId: Long, time: LocalTime) {
if (scheduleRepository.existsByDateAndThemeIdAndTime(date, themeId, time)) {
private fun validateAlreadyExists(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime) {
if (scheduleRepository.existsDuplicate(storeId, date, themeId, time)) {
log.info {
"[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)
if (dateTime.isBefore(LocalDateTime.now())) {
@ -64,4 +65,13 @@ class ScheduleValidator(
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.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.auth.web.support.UserOnly
import roomescape.common.dto.AuditInfo
import roomescape.common.dto.response.CommonApiResponse
import roomescape.schedule.web.*
import java.time.LocalDate
interface ScheduleAPI {
interface AdminScheduleAPI {
@Public
@Operation(summary = "입력된 날짜에 가능한 테마 목록 조회")
@ApiResponses(ApiResponse(responseCode = "200", description = "입력된 날짜에 가능한 테마 목록 조회", useReturnTypeSchema = true))
fun findAvailableThemes(
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate
): ResponseEntity<CommonApiResponse<AvailableThemeIdListResponse>>
@Public
@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_SUMMARY)
@Operation(summary = "관리자 페이지에서 일정 요약 목록 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun searchSchedules(
@PathVariable("storeId") storeId: Long,
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate?,
@RequestParam(required = false) themeId: Long?,
): ResponseEntity<CommonApiResponse<AdminScheduleSummaryListResponse>>
@AdminOnly(privilege = Privilege.READ_DETAIL)
@Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true))
fun findScheduleDetail(
@Operation(summary = "관지라 페이지에서 특정 예약의 일정 상세 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findScheduleAudit(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<ScheduleDetailResponse>>
): ResponseEntity<CommonApiResponse<AuditInfo>>
@AdminOnly(privilege = Privilege.CREATE)
@Operation(summary = "일정 생성", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
@AdminOnly(type = AdminType.STORE, privilege = Privilege.CREATE)
@Operation(summary = "일정 생성")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun createSchedule(
@PathVariable("storeId") storeId: Long,
@Valid @RequestBody request: ScheduleCreateRequest
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>>
@AdminOnly(privilege = Privilege.UPDATE)
@Operation(summary = "일정 수정", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
@AdminOnly(type = AdminType.STORE, privilege = Privilege.UPDATE)
@Operation(summary = "일정 수정")
@ApiResponses(ApiResponse(responseCode = "200"))
fun updateSchedule(
@PathVariable("id") id: Long,
@Valid @RequestBody request: ScheduleUpdateRequest
): ResponseEntity<CommonApiResponse<Unit>>
@AdminOnly(privilege = Privilege.DELETE)
@Operation(summary = "일정 삭제", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true))
@AdminOnly(type = AdminType.STORE, privilege = Privilege.DELETE)
@Operation(summary = "일정 삭제")
@ApiResponses(ApiResponse(responseCode = "204"))
fun deleteSchedule(
@PathVariable("id") id: Long
): 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", "이미 동일한 일정이 있어요."),
PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."),
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
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.LocalDateTime
import java.time.LocalTime
@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(
id: Long,
var date: LocalDate,
var time: LocalTime,
val storeId: Long,
var themeId: Long,
@Enumerated(value = EnumType.STRING)
var status: ScheduleStatus
) : AuditingBaseEntity(id) {
var status: ScheduleStatus,
) : 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(
time: LocalTime?,
@ -24,11 +45,29 @@ class ScheduleEntity(
) {
time?.let { this.time = it }
status?.let { this.status = it }
updateLastModifiedBy()
}
fun 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 {

View File

@ -2,23 +2,80 @@ package roomescape.schedule.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import roomescape.schedule.business.domain.ScheduleOverview
import java.time.LocalDate
import java.time.LocalTime
interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
fun findAllByDate(date: LocalDate): List<ScheduleEntity>
fun findAllByDateAndThemeId(date: LocalDate, themeId: Long): List<ScheduleEntity>
fun existsByDateAndThemeIdAndTime(date: LocalDate, themeId: Long, time: LocalTime): Boolean
@Query("""
SELECT
COUNT(s) > 0
FROM
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(
"""
SELECT DISTINCT s.themeId
FROM ScheduleEntity s
WHERE s.date = :date
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.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
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.response.CommonApiResponse
import roomescape.schedule.business.ScheduleService
import roomescape.schedule.docs.ScheduleAPI
import roomescape.schedule.docs.PublicScheduleAPI
import roomescape.schedule.docs.UserScheduleAPI
import java.time.LocalDate
@RestController
@RequestMapping("/schedules")
class ScheduleController(
private val scheduleService: ScheduleService
) : ScheduleAPI {
@GetMapping("/themes")
override fun findAvailableThemes(
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate
): ResponseEntity<CommonApiResponse<AvailableThemeIdListResponse>> {
val response = scheduleService.findThemesByDate(date)
) : UserScheduleAPI, PublicScheduleAPI {
return ResponseEntity.ok(CommonApiResponse(response))
}
@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")
@PostMapping("/schedules/{id}/hold")
override fun holdSchedule(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>> {
@ -60,22 +23,13 @@ class ScheduleController(
return ResponseEntity.ok(CommonApiResponse())
}
@PatchMapping("/{id}")
override fun updateSchedule(
@PathVariable("id") id: Long,
@Valid @RequestBody request: ScheduleUpdateRequest
): ResponseEntity<CommonApiResponse<Unit>> {
scheduleService.updateSchedule(id, request)
@GetMapping("/stores/{storeId}/schedules")
override fun getStoreSchedulesByDate(
@PathVariable("storeId") storeId: Long,
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate
): ResponseEntity<CommonApiResponse<ScheduleWithThemeListResponse>> {
val response = scheduleService.getStoreScheduleByDate(storeId, date)
return ResponseEntity.ok(CommonApiResponse(Unit))
}
@DeleteMapping("/{id}")
override fun deleteSchedule(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>> {
scheduleService.deleteSchedule(id)
return ResponseEntity.noContent().build()
return ResponseEntity.ok(CommonApiResponse(response))
}
}

View File

@ -1,71 +1,46 @@
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.ScheduleStatus
import roomescape.theme.infrastructure.persistence.Difficulty
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
data class AvailableThemeIdListResponse(
val themeIds: List<Long>
)
data class ScheduleByDateResponse(
// ========================================
// Public (인증 불필요)
// ========================================
data class ScheduleWithThemeResponse(
val id: Long,
val time: LocalTime,
val startFrom: LocalTime,
val endAt: LocalTime,
val themeId: Long,
val themeName: String,
val themeDifficulty: Difficulty,
val status: ScheduleStatus
)
data class ScheduleListByDateResponse(
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(
fun ScheduleOverview.toResponse() = ScheduleWithThemeResponse(
id = this.id,
date = this.date,
time = this.time,
status = this.status,
createdAt = this.createdAt,
createdBy = createdBy,
updatedAt = this.updatedAt,
updatedBy = updatedBy
startFrom = this.time,
endAt = this.getEndAt(),
themeId = this.themeId,
themeName = this.themeName,
themeDifficulty = this.themeDifficulty,
status = this.status
)
data class ScheduleWithThemeListResponse(
val schedules: List<ScheduleWithThemeResponse>
)
fun List<ScheduleOverview>.toResponse() = ScheduleWithThemeListResponse(
this.map { it.toResponse() }
)
// ========================================
// Other-Service (API 없이 다른 서비스에서 호출)
// ========================================
data class ScheduleSummaryResponse(
val date: LocalDate,
val time: LocalTime,
@ -79,3 +54,25 @@ fun ScheduleEntity.toSummaryResponse() = ScheduleSummaryResponse(
themeId = this.themeId,
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 roomescape.admin.business.AdminService
import roomescape.common.config.next
import roomescape.common.dto.AuditInfo
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.ThemeEntity
@ -16,6 +17,13 @@ import roomescape.theme.web.*
private val log: KLogger = KotlinLogging.logger {}
/**
* Structure:
* - Public: 모두가 접근 가능한 메서드
* - Store Admin: 매장 관리자가 사용하는 메서드
* - HQ Admin: 본사 관리자가 사용하는 메서드
* - Common: 공통 메서드
*/
@Service
class ThemeService(
private val themeRepository: ThemeRepository,
@ -23,34 +31,30 @@ class ThemeService(
private val tsidFactory: TsidFactory,
private val adminService: AdminService
) {
// ========================================
// Public (인증 불필요)
// ========================================
@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}" }
val result: MutableList<ThemeEntity> = mutableListOf()
val result: List<ThemeEntity> = themeRepository.findAllByIdIn(request.themeIds)
for (id in request.themeIds) {
val theme: ThemeEntity? = themeRepository.findByIdOrNull(id)
if (theme == null) {
log.warn { "[ThemeService.findThemesByIds] id=${id} 인 테마 조회 실패" }
continue
}
result.add(theme)
}
return result.toListResponse().also {
return result.toInfoListResponse().also {
log.info { "[ThemeService.findThemesByIds] ${it.themes.size} / ${request.themeIds.size} 개 테마 조회 완료" }
}
}
@Transactional(readOnly = true)
fun findThemesForReservation(): ThemeInfoListResponse {
log.info { "[ThemeService.findThemesForReservation] 예약 페이지에서의 테마 목록 조회 시작" }
return themeRepository.findOpenedThemes()
.toListResponse()
.also { log.info { "[ThemeService.findThemesForReservation] ${it.themes.size}개 테마 조회 완료" } }
}
// ========================================
// HQ Admin (본사)
// ========================================
@Transactional(readOnly = true)
fun findAdminThemes(): AdminThemeSummaryListResponse {
log.info { "[ThemeService.findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
@ -66,32 +70,24 @@ class ThemeService(
val theme: ThemeEntity = findOrThrow(id)
val createdBy = adminService.findOperatorById(theme.createdBy)
val updatedBy = adminService.findOperatorById(theme.updatedBy)
val createdBy = adminService.findOperatorOrUnknown(theme.createdBy)
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}" } }
}
@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
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponseV2 {
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse {
log.info { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" }
themeValidator.validateCanCreate(request)
val theme: ThemeEntity = themeRepository.save(
request.toEntity(tsidFactory.next())
)
val theme: ThemeEntity = request.toEntity(id = 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}" }
}
}
@ -131,12 +127,29 @@ class ThemeService(
request.availableMinutes,
request.expectedMinutesFrom,
request.expectedMinutesTo,
request.isOpen,
request.isActive,
).also {
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 {
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.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody
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.theme.web.*
@Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.")
interface ThemeAPIV2 {
interface AdminThemeAPI {
@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)
@Operation(summary = "모든 테마 조회", description = "관리자 페이지에서 요약된 테마 목록을 조회합니다.", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findAdminThemes(): ResponseEntity<CommonApiResponse<AdminThemeSummaryListResponse>>
@AdminOnly(privilege = Privilege.READ_DETAIL)
@Operation(summary = "테마 상세 조회", description = "해당 테마의 상세 정보를 조회합니다.", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
@AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_DETAIL)
@Operation(summary = "테마 상세 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity<CommonApiResponse<AdminThemeDetailResponse>>
@AdminOnly(privilege = Privilege.CREATE)
@Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity<CommonApiResponse<ThemeCreateResponseV2>>
@AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE)
@Operation(summary = "테마 추가")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity<CommonApiResponse<ThemeCreateResponse>>
@AdminOnly(privilege = Privilege.DELETE)
@Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true))
@AdminOnly(type = AdminType.HQ, privilege = Privilege.DELETE)
@Operation(summary = "테마 삭제")
@ApiResponses(ApiResponse(responseCode = "204"))
fun deleteTheme(@PathVariable id: Long): ResponseEntity<CommonApiResponse<Unit>>
@AdminOnly(privilege = Privilege.UPDATE)
@Operation(summary = "테마 수정", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
@AdminOnly(type = AdminType.HQ, privilege = Privilege.UPDATE)
@Operation(summary = "테마 수정")
@ApiResponses(ApiResponse(responseCode = "200"))
fun updateTheme(
@PathVariable id: Long,
@Valid @RequestBody themeUpdateRequest: ThemeUpdateRequest
@Valid @RequestBody request: ThemeUpdateRequest
): 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
@Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>>
@Operation(summary = "입력된 모든 ID에 대한 테마 정보 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findThemeInfosByIds(request: ThemeIdListRequest): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>>
@Public
@Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findThemesByIds(request: ThemeIdListResponse): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>>
@Operation(summary = "입력된 테마 ID에 대한 정보 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findThemeInfoById(@PathVariable id: Long): ResponseEntity<CommonApiResponse<ThemeInfoResponse>>
}

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