generated from pricelees/issue-pr-template
feat: 지금까지 구현된 매장 등 정보를 반영한 프론트엔드 기능 구현 완료
This commit is contained in:
parent
7a6afc7282
commit
dc37ae6d1a
@ -1,5 +1,6 @@
|
|||||||
import axios, {type AxiosError, type AxiosRequestConfig, type Method} from 'axios';
|
import axios, {type AxiosError, type AxiosRequestConfig, type Method} from 'axios';
|
||||||
import JSONbig from 'json-bigint';
|
import JSONbig from 'json-bigint';
|
||||||
|
import { PrincipalType } from './auth/authTypes';
|
||||||
|
|
||||||
// Create a JSONbig instance that stores big integers as strings
|
// Create a JSONbig instance that stores big integers as strings
|
||||||
const JSONbigString = JSONbig({ storeAsString: true });
|
const JSONbigString = JSONbig({ storeAsString: true });
|
||||||
@ -38,7 +39,7 @@ async function request<T>(
|
|||||||
method: Method,
|
method: Method,
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
data: object = {},
|
data: object = {},
|
||||||
isRequiredAuth: boolean = false
|
type: PrincipalType,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const config: AxiosRequestConfig = {
|
const config: AxiosRequestConfig = {
|
||||||
method,
|
method,
|
||||||
@ -48,7 +49,9 @@ async function request<T>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const accessToken = localStorage.getItem('accessToken');
|
const accessTokenKey = type === PrincipalType.ADMIN ? 'adminAccessToken' : 'accessToken';
|
||||||
|
const accessToken = localStorage.getItem(accessTokenKey);
|
||||||
|
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
if (!config.headers) {
|
if (!config.headers) {
|
||||||
config.headers = {};
|
config.headers = {};
|
||||||
@ -70,30 +73,50 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> {
|
async function get<T>(endpoint: string): Promise<T> {
|
||||||
return request<T>('GET', endpoint, {}, isRequiredAuth);
|
return request<T>('GET', endpoint, {}, PrincipalType.USER);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
|
async function adminGet<T>(endpoint: string): Promise<T> {
|
||||||
return request<T>('POST', endpoint, data, isRequiredAuth);
|
return request<T>('GET', endpoint, {}, PrincipalType.ADMIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function put<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
|
async function post<T>(endpoint: string, data: object = {}): Promise<T> {
|
||||||
return request<T>('PUT', endpoint, data, isRequiredAuth);
|
return request<T>('POST', endpoint, data, PrincipalType.USER);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patch<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
|
async function adminPost<T>(endpoint: string, data: object = {}): Promise<T> {
|
||||||
return request<T>('PATCH', endpoint, data, isRequiredAuth);
|
return request<T>('POST', endpoint, data, PrincipalType.ADMIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> {
|
async function put<T>(endpoint: string, data: object = {}): Promise<T> {
|
||||||
return request<T>('DELETE', endpoint, {}, isRequiredAuth);
|
return request<T>('PUT', endpoint, data, PrincipalType.USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminPut<T>(endpoint: string, data: object = {}): Promise<T> {
|
||||||
|
return request<T>('PUT', endpoint, data, PrincipalType.ADMIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patch<T>(endpoint: string, data: object = {}): Promise<T> {
|
||||||
|
return request<T>('PATCH', endpoint, data, PrincipalType.USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminPatch<T>(endpoint: string, data: object = {}): Promise<T> {
|
||||||
|
return request<T>('PATCH', endpoint, data, PrincipalType.ADMIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del<T>(endpoint: string): Promise<T> {
|
||||||
|
return request<T>('DELETE', endpoint, {}, PrincipalType.USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminDel<T>(endpoint: string): Promise<T> {
|
||||||
|
return request<T>('DELETE', endpoint, {}, PrincipalType.ADMIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
get,
|
get, adminGet,
|
||||||
post,
|
post, adminPost,
|
||||||
put,
|
put, adminPut,
|
||||||
patch,
|
patch, adminPatch,
|
||||||
del
|
del, adminDel,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,20 +12,22 @@ export const userLogin = async (
|
|||||||
return await apiClient.post<UserLoginSuccessResponse>(
|
return await apiClient.post<UserLoginSuccessResponse>(
|
||||||
'/auth/login',
|
'/auth/login',
|
||||||
{ ...data, principalType: PrincipalType.USER },
|
{ ...data, principalType: PrincipalType.USER },
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminLogin = async (
|
export const adminLogin = async (
|
||||||
data: Omit<LoginRequest, 'principalType'>,
|
data: Omit<LoginRequest, 'principalType'>,
|
||||||
): Promise<AdminLoginSuccessResponse> => {
|
): Promise<AdminLoginSuccessResponse> => {
|
||||||
return await apiClient.post<AdminLoginSuccessResponse>(
|
return await apiClient.adminPost<AdminLoginSuccessResponse>(
|
||||||
'/auth/login',
|
'/auth/login',
|
||||||
{ ...data, principalType: PrincipalType.ADMIN },
|
{ ...data, principalType: PrincipalType.ADMIN },
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logout = async (): Promise<void> => {
|
export const logout = async (): Promise<void> => {
|
||||||
await apiClient.post('/auth/logout', {}, true);
|
await apiClient.post('/auth/logout', {});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const adminLogout = async (): Promise<void> => {
|
||||||
|
await apiClient.adminPost('/auth/logout', {});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,24 @@
|
|||||||
import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes";
|
import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes";
|
||||||
import type {UserContactRetrieveResponse} from "@_api/user/userTypes";
|
import type {UserContactRetrieveResponse} from "@_api/user/userTypes";
|
||||||
|
|
||||||
|
export interface ReservationData {
|
||||||
|
scheduleId: string;
|
||||||
|
store: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
theme: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
minParticipants: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
}
|
||||||
|
date: string; // "yyyy-MM-dd"
|
||||||
|
startFrom: string; // "HH:mm ~ HH:mm"
|
||||||
|
endAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const ReservationStatus = {
|
export const ReservationStatus = {
|
||||||
PENDING: 'PENDING',
|
PENDING: 'PENDING',
|
||||||
CONFIRMED: 'CONFIRMED',
|
CONFIRMED: 'CONFIRMED',
|
||||||
|
|||||||
@ -1,37 +1,49 @@
|
|||||||
import apiClient from '../apiClient';
|
import apiClient from "@_api/apiClient";
|
||||||
import type {
|
import type { AdminScheduleSummaryListResponse, ScheduleCreateRequest, ScheduleCreateResponse, ScheduleStatus, ScheduleUpdateRequest, ScheduleWithThemeListResponse } from "./scheduleTypes";
|
||||||
AvailableThemeIdListResponse,
|
import type { AuditInfo } from "@_api/common/commonTypes";
|
||||||
ScheduleCreateRequest,
|
|
||||||
ScheduleCreateResponse,
|
|
||||||
ScheduleDetailRetrieveResponse,
|
|
||||||
ScheduleRetrieveListResponse,
|
|
||||||
ScheduleUpdateRequest
|
|
||||||
} from './scheduleTypes';
|
|
||||||
|
|
||||||
export const fetchStoreAvailableThemesByDate = async (storeId: string, date: string): Promise<AvailableThemeIdListResponse> => {
|
// admin
|
||||||
return await apiClient.get<AvailableThemeIdListResponse>(`/stores/${storeId}/themes?date=${date}`);
|
export const fetchAdminSchedules = async (storeId: string, date?: string, themeId?: string): Promise<AdminScheduleSummaryListResponse> => {
|
||||||
};
|
const queryParams: string[] = [];
|
||||||
|
|
||||||
export const fetchStoreSchedulesByDateAndTheme = async (storeId: string, date: string, themeId: string): Promise<ScheduleRetrieveListResponse> => {
|
if (date && date.trim() !== '') {
|
||||||
return await apiClient.get<ScheduleRetrieveListResponse>(`/stores/${storeId}/schedules?date=${date}&themeId=${themeId}`);
|
queryParams.push(`date=${date}`);
|
||||||
};
|
}
|
||||||
|
|
||||||
export const fetchScheduleDetailById = async (id: string): Promise<ScheduleDetailRetrieveResponse> => {
|
if (themeId && themeId.trim() !== '') {
|
||||||
return await apiClient.get<ScheduleDetailRetrieveResponse>(`/admin/schedules/${id}`);
|
queryParams.push(`themeId=${themeId}`);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
// 기본 URL에 쿼리 파라미터 추가
|
||||||
|
const baseUrl = `/admin/stores/${storeId}/schedules`;
|
||||||
|
const fullUrl = queryParams.length > 0
|
||||||
|
? `${baseUrl}?${queryParams.join('&')}`
|
||||||
|
: baseUrl;
|
||||||
|
|
||||||
|
return await apiClient.adminGet<AdminScheduleSummaryListResponse>(fullUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const 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> => {
|
export const createSchedule = async (storeId: string, request: ScheduleCreateRequest): Promise<ScheduleCreateResponse> => {
|
||||||
return await apiClient.post<ScheduleCreateResponse>(`/admin/stores/${storeId}/schedules`, request);
|
return await apiClient.adminPost<ScheduleCreateResponse>(`/admin/stores/${storeId}/schedules`, request);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise<void> => {
|
export const updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise<void> => {
|
||||||
await apiClient.patch(`/admin/schedules/${id}`, request);
|
return await apiClient.adminPatch<void>(`/admin/schedules/${id}`, request);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteSchedule = async (id: string): Promise<void> => {
|
export const deleteSchedule = async (id: string): Promise<void> => {
|
||||||
await apiClient.del(`/admin/schedules/${id}`);
|
return await apiClient.adminDel<void>(`/admin/schedules/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// public
|
||||||
export const holdSchedule = async (id: string): Promise<void> => {
|
export const holdSchedule = async (id: string): Promise<void> => {
|
||||||
await apiClient.patch(`/schedules/${id}/hold`, {});
|
return await apiClient.post<void>(`/schedules/${id}/hold`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchSchedules = async (storeId: string, date: string): Promise<ScheduleWithThemeListResponse> => {
|
||||||
|
return await apiClient.get<ScheduleWithThemeListResponse>(`/stores/${storeId}/schedules?date=${date}`);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { OperatorInfo } from '@_api/common/commonTypes';
|
import type { Difficulty } from '@_api/theme/themeTypes';
|
||||||
|
|
||||||
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
|
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
|
||||||
|
|
||||||
@ -9,24 +9,11 @@ export const ScheduleStatus = {
|
|||||||
BLOCKED: 'BLOCKED' as ScheduleStatus,
|
BLOCKED: 'BLOCKED' as ScheduleStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AvailableThemeIdListResponse {
|
// Admin
|
||||||
themeIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduleRetrieveResponse {
|
|
||||||
id: string;
|
|
||||||
time: string; // "HH:mm"
|
|
||||||
status: ScheduleStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduleRetrieveListResponse {
|
|
||||||
schedules: ScheduleRetrieveResponse[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduleCreateRequest {
|
export interface ScheduleCreateRequest {
|
||||||
date: string; // "yyyy-MM-dd"
|
date: string;
|
||||||
time: string; // "HH:mm"
|
|
||||||
themeId: string;
|
themeId: string;
|
||||||
|
time: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleCreateResponse {
|
export interface ScheduleCreateResponse {
|
||||||
@ -40,13 +27,29 @@ export interface ScheduleUpdateRequest {
|
|||||||
status?: ScheduleStatus;
|
status?: ScheduleStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleDetailRetrieveResponse {
|
export interface AdminScheduleSummaryResponse {
|
||||||
id: string;
|
id: string,
|
||||||
date: string; // "yyyy-MM-dd"
|
themeName: string,
|
||||||
time: string; // "HH:mm"
|
startFrom: string,
|
||||||
status: ScheduleStatus;
|
endAt: string,
|
||||||
createdAt: string; // or Date
|
status: ScheduleStatus,
|
||||||
createdBy: OperatorInfo;
|
}
|
||||||
updatedAt: string; // or Date
|
|
||||||
updatedBy: OperatorInfo;
|
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[];
|
||||||
}
|
}
|
||||||
@ -1,22 +1,48 @@
|
|||||||
import apiClient from '@_api/apiClient';
|
import apiClient from '@_api/apiClient';
|
||||||
import { type SimpleStoreResponse, type StoreDetailResponse, type StoreRegisterRequest, type UpdateStoreRequest } from './storeTypes';
|
import type {
|
||||||
|
SimpleStoreListResponse,
|
||||||
|
StoreCreateResponse,
|
||||||
|
StoreDetailResponse,
|
||||||
|
StoreInfoResponse,
|
||||||
|
StoreRegisterRequest,
|
||||||
|
UpdateStoreRequest
|
||||||
|
} from './storeTypes';
|
||||||
|
|
||||||
export const getStores = async (): Promise<SimpleStoreResponse[]> => {
|
export const getStores = async (sidoCode?: string, sigunguCode?: string): Promise<SimpleStoreListResponse> => {
|
||||||
return await apiClient.get('/admin/stores');
|
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 getStoreDetail = async (id: number): Promise<StoreDetailResponse> => {
|
export const getStoreInfo = async (id: string): Promise<StoreInfoResponse> => {
|
||||||
return await apiClient.get(`/admin/stores/${id}`);
|
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 (data: StoreRegisterRequest): Promise<StoreDetailResponse> => {
|
export const createStore = async (request: StoreRegisterRequest): Promise<StoreCreateResponse> => {
|
||||||
return await apiClient.post('/admin/stores', data);
|
return await apiClient.adminPost<StoreCreateResponse>('/admin/stores', request);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateStore = async (id: number, data: UpdateStoreRequest): Promise<StoreDetailResponse> => {
|
export const updateStore = async (id: string, request: UpdateStoreRequest): Promise<void> => {
|
||||||
return await apiClient.put(`/admin/stores/${id}`, data);
|
await apiClient.adminPatch(`/admin/stores/${id}`, request);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteStore = async (id: number): Promise<void> => {
|
export const deleteStore = async (id: string): Promise<void> => {
|
||||||
await apiClient.del(`/admin/stores/${id}`);
|
await apiClient.adminPost(`/admin/stores/${id}/disable`, {});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import { type AuditInfo } from '@_api/common/commonTypes';
|
import {type AuditInfo} from '@_api/common/commonTypes';
|
||||||
import type { RegionInfoResponse } from '@_api/region/regionTypes';
|
import type {RegionInfoResponse} from '@_api/region/regionTypes';
|
||||||
|
|
||||||
export interface SimpleStoreResponse {
|
export interface SimpleStoreResponse {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SimpleStoreListResponse {
|
||||||
|
stores: SimpleStoreResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoreDetailResponse {
|
export interface StoreDetailResponse {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -25,8 +29,20 @@ export interface StoreRegisterRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStoreRequest {
|
export interface UpdateStoreRequest {
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
contact?: string;
|
||||||
|
regionCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreInfoResponse {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
address: string;
|
address: string;
|
||||||
contact: string;
|
contact: string;
|
||||||
regionCode: string;
|
businessRegNum: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreCreateResponse {
|
||||||
|
id: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import apiClient from '@_api/apiClient';
|
import apiClient from '@_api/apiClient';
|
||||||
import type {
|
import type {
|
||||||
AdminThemeDetailRetrieveResponse,
|
AdminThemeDetailResponse,
|
||||||
AdminThemeSummaryRetrieveListResponse,
|
AdminThemeSummaryListResponse,
|
||||||
SimpleActiveThemeListResponse,
|
SimpleActiveThemeListResponse,
|
||||||
ThemeCreateRequest,
|
ThemeCreateRequest,
|
||||||
ThemeCreateResponse,
|
ThemeCreateResponse,
|
||||||
@ -11,24 +11,28 @@ import type {
|
|||||||
ThemeUpdateRequest
|
ThemeUpdateRequest
|
||||||
} from './themeTypes';
|
} from './themeTypes';
|
||||||
|
|
||||||
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryRetrieveListResponse> => {
|
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryListResponse> => {
|
||||||
return await apiClient.get<AdminThemeSummaryRetrieveListResponse>('/admin/themes');
|
return await apiClient.adminGet<AdminThemeSummaryListResponse>('/admin/themes');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailRetrieveResponse> => {
|
export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailResponse> => {
|
||||||
return await apiClient.get<AdminThemeDetailRetrieveResponse>(`/admin/themes/${id}`);
|
return await apiClient.adminGet<AdminThemeDetailResponse>(`/admin/themes/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createTheme = async (themeData: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
|
export const createTheme = async (themeData: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
|
||||||
return await apiClient.post<ThemeCreateResponse>('/admin/themes', themeData);
|
return await apiClient.adminPost<ThemeCreateResponse>('/admin/themes', themeData);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateTheme = async (id: string, themeData: ThemeUpdateRequest): Promise<void> => {
|
export const updateTheme = async (id: string, themeData: ThemeUpdateRequest): Promise<void> => {
|
||||||
await apiClient.patch<any>(`/admin/themes/${id}`, themeData);
|
await apiClient.adminPatch<any>(`/admin/themes/${id}`, themeData);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteTheme = async (id: string): Promise<void> => {
|
export const deleteTheme = async (id: string): Promise<void> => {
|
||||||
await apiClient.del<any>(`/admin/themes/${id}`);
|
await apiClient.adminDel<any>(`/admin/themes/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchActiveThemes = async (): Promise<SimpleActiveThemeListResponse> => {
|
||||||
|
return await apiClient.adminGet<SimpleActiveThemeListResponse>('/admin/themes/active');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise<ThemeInfoListResponse> => {
|
export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise<ThemeInfoListResponse> => {
|
||||||
@ -38,7 +42,3 @@ export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise<Th
|
|||||||
export const fetchThemeById = async (id: string): Promise<ThemeInfoResponse> => {
|
export const fetchThemeById = async (id: string): Promise<ThemeInfoResponse> => {
|
||||||
return await apiClient.get<ThemeInfoResponse>(`/themes/${id}`);
|
return await apiClient.get<ThemeInfoResponse>(`/themes/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchActiveThemes = async (): Promise<SimpleActiveThemeListResponse> => {
|
|
||||||
return await apiClient.get<SimpleActiveThemeListResponse>('/admin/themes/active');
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,22 +1,9 @@
|
|||||||
import type { OperatorInfo } from '@_api/common/commonTypes';
|
import type { AuditInfo } from '@_api/common/commonTypes';
|
||||||
|
|
||||||
export interface AdminThemeDetailResponse {
|
export interface AdminThemeDetailResponse {
|
||||||
id: string;
|
theme: ThemeInfoResponse;
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
difficulty: Difficulty;
|
|
||||||
price: number;
|
|
||||||
minParticipants: number;
|
|
||||||
maxParticipants: number;
|
|
||||||
availableMinutes: number;
|
|
||||||
expectedMinutesFrom: number;
|
|
||||||
expectedMinutesTo: number;
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createDate: string; // Assuming ISO string format
|
audit: AuditInfo
|
||||||
updatedDate: string; // Assuming ISO string format
|
|
||||||
createdBy: OperatorInfo;
|
|
||||||
updatedBy: OperatorInfo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThemeCreateRequest {
|
export interface ThemeCreateRequest {
|
||||||
@ -45,14 +32,13 @@ export interface ThemeUpdateRequest {
|
|||||||
price?: number;
|
price?: number;
|
||||||
minParticipants?: number;
|
minParticipants?: number;
|
||||||
maxParticipants?: number;
|
maxParticipants?: number;
|
||||||
|
|
||||||
availableMinutes?: number;
|
availableMinutes?: number;
|
||||||
expectedMinutesFrom?: number;
|
expectedMinutesFrom?: number;
|
||||||
expectedMinutesTo?: number;
|
expectedMinutesTo?: number;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminThemeSummaryRetrieveResponse {
|
export interface AdminThemeSummaryResponse {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
difficulty: Difficulty;
|
difficulty: Difficulty;
|
||||||
@ -60,27 +46,8 @@ export interface AdminThemeSummaryRetrieveResponse {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminThemeSummaryRetrieveListResponse {
|
export interface AdminThemeSummaryListResponse {
|
||||||
themes: AdminThemeSummaryRetrieveResponse[];
|
themes: AdminThemeSummaryResponse[];
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminThemeDetailRetrieveResponse {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
difficulty: Difficulty;
|
|
||||||
price: number;
|
|
||||||
minParticipants: number;
|
|
||||||
maxParticipants: number;
|
|
||||||
availableMinutes: number;
|
|
||||||
expectedMinutesFrom: number;
|
|
||||||
expectedMinutesTo: number;
|
|
||||||
isActive: boolean;
|
|
||||||
createdAt: string; // LocalDateTime in Kotlin, map to string (ISO format)
|
|
||||||
createdBy: OperatorInfo;
|
|
||||||
updatedAt: string; // LocalDateTime in Kotlin, map to string (ISO format)
|
|
||||||
updatedBy: OperatorInfo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThemeInfoResponse {
|
export interface ThemeInfoResponse {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { adminLogin as apiLogin, logout as apiLogout } from '@_api/auth/authAPI';
|
import { adminLogin as apiLogin, adminLogout as apiLogout } from '@_api/auth/authAPI';
|
||||||
import {
|
import {
|
||||||
type AdminLoginSuccessResponse,
|
type AdminLoginSuccessResponse,
|
||||||
type AdminType,
|
type AdminType,
|
||||||
@ -27,7 +27,7 @@ export const AdminAuthProvider: React.FC<{ children: ReactNode }> = ({ children
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken');
|
const token = localStorage.getItem('adminAccessToken');
|
||||||
const storedName = localStorage.getItem('adminName');
|
const storedName = localStorage.getItem('adminName');
|
||||||
const storedType = localStorage.getItem('adminType') as AdminType | null;
|
const storedType = localStorage.getItem('adminType') as AdminType | null;
|
||||||
const storedStoreId = localStorage.getItem('adminStoreId');
|
const storedStoreId = localStorage.getItem('adminStoreId');
|
||||||
@ -48,7 +48,7 @@ export const AdminAuthProvider: React.FC<{ children: ReactNode }> = ({ children
|
|||||||
const login = async (data: Omit<LoginRequest, 'principalType'>) => {
|
const login = async (data: Omit<LoginRequest, 'principalType'>) => {
|
||||||
const response = await apiLogin(data);
|
const response = await apiLogin(data);
|
||||||
|
|
||||||
localStorage.setItem('accessToken', response.accessToken);
|
localStorage.setItem('adminAccessToken', response.accessToken);
|
||||||
localStorage.setItem('adminName', response.name);
|
localStorage.setItem('adminName', response.name);
|
||||||
localStorage.setItem('adminType', response.type);
|
localStorage.setItem('adminType', response.type);
|
||||||
if (response.storeId) {
|
if (response.storeId) {
|
||||||
@ -69,7 +69,7 @@ export const AdminAuthProvider: React.FC<{ children: ReactNode }> = ({ children
|
|||||||
try {
|
try {
|
||||||
await apiLogout();
|
await apiLogout();
|
||||||
} finally {
|
} finally {
|
||||||
localStorage.removeItem('accessToken');
|
localStorage.removeItem('adminAccessToken');
|
||||||
localStorage.removeItem('adminName');
|
localStorage.removeItem('adminName');
|
||||||
localStorage.removeItem('adminType');
|
localStorage.removeItem('adminType');
|
||||||
localStorage.removeItem('adminStoreId');
|
localStorage.removeItem('adminStoreId');
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
|
/* New CSS content */
|
||||||
.admin-schedule-container {
|
.admin-schedule-container {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
font-size: 0.95rem; /* Slightly smaller base font */
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 2rem;
|
font-size: 1.8rem; /* smaller */
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -18,7 +20,7 @@
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background-color: #f9f9f9;
|
background-color: #f9f9f9;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
align-items: center;
|
align-items: flex-end; /* Align to bottom */
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-controls .form-group {
|
.schedule-controls .form-group {
|
||||||
@ -26,18 +28,29 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Width adjustments */
|
||||||
|
.schedule-controls .store-selector-group,
|
||||||
|
.schedule-controls .date-selector-group {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-controls .theme-selector-group {
|
||||||
|
flex: 2 1 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.schedule-controls .form-label {
|
.schedule-controls .form-label {
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem; /* smaller */
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-controls .form-input,
|
.schedule-controls .form-input,
|
||||||
.schedule-controls .form-select {
|
.schedule-controls .form-select {
|
||||||
padding: 0.75rem;
|
padding: 0.6rem; /* smaller */
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 1rem;
|
font-size: 0.9rem; /* smaller */
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-card {
|
.section-card {
|
||||||
@ -63,10 +76,11 @@ table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
padding: 1rem;
|
padding: 0.8rem; /* smaller */
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #eee;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
font-size: 0.9rem; /* smaller */
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
@ -75,11 +89,11 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.4rem 0.8rem; /* smaller */
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem; /* smaller */
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@ -174,8 +188,8 @@ th {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
height: 3rem;
|
height: auto; /* remove fixed height */
|
||||||
box-sizing: border-box; /* Ensures padding/border are included in height */
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.details-form-container .button-group {
|
.details-form-container .button-group {
|
||||||
@ -190,7 +204,7 @@ th {
|
|||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
margin-bottom: 1.5rem; /* Add margin to separate from buttons */
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audit-title {
|
.audit-title {
|
||||||
@ -239,13 +253,13 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background-color: #fff;
|
background-color: #ffffff !important;
|
||||||
padding: 2rem;
|
padding: 2rem !important;
|
||||||
border-radius: 8px;
|
border-radius: 8px !important;
|
||||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3) !important;
|
||||||
width: 90%;
|
width: 90% !important;
|
||||||
max-width: 600px;
|
max-width: 600px !important;
|
||||||
position: relative;
|
position: relative !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close-btn {
|
.modal-close-btn {
|
||||||
@ -282,35 +296,25 @@ th {
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-modal-info-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 1rem;
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-item strong {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-item span {
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-details-button {
|
.theme-details-button {
|
||||||
align-self: center !important;
|
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;
|
||||||
}
|
}
|
||||||
@ -16,6 +16,19 @@
|
|||||||
text-align: center;
|
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 {
|
.section-card {
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
@ -81,15 +81,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-modal-content {
|
.theme-modal-content {
|
||||||
background-color: #ffffff;
|
background-color: #ffffff !important;
|
||||||
padding: 30px;
|
padding: 30px !important;
|
||||||
border-radius: 16px;
|
border-radius: 16px !important;
|
||||||
width: 90%;
|
width: 90% !important;
|
||||||
max-width: 600px;
|
max-width: 600px !important;
|
||||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2) !important;
|
||||||
display: flex;
|
display: flex !important;
|
||||||
flex-direction: column;
|
flex-direction: column !important;
|
||||||
gap: 20px;
|
gap: 20px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-thumbnail {
|
.modal-thumbnail {
|
||||||
@ -163,3 +163,18 @@
|
|||||||
.modal-button.close:hover {
|
.modal-button.close:hover {
|
||||||
background-color: #5a6268;
|
background-color: #5a6268;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Added for modal info alignment */
|
||||||
|
.modal-info-grid p {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.modal-info-grid p strong {
|
||||||
|
flex: 0 0 130px; /* fixed width for labels */
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.modal-info-grid p span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|||||||
@ -177,16 +177,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content-v2 {
|
.modal-content-v2 {
|
||||||
background: #ffffff;
|
background: #ffffff !important;
|
||||||
padding: 30px;
|
padding: 30px !important;
|
||||||
border-radius: 16px;
|
border-radius: 16px !important;
|
||||||
width: 90%;
|
width: 90% !important;
|
||||||
max-width: 500px;
|
max-width: 500px !important;
|
||||||
position: relative;
|
position: relative !important;
|
||||||
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
|
box-shadow: 0 5px 15px rgba(0,0,0,0.3) !important;
|
||||||
animation: slide-up 0.3s ease-out;
|
animation: slide-up 0.3s ease-out !important;
|
||||||
max-height: 90vh; /* Prevent modal from being too tall */
|
max-height: 90vh !important; /* Prevent modal from being too tall */
|
||||||
overflow-y: auto; /* Allow scrolling for long content */
|
overflow-y: auto !important; /* Allow scrolling for long content */
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-up {
|
@keyframes slide-up {
|
||||||
@ -240,13 +240,6 @@
|
|||||||
color: #505a67;
|
color: #505a67;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-section-v2 p strong {
|
|
||||||
color: #333d4b;
|
|
||||||
font-weight: 600;
|
|
||||||
min-width: 100px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancellation-section-v2 {
|
.cancellation-section-v2 {
|
||||||
background-color: #fcf2f2;
|
background-color: #fcf2f2;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
@ -346,3 +339,18 @@
|
|||||||
border-color: #007bff;
|
border-color: #007bff;
|
||||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Added for modal info alignment */
|
||||||
|
.modal-info-grid p {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.modal-info-grid p strong {
|
||||||
|
flex: 0 0 130px; /* fixed width for labels */
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.modal-info-grid p span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,43 +1,43 @@
|
|||||||
/* General Container */
|
/* General Container */
|
||||||
.reservation-v21-container {
|
.reservation-v21-container {
|
||||||
padding: 40px;
|
width: 100%;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 40px auto;
|
margin: 2rem auto;
|
||||||
background-color: #ffffff;
|
padding: 2rem;
|
||||||
border-radius: 16px;
|
font-family: 'Pretendard', sans-serif;
|
||||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07);
|
background-color: #fff;
|
||||||
font-family: 'Toss Product Sans', sans-serif;
|
border-radius: 12px;
|
||||||
color: #333D4B;
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
color: #191F28;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
color: #212529;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Step Sections */
|
/* Step Section */
|
||||||
.step-section {
|
.step-section {
|
||||||
margin-bottom: 40px;
|
margin-bottom: 3rem;
|
||||||
padding: 24px;
|
padding: 1.5rem;
|
||||||
border: 1px solid #E5E8EB;
|
border: 1px solid #f1f3f5;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
transition: all 0.3s ease;
|
background-color: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-section.disabled {
|
.step-section.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-color: #F9FAFB;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-section h3 {
|
.step-section h3 {
|
||||||
font-size: 20px;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 20px;
|
margin-top: 0;
|
||||||
color: #191F28;
|
margin-bottom: 1.5rem;
|
||||||
|
color: #343a40;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Date Carousel */
|
/* Date Carousel */
|
||||||
@ -45,274 +45,241 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
margin-bottom: 1rem;
|
||||||
margin: 20px 0;
|
}
|
||||||
|
|
||||||
|
.carousel-arrow {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #868e96;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-options-container {
|
.date-options-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
overflow-x: hidden;
|
overflow-x: auto;
|
||||||
flex-grow: 1;
|
-ms-overflow-style: none;
|
||||||
justify-content: space-between;
|
scrollbar-width: none;
|
||||||
margin: 0px 15px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-arrow, .today-button {
|
.date-options-container::-webkit-scrollbar {
|
||||||
background-color: #F2F4F6;
|
display: none;
|
||||||
border: 1px solid #E5E8EB;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #4E5968;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-button {
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
width: auto;
|
|
||||||
padding: 0 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-arrow:hover, .today-button:hover {
|
|
||||||
background-color: #E5E8EB;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-option {
|
.date-option {
|
||||||
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 8px;
|
padding: 10px;
|
||||||
border-radius: 8px;
|
border-radius: 50%;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 1px solid transparent;
|
align-items: center;
|
||||||
transition: all 0.3s ease;
|
transition: background-color 0.3s, color 0.3s;
|
||||||
width: 60px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-option:hover {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-option.active {
|
|
||||||
border: 1px solid #007bff;
|
|
||||||
background-color: #e7f3ff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-option .day-of-week {
|
.date-option .day-of-week {
|
||||||
font-size: 12px;
|
font-size: 0.8rem;
|
||||||
color: #888;
|
margin-bottom: 4px;
|
||||||
}
|
|
||||||
|
|
||||||
.date-option.active .day-of-week {
|
|
||||||
color: #007bff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-option .day-circle {
|
.date-option .day-circle {
|
||||||
font-size: 16px;
|
font-weight: 600;
|
||||||
font-weight: bold;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 4px;
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
color: #333;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-option.active .day-circle {
|
.date-option.active {
|
||||||
background-color: #007bff;
|
background-color: #0064FF;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.date-option:not(.active):hover {
|
||||||
|
background-color: #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
.date-option.disabled {
|
.date-option.disabled {
|
||||||
opacity: 0.5;
|
color: #ced4da;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-option.disabled .day-circle {
|
.today-button {
|
||||||
background-color: #E5E8EB;
|
background-color: #f8f9fa;
|
||||||
color: #B0B8C1;
|
border: 1px solid #dee2e6;
|
||||||
}
|
border-radius: 20px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
|
||||||
/* Theme List */
|
|
||||||
.theme-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-card {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 12px;
|
margin-left: 1rem;
|
||||||
overflow: hidden;
|
font-weight: 500;
|
||||||
border: 2px solid #E5E8EB;
|
}
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
|
/* --- Region & Store Selectors --- */
|
||||||
|
.region-store-selectors {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-store-selectors select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23868e96%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right .7em top 50%;
|
||||||
|
background-size: .65em auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-store-selectors select:disabled {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-store-selectors select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0064FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Schedule List --- */
|
||||||
|
.schedule-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-card:hover {
|
.theme-schedule-group {
|
||||||
transform: translateY(-4px);
|
background-color: #fff;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-card.active {
|
.theme-header {
|
||||||
border-color: #3182F6;
|
|
||||||
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-thumbnail {
|
|
||||||
width: 100%;
|
|
||||||
height: 120px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-info {
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
justify-content: space-between;
|
||||||
flex-grow: 1;
|
align-items: center;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #f1f3f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-info h4 {
|
.theme-header h4 {
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-info p {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #6B7684;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
.theme-meta {
|
color: #343a40;
|
||||||
font-size: 14px;
|
|
||||||
color: #4E5968;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-meta p {
|
|
||||||
margin: 2px 0;
|
|
||||||
}
|
|
||||||
.theme-meta strong {
|
|
||||||
color: #333D4B;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-detail-button {
|
.theme-detail-button {
|
||||||
width: 100%;
|
padding: 0.5rem 1rem;
|
||||||
padding: 8px;
|
font-size: 0.9rem;
|
||||||
font-size: 14px;
|
background-color: transparent;
|
||||||
font-weight: 600;
|
color: #0064FF;
|
||||||
border: none;
|
border: 1px solid #0064FF;
|
||||||
background-color: #F2F4F6;
|
border-radius: 6px;
|
||||||
color: #4E5968;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
font-weight: 600;
|
||||||
|
transition: background-color 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-detail-button:hover {
|
.theme-detail-button:hover {
|
||||||
background-color: #E5E8EB;
|
background-color: #0064FF;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Time Slots */
|
/* Time Slots */
|
||||||
.time-slots {
|
.time-slots {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
gap: 12px;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-slot {
|
.time-slot {
|
||||||
cursor: pointer;
|
padding: 0.75rem;
|
||||||
padding: 16px;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background-color: #F2F4F6;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
transition: all 0.2s;
|
||||||
transition: all 0.2s ease-in-out;
|
background-color: #fff;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-slot:hover {
|
.time-slot:hover:not(.disabled) {
|
||||||
background-color: #E5E8EB;
|
border-color: #0064FF;
|
||||||
|
color: #0064FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-slot.active {
|
.time-slot.active {
|
||||||
background-color: #3182F6;
|
background-color: #0064FF;
|
||||||
color: #ffffff;
|
color: white;
|
||||||
|
border-color: #0064FF;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-slot.disabled {
|
.time-slot.disabled {
|
||||||
background-color: #F9FAFB;
|
background-color: #f8f9fa;
|
||||||
color: #B0B8C1;
|
color: #adb5bd;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-availability {
|
.time-availability {
|
||||||
font-size: 12px;
|
|
||||||
display: block;
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-times {
|
.no-times {
|
||||||
|
color: #868e96;
|
||||||
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
background-color: #fff;
|
||||||
color: #8A94A2;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Next Step Button */
|
/* --- Next Step Button --- */
|
||||||
.next-step-button-container {
|
.next-step-button-container {
|
||||||
display: flex;
|
margin-top: 2rem;
|
||||||
justify-content: flex-end;
|
text-align: center;
|
||||||
margin-top: 30px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.next-step-button {
|
.next-step-button {
|
||||||
padding: 14px 28px;
|
width: 100%;
|
||||||
font-size: 18px;
|
max-width: 400px;
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #0064FF;
|
||||||
border: none;
|
border: none;
|
||||||
background-color: #3182F6;
|
border-radius: 8px;
|
||||||
color: #ffffff;
|
|
||||||
border-radius: 12px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.next-step-button:hover:not(:disabled) {
|
||||||
|
background-color: #0053d1;
|
||||||
|
}
|
||||||
|
|
||||||
.next-step-button:disabled {
|
.next-step-button:disabled {
|
||||||
background-color: #B0B8C1;
|
background-color: #a0a0a0;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.next-step-button:hover:not(:disabled) {
|
|
||||||
background-color: #1B64DA;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal Styles */
|
/* --- Modal Styles --- */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -328,170 +295,158 @@
|
|||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background-color: #ffffff !important;
|
background-color: #ffffff !important;
|
||||||
padding: 32px !important;
|
padding: 2rem !important;
|
||||||
border-radius: 16px !important;
|
border-radius: 12px !important;
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3) !important;
|
||||||
width: 90% !important;
|
width: 90% !important;
|
||||||
max-width: 500px !important;
|
max-width: 500px !important;
|
||||||
position: relative !important;
|
position: relative !important;
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1) !important;
|
max-height: 90vh !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close-button {
|
.modal-close-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 16px;
|
top: 1rem;
|
||||||
right: 16px;
|
right: 1rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 24px;
|
font-size: 1.5rem;
|
||||||
|
color: #868e96;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #8A94A2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-theme-thumbnail {
|
.modal-theme-thumbnail {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content h2 {
|
.modal-content h2 {
|
||||||
font-size: 24px;
|
margin-top: 0;
|
||||||
font-weight: 700;
|
margin-bottom: 2rem;
|
||||||
margin-bottom: 24px;
|
text-align: center;
|
||||||
color: #191F28;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-section {
|
.modal-section {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-section h3 {
|
.modal-section h3 {
|
||||||
font-size: 18px;
|
margin-top: 0;
|
||||||
font-weight: 600;
|
margin-bottom: 1rem;
|
||||||
margin-bottom: 12px;
|
font-size: 1.1rem;
|
||||||
border-bottom: 1px solid #E5E8EB;
|
color: #495057;
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-section p {
|
.modal-section p {
|
||||||
font-size: 16px;
|
margin: 0.5rem 0;
|
||||||
|
color: #495057;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #4E5968;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-section p strong {
|
|
||||||
color: #333D4B;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 12px;
|
gap: 1rem;
|
||||||
margin-top: 30px;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions button {
|
.modal-actions .cancel-button,
|
||||||
padding: 12px 24px;
|
.modal-actions .confirm-button {
|
||||||
font-size: 16px;
|
padding: 0.75rem 1.5rem;
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
border: none;
|
||||||
transition: background-color 0.2s;
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions .cancel-button {
|
.modal-actions .cancel-button {
|
||||||
background-color: #E5E8EB;
|
background-color: #f1f3f5;
|
||||||
color: #4E5968;
|
color: #495057;
|
||||||
}
|
|
||||||
.modal-actions .cancel-button:hover {
|
|
||||||
background-color: #D1D6DB;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions .confirm-button {
|
.modal-actions .confirm-button {
|
||||||
background-color: #3182F6;
|
background-color: #0064FF;
|
||||||
color: #ffffff;
|
color: #fff;
|
||||||
}
|
|
||||||
.modal-actions .confirm-button:hover {
|
|
||||||
background-color: #1B64DA;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Styles for ReservationFormPage */
|
/* --- Form Styles for ReservationFormPage --- */
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: bold;
|
margin-bottom: 0.5rem;
|
||||||
margin-bottom: 8px;
|
font-weight: 600;
|
||||||
color: #333;
|
color: #495057;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input[type="text"],
|
.form-input {
|
||||||
.form-group input[type="tel"],
|
|
||||||
.form-group input[type="number"],
|
|
||||||
.form-group textarea {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 0.75rem;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ddd;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 16px;
|
font-size: 1rem;
|
||||||
box-sizing: border-box;
|
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus, .form-group textarea:focus {
|
/* Success Page */
|
||||||
outline: none;
|
.success-icon {
|
||||||
border-color: #3182F6;
|
font-size: 4rem;
|
||||||
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
|
color: #0064FF;
|
||||||
}
|
|
||||||
|
|
||||||
.form-group textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.participant-control {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.participant-control input {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-left: none;
|
margin-bottom: 1.5rem;
|
||||||
border-right: none;
|
|
||||||
width: 60px;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.participant-control button {
|
.success-page-actions {
|
||||||
width: 44px;
|
display: flex;
|
||||||
height: 44px;
|
justify-content: center;
|
||||||
border: 1px solid #ccc;
|
gap: 1rem;
|
||||||
background-color: #f0f0f0;
|
margin-top: 2.5rem;
|
||||||
font-size: 20px;
|
}
|
||||||
cursor: pointer;
|
|
||||||
|
.success-page-actions .action-button {
|
||||||
|
padding: 0.8rem 1.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.participant-control button:hover:not(:disabled) {
|
.success-page-actions .action-button.secondary {
|
||||||
background-color: #e0e0e0;
|
background-color: #f1f3f5;
|
||||||
|
color: #495057;
|
||||||
}
|
}
|
||||||
|
|
||||||
.participant-control button:disabled {
|
.success-page-actions .action-button:not(.secondary) {
|
||||||
background-color: #e9ecef;
|
background-color: #0064FF;
|
||||||
cursor: not-allowed;
|
color: #fff;
|
||||||
color: #aaa;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.participant-control button:first-of-type {
|
/* Added for modal info alignment */
|
||||||
border-radius: 8px 0 0 8px;
|
.modal-info-grid p {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.modal-info-grid p strong {
|
||||||
.participant-control button:last-of-type {
|
flex: 0 0 130px; /* fixed width for labels */
|
||||||
border-radius: 0 8px 8px 0;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.modal-info-grid p span {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import '@_css/home-page-v2.css';
|
|||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import {useNavigate} from 'react-router-dom';
|
import {useNavigate} from 'react-router-dom';
|
||||||
import {fetchThemesByIds} from '@_api/theme/themeAPI';
|
import {fetchThemesByIds} from '@_api/theme/themeAPI';
|
||||||
import {mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
import {DifficultyKoreanMap, mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||||
|
|
||||||
const HomePage: React.FC = () => {
|
const HomePage: React.FC = () => {
|
||||||
const [ranking, setRanking] = useState<ThemeInfoResponse[]>([]);
|
const [ranking, setRanking] = useState<ThemeInfoResponse[]>([]);
|
||||||
@ -71,11 +71,12 @@ const HomePage: React.FC = () => {
|
|||||||
<div className="modal-theme-info">
|
<div className="modal-theme-info">
|
||||||
<h2>{selectedTheme.name}</h2>
|
<h2>{selectedTheme.name}</h2>
|
||||||
<p>{selectedTheme.description}</p>
|
<p>{selectedTheme.description}</p>
|
||||||
<div className="theme-details">
|
<div className="theme-details modal-info-grid">
|
||||||
<p><strong>난이도:</strong> {selectedTheme.difficulty}</p>
|
<p><strong>난이도:</strong><span>{DifficultyKoreanMap[selectedTheme.difficulty]}</span></p>
|
||||||
<p><strong>가격:</strong> {selectedTheme.price.toLocaleString()}원</p>
|
<p><strong>이용 가능 인원:</strong><span>{selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</span></p>
|
||||||
<p><strong>예상 시간:</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</p>
|
<p><strong>1인당 요금:</strong><span>{selectedTheme.price.toLocaleString()}원</span></p>
|
||||||
<p><strong>이용 가능 인원:</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</p>
|
<p><strong>예상 시간:</strong><span>{selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</span></p>
|
||||||
|
<p><strong>이용 가능 시간:</strong><span>{selectedTheme.availableMinutes}분</span></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-buttons">
|
<div className="modal-buttons">
|
||||||
|
|||||||
@ -117,10 +117,10 @@ const CancellationView: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<div className="cancellation-view-v2">
|
<div className="cancellation-view-v2">
|
||||||
<h3>취소 정보 확인</h3>
|
<h3>취소 정보 확인</h3>
|
||||||
<div className="cancellation-summary-v2">
|
<div className="cancellation-summary-v2 modal-info-grid">
|
||||||
<p><strong>테마:</strong> {reservation.themeName}</p>
|
<p><strong>테마:</strong><span>{reservation.themeName}</span></p>
|
||||||
<p><strong>신청 일시:</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
|
<p><strong>신청 일시:</strong><span>{formatDisplayDateTime(reservation.applicationDateTime)}</span></p>
|
||||||
{reservation.payment && <p><strong>결제 금액:</strong> {reservation.payment.totalAmount.toLocaleString()}원</p>}
|
{reservation.payment && <p><strong>결제 금액:</strong><span>{reservation.payment.totalAmount.toLocaleString()}원</span></p>}
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={reason}
|
value={reason}
|
||||||
@ -157,33 +157,33 @@ const ReservationDetailView: React.FC<{
|
|||||||
<>
|
<>
|
||||||
{payment.totalAmount !== detail.amount && (
|
{payment.totalAmount !== detail.amount && (
|
||||||
<>
|
<>
|
||||||
<p><strong>(카드)승인 금액:</strong> {detail.amount.toLocaleString()}원</p>
|
<p><strong>승인 금액:</strong><span>{detail.amount.toLocaleString()}원</span></p>
|
||||||
{detail.easypayDiscountAmount && (
|
{detail.easypayDiscountAmount && (
|
||||||
<p><strong>(간편결제)할인 금액:</strong> {detail.easypayDiscountAmount.toLocaleString()}원</p>
|
<p><strong>할인 금액:</strong><span>{detail.easypayDiscountAmount.toLocaleString()}원</span></p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{detail.easypayProviderName && (
|
{detail.easypayProviderName && (
|
||||||
<p><strong>간편결제사: </strong> {detail.easypayProviderName}</p>
|
<p><strong>간편결제사: </strong><span>{detail.easypayProviderName}</span></p>
|
||||||
)}
|
)}
|
||||||
<p><strong>카드사 / 구분:</strong> {detail.issuerCode}({detail.ownerType}) / {detail.cardType}</p>
|
<p><strong>카드사 / 구분:</strong><span>{detail.issuerCode}({detail.ownerType}) / {detail.cardType}</span></p>
|
||||||
<p><strong>카드 번호:</strong> {detail.cardNumber}</p>
|
<p><strong>카드 번호:</strong><span>{detail.cardNumber}</span></p>
|
||||||
<p><strong>할부:</strong> {detail.installmentPlanMonths === 0 ? '일시불' : `${detail.installmentPlanMonths}개월`}</p>
|
<p><strong>할부:</strong><span>{detail.installmentPlanMonths === 0 ? '일시불' : `${detail.installmentPlanMonths}개월`}</span></p>
|
||||||
<p><strong>승인 번호:</strong> {detail.approvalNumber}</p>
|
<p><strong>승인 번호:</strong><span>{detail.approvalNumber}</span></p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case 'BANK_TRANSFER':
|
case 'BANK_TRANSFER':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p><strong>은행:</strong> {detail.bankName}</p>
|
<p><strong>은행:</strong><span>{detail.bankName}</span></p>
|
||||||
<p><strong>정산 상태:</strong> {detail.settlementStatus}</p>
|
<p><strong>정산 상태:</strong><span>{detail.settlementStatus}</span></p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case 'EASYPAY_PREPAID':
|
case 'EASYPAY_PREPAID':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p><strong>결제 금액:</strong> {detail.amount.toLocaleString()}원</p>
|
<p><strong>결제 금액:</strong><span>{detail.amount.toLocaleString()}원</span></p>
|
||||||
{detail.discountAmount > 0 && <p><strong>포인트 사용:</strong> {detail.discountAmount.toLocaleString()}원</p>}
|
{detail.discountAmount > 0 && <p><strong>포인트 사용:</strong><span>{detail.discountAmount.toLocaleString()}원</span></p>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@ -193,13 +193,13 @@ const ReservationDetailView: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="modal-section-v2">
|
<div className="modal-section-v2 modal-info-grid">
|
||||||
<h3>예약 정보</h3>
|
<h3>예약 정보</h3>
|
||||||
<p><strong>예약 테마:</strong> {reservation.themeName}</p>
|
<p><strong>예약 테마:</strong><span>{reservation.themeName}</span></p>
|
||||||
<p><strong>이용 예정일:</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p>
|
<p><strong>이용 예정일:</strong><span>{formatCardDateTime(reservation.date, reservation.startAt)}</span></p>
|
||||||
<p><strong>예약자 이름:</strong> {reservation.user.name}</p>
|
<p><strong>예약자 이름:</strong><span>{reservation.user.name}</span></p>
|
||||||
<p><strong>예약자 이메일:</strong> {reservation.user.phone}</p>
|
<p><strong>예약자 이메일:</strong><span>{reservation.user.phone}</span></p>
|
||||||
<p><strong>예약 신청 일시:</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
|
<p><strong>예약 신청 일시:</strong><span>{formatDisplayDateTime(reservation.applicationDateTime)}</span></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!reservation.payment ? (
|
{!reservation.payment ? (
|
||||||
@ -209,14 +209,14 @@ const ReservationDetailView: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="modal-section-v2">
|
<div className="modal-section-v2 modal-info-grid">
|
||||||
<h3>결제 정보</h3>
|
<h3>결제 정보</h3>
|
||||||
<p><strong>주문 ID:</strong> {reservation.payment.orderId}</p>
|
<p><strong>주문 ID:</strong><span>{reservation.payment.orderId}</span></p>
|
||||||
<p><strong>총 결제액:</strong> {reservation.payment.totalAmount.toLocaleString()}원</p>
|
<p><strong>총 결제액:</strong><span>{reservation.payment.totalAmount.toLocaleString()}원</span></p>
|
||||||
<p><strong>결제 수단:</strong> {reservation.payment.method}</p>
|
<p><strong>결제 수단:</strong><span>{reservation.payment.method}</span></p>
|
||||||
{reservation.payment.approvedAt && <p><strong>결제 승인 일시:</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p>}
|
{reservation.payment.approvedAt && <p><strong>결제 승인 일시:</strong><span>{formatDisplayDateTime(reservation.payment.approvedAt)}</span></p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-section-v2">
|
<div className="modal-section-v2 modal-info-grid">
|
||||||
<h3>결제 상세 정보</h3>
|
<h3>결제 상세 정보</h3>
|
||||||
{renderPaymentSubDetails(reservation.payment)}
|
{renderPaymentSubDetails(reservation.payment)}
|
||||||
</div>
|
</div>
|
||||||
@ -224,12 +224,12 @@ const ReservationDetailView: React.FC<{
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{reservation.payment && reservation.payment.cancel && (
|
{reservation.payment && reservation.payment.cancel && (
|
||||||
<div className="modal-section-v2 cancellation-section-v2">
|
<div className="modal-section-v2 cancellation-section-v2 modal-info-grid">
|
||||||
<h3>취소 정보</h3>
|
<h3>취소 정보</h3>
|
||||||
<p><strong>취소 요청 일시:</strong> {formatDisplayDateTime(reservation.payment.cancel.cancellationRequestedAt)}</p>
|
<p><strong>취소 요청 일시:</strong><span>{formatDisplayDateTime(reservation.payment.cancel.cancellationRequestedAt)}</span></p>
|
||||||
<p><strong>환불 완료 일시:</strong> {formatDisplayDateTime(reservation.payment.cancel.cancellationApprovedAt)}</p>
|
<p><strong>환불 완료 일시:</strong><span>{formatDisplayDateTime(reservation.payment.cancel.cancellationApprovedAt)}</span></p>
|
||||||
<p><strong>취소 사유:</strong> {reservation.payment.cancel.cancelReason}</p>
|
<p><strong>취소 사유:</strong><span>{reservation.payment.cancel.cancelReason}</span></p>
|
||||||
<p><strong>취소 요청인:</strong> {reservation.payment.cancel.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</p>
|
<p><strong>취소 요청인:</strong><span>{reservation.payment.cancel.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</span></p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{reservation.payment && reservation.payment.status !== 'CANCELED' && (
|
{reservation.payment && reservation.payment.status !== 'CANCELED' && (
|
||||||
|
|||||||
@ -1,19 +1,20 @@
|
|||||||
import {isLoginRequiredError} from '@_api/apiClient';
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
import {createPendingReservation} from '@_api/reservation/reservationAPI';
|
import { createPendingReservation } from '@_api/reservation/reservationAPI';
|
||||||
import {fetchContact} from '@_api/user/userAPI';
|
import type { ReservationData } from '@_api/reservation/reservationTypes';
|
||||||
|
import { fetchContact } from '@_api/user/userAPI';
|
||||||
import '@_css/reservation-v2-1.css';
|
import '@_css/reservation-v2-1.css';
|
||||||
import React, {useEffect, useState} from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {useLocation, useNavigate} from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
|
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
|
||||||
|
|
||||||
const ReservationFormPage: React.FC = () => {
|
const ReservationFormPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { scheduleId, theme, date, time } = location.state || {};
|
const reservationData = location.state as ReservationData;
|
||||||
|
|
||||||
const [reserverName, setReserverName] = useState('');
|
const [reserverName, setReserverName] = useState('');
|
||||||
const [reserverContact, setReserverContact] = useState('');
|
const [reserverContact, setReserverContact] = useState('');
|
||||||
const [participantCount, setParticipantCount] = useState(theme.minParticipants || 1);
|
const [participantCount, setParticipantCount] = useState(reservationData.theme.minParticipants || 2);
|
||||||
const [requirement, setRequirement] = useState('');
|
const [requirement, setRequirement] = useState('');
|
||||||
const [isLoadingUserInfo, setIsLoadingUserInfo] = useState(true);
|
const [isLoadingUserInfo, setIsLoadingUserInfo] = useState(true);
|
||||||
|
|
||||||
@ -50,30 +51,29 @@ const ReservationFormPage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reservationData = {
|
createPendingReservation({
|
||||||
scheduleId,
|
scheduleId: reservationData.scheduleId,
|
||||||
reserverName,
|
reserverName,
|
||||||
reserverContact,
|
reserverContact,
|
||||||
participantCount,
|
participantCount,
|
||||||
requirement,
|
requirement,
|
||||||
};
|
}).then(res => {
|
||||||
|
|
||||||
createPendingReservation(reservationData)
|
|
||||||
.then(res => {
|
|
||||||
navigate('/reservation/payment', {
|
navigate('/reservation/payment', {
|
||||||
state: {
|
state: {
|
||||||
reservationId: res.id,
|
reservationId: res.id,
|
||||||
themeName: theme.name,
|
storeName: reservationData.store.name,
|
||||||
date: date,
|
themeName: reservationData.theme.name,
|
||||||
startAt: time,
|
date: reservationData.date,
|
||||||
price: theme.price * participantCount,
|
time: formatTime(reservationData.startFrom) + ' ~ ' + formatTime(reservationData.endAt),
|
||||||
|
themePrice: reservationData.theme.price,
|
||||||
|
totalPrice: reservationData.theme.price * participantCount,
|
||||||
|
participantCount: participantCount,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
}).catch(handleError);
|
||||||
.catch(handleError);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!scheduleId || !theme) {
|
if (!reservationData) {
|
||||||
return (
|
return (
|
||||||
<div className="reservation-v21-container">
|
<div className="reservation-v21-container">
|
||||||
<h2 className="page-title">잘못된 접근</h2>
|
<h2 className="page-title">잘못된 접근</h2>
|
||||||
@ -89,9 +89,10 @@ const ReservationFormPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="step-section">
|
<div className="step-section">
|
||||||
<h3>예약 내용 확인</h3>
|
<h3>예약 내용 확인</h3>
|
||||||
<p><strong>테마:</strong> {theme.name}</p>
|
<p><strong>매장:</strong> {reservationData.store.name}</p>
|
||||||
<p><strong>날짜:</strong> {formatDate(date)}</p>
|
<p><strong>테마:</strong> {reservationData.theme.name}</p>
|
||||||
<p><strong>시간:</strong> {formatTime(time)}</p>
|
<p><strong>날짜:</strong> {formatDate(reservationData.date)}</p>
|
||||||
|
<p><strong>시간:</strong> {formatTime(reservationData.startFrom)} ~ {formatTime(reservationData.endAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="step-section">
|
<div className="step-section">
|
||||||
@ -124,9 +125,9 @@ const ReservationFormPage: React.FC = () => {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={participantCount}
|
value={participantCount}
|
||||||
onChange={e => setParticipantCount(Math.max(theme.minParticipants, Math.min(theme.maxParticipants, Number(e.target.value))))}
|
onChange={e => setParticipantCount(Math.max(reservationData.theme.minParticipants, Math.min(reservationData.theme.maxParticipants, Number(e.target.value))))}
|
||||||
min={theme.minParticipants}
|
min={reservationData.theme.minParticipants}
|
||||||
max={theme.maxParticipants}
|
max={reservationData.theme.maxParticipants}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,30 +1,44 @@
|
|||||||
import {isLoginRequiredError} from '@_api/apiClient';
|
import {isLoginRequiredError} from '@_api/apiClient';
|
||||||
import {fetchStoreAvailableThemesByDate, fetchStoreSchedulesByDateAndTheme, holdSchedule} from '@_api/schedule/scheduleAPI';
|
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
|
||||||
import {type ScheduleRetrieveResponse, ScheduleStatus} from '@_api/schedule/scheduleTypes';
|
import {type SidoResponse, type SigunguResponse} from '@_api/region/regionTypes';
|
||||||
import {fetchThemesByIds} from '@_api/theme/themeAPI';
|
import {fetchSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
|
||||||
import {mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
import {ScheduleStatus, type ScheduleWithThemeResponse} from '@_api/schedule/scheduleTypes';
|
||||||
|
import {getStores} from '@_api/store/storeAPI';
|
||||||
|
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
||||||
|
import {fetchThemeById} from '@_api/theme/themeAPI';
|
||||||
|
import {DifficultyKoreanMap, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||||
import '@_css/reservation-v2-1.css';
|
import '@_css/reservation-v2-1.css';
|
||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import {useLocation, useNavigate} from 'react-router-dom';
|
import {useLocation, useNavigate} from 'react-router-dom';
|
||||||
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
|
import {type ReservationData} from '@_api/reservation/reservationTypes';
|
||||||
|
import {formatDate} from 'src/util/DateTimeFormatter';
|
||||||
|
|
||||||
const ReservationStep1Page: React.FC = () => {
|
const ReservationStep1Page: React.FC = () => {
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
const [viewDate, setViewDate] = useState<Date>(new Date()); // For carousel
|
const [viewDate, setViewDate] = useState<Date>(new Date());
|
||||||
const [themes, setThemes] = useState<ThemeInfoResponse[]>([]);
|
|
||||||
const [selectedTheme, setSelectedTheme] = useState<ThemeInfoResponse | null>(null);
|
const [sidoList, setSidoList] = useState<SidoResponse[]>([]);
|
||||||
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
|
const [sigunguList, setSigunguList] = useState<SigunguResponse[]>([]);
|
||||||
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleRetrieveResponse | null>(null);
|
const [storeList, setStoreList] = useState<SimpleStoreResponse[]>([]);
|
||||||
|
|
||||||
|
const [selectedSido, setSelectedSido] = useState('');
|
||||||
|
const [selectedSigungu, setSelectedSigungu] = useState('');
|
||||||
|
const [selectedStore, setSelectedStore] = useState<SimpleStoreResponse | null>(null);
|
||||||
|
|
||||||
|
const [schedulesByTheme, setSchedulesByTheme] = useState<Record<string, ScheduleWithThemeResponse[]>>({});
|
||||||
|
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleWithThemeResponse | null>(null);
|
||||||
|
|
||||||
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
||||||
|
const [modalThemeDetails, setModalThemeDetails] = useState<ThemeInfoResponse | null>(null);
|
||||||
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const handleError = (err: any) => {
|
const handleError = (err: any) => {
|
||||||
if (isLoginRequiredError(err)) {
|
if (isLoginRequiredError(err)) {
|
||||||
alert('로그인이 필요해요.');
|
alert('로그인이 필요해요.');
|
||||||
navigate('/login', { state: { from: location } });
|
navigate('/login', {state: {from: location}});
|
||||||
} else {
|
} else {
|
||||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||||
alert(message);
|
alert(message);
|
||||||
@ -33,67 +47,58 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDate) {
|
fetchSidoList().then(res => setSidoList(res.sidoList)).catch(handleError);
|
||||||
const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd
|
}, []);
|
||||||
fetchStoreAvailableThemesByDate(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 fetchThemesByIds({ themeIds });
|
|
||||||
} else {
|
|
||||||
return Promise.resolve({ themes: [] });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(themeResponse => {
|
|
||||||
setThemes(themeResponse.themes.map(mapThemeResponse));
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (isLoginRequiredError(err)) {
|
|
||||||
setThemes([]);
|
|
||||||
} else {
|
|
||||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
|
||||||
alert(message);
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setSelectedTheme(null);
|
|
||||||
setSchedules([]);
|
|
||||||
setSelectedSchedule(null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [selectedDate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDate && selectedTheme) {
|
if (selectedSido) {
|
||||||
const dateStr = selectedDate.toLocaleDateString('en-CA');
|
fetchSigunguList(selectedSido).then(res => setSigunguList(res.sigunguList)).catch(handleError);
|
||||||
fetchStoreSchedulesByDateAndTheme(dateStr, selectedTheme.id)
|
|
||||||
.then(res => {
|
|
||||||
setSchedules(res.schedules);
|
|
||||||
setSelectedSchedule(null);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (isLoginRequiredError(err)) {
|
|
||||||
setSchedules([]);
|
|
||||||
} else {
|
} else {
|
||||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
setSigunguList([]);
|
||||||
alert(message);
|
}
|
||||||
console.error(err);
|
setSelectedSigungu('');
|
||||||
|
}, [selectedSido]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getStores(selectedSido, selectedSigungu)
|
||||||
|
.then(res => setStoreList(res.stores))
|
||||||
|
.catch(handleError);
|
||||||
|
setSelectedStore(null);
|
||||||
|
}, [selectedSido, selectedSigungu]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDate && selectedStore) {
|
||||||
|
const dateStr = selectedDate.toLocaleDateString('en-CA');
|
||||||
|
fetchSchedules(selectedStore.id, dateStr)
|
||||||
|
.then(res => {
|
||||||
|
const grouped = res.schedules.reduce((acc, schedule) => {
|
||||||
|
const key = schedule.themeName;
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(schedule);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, ScheduleWithThemeResponse[]>);
|
||||||
|
setSchedulesByTheme(grouped);
|
||||||
|
})
|
||||||
|
.catch(handleError);
|
||||||
|
} else {
|
||||||
|
setSchedulesByTheme({});
|
||||||
}
|
}
|
||||||
setSelectedSchedule(null);
|
setSelectedSchedule(null);
|
||||||
});
|
}, [selectedDate, selectedStore]);
|
||||||
}
|
|
||||||
}, [selectedDate, selectedTheme]);
|
|
||||||
|
|
||||||
const handleNextStep = () => {
|
const handleDateSelect = (date: Date) => {
|
||||||
if (!selectedDate || !selectedTheme || !selectedSchedule) {
|
const today = new Date();
|
||||||
alert('날짜, 테마, 시간을 모두 선택해주세요.');
|
today.setHours(0, 0, 0, 0);
|
||||||
|
if (date < today) {
|
||||||
|
alert("지난 날짜는 선택할 수 없습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedSchedule.status !== ScheduleStatus.AVAILABLE) {
|
setSelectedDate(date);
|
||||||
alert('예약할 수 없는 시간입니다.');
|
};
|
||||||
|
|
||||||
|
const handleNextStep = () => {
|
||||||
|
if (!selectedSchedule) {
|
||||||
|
alert('예약할 시간을 선택해주세요.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsConfirmModalOpen(true);
|
setIsConfirmModalOpen(true);
|
||||||
@ -104,28 +109,38 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
|
|
||||||
holdSchedule(selectedSchedule.id)
|
holdSchedule(selectedSchedule.id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
navigate('/reservation/form', {
|
fetchThemeById(selectedSchedule.themeId).then(res => {
|
||||||
state: {
|
const reservationData: ReservationData = {
|
||||||
scheduleId: selectedSchedule.id,
|
scheduleId: selectedSchedule.id,
|
||||||
theme: selectedTheme,
|
store: {
|
||||||
|
id: selectedStore!.id,
|
||||||
|
name: selectedStore!.name,
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
id: res.id,
|
||||||
|
name: res.name,
|
||||||
|
price: res.price,
|
||||||
|
minParticipants: res.minParticipants,
|
||||||
|
maxParticipants: res.maxParticipants,
|
||||||
|
},
|
||||||
date: selectedDate.toLocaleDateString('en-CA'),
|
date: selectedDate.toLocaleDateString('en-CA'),
|
||||||
time: selectedSchedule.time,
|
startFrom: selectedSchedule.startFrom,
|
||||||
}
|
endAt: selectedSchedule.endAt,
|
||||||
});
|
};
|
||||||
|
navigate('/reservation/form', {state: reservationData});
|
||||||
|
}).catch(handleError);
|
||||||
})
|
})
|
||||||
.catch(handleError)
|
.catch(handleError);
|
||||||
.finally(() => setIsConfirmModalOpen(false));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDateSelect = (date: Date) => {
|
const openThemeModal = (themeId: string) => {
|
||||||
const today = new Date();
|
fetchThemeById(themeId)
|
||||||
today.setHours(0, 0, 0, 0);
|
.then(themeDetails => {
|
||||||
if (date < today) {
|
setModalThemeDetails(themeDetails);
|
||||||
alert("지난 날짜는 선택할 수 없습니다.");
|
setIsThemeModalOpen(true);
|
||||||
return;
|
})
|
||||||
}
|
.catch(handleError);
|
||||||
setSelectedDate(date);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const renderDateCarousel = () => {
|
const renderDateCarousel = () => {
|
||||||
const dates = [];
|
const dates = [];
|
||||||
@ -184,11 +199,6 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openThemeModal = (theme: ThemeInfoResponse) => {
|
|
||||||
setSelectedTheme(theme);
|
|
||||||
setIsThemeModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusText = (status: ScheduleStatus) => {
|
const getStatusText = (status: ScheduleStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case ScheduleStatus.AVAILABLE:
|
case ScheduleStatus.AVAILABLE:
|
||||||
@ -200,8 +210,6 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== ScheduleStatus.AVAILABLE;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reservation-v21-container">
|
<div className="reservation-v21-container">
|
||||||
<h2 className="page-title">예약하기</h2>
|
<h2 className="page-title">예약하기</h2>
|
||||||
@ -212,82 +220,97 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
|
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
|
||||||
<h3>2. 테마 선택</h3>
|
<h3>2. 매장 선택</h3>
|
||||||
<div className="theme-list">
|
<div className="region-store-selectors">
|
||||||
{themes.map(theme => (
|
<select value={selectedSido} onChange={e => setSelectedSido(e.target.value)}>
|
||||||
<div
|
<option value="">시/도</option>
|
||||||
key={theme.id}
|
{sidoList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
|
||||||
className={`theme-card ${selectedTheme?.id === theme.id ? 'active' : ''}`}
|
</select>
|
||||||
onClick={() => setSelectedTheme(theme)}
|
<select value={selectedSigungu} onChange={e => setSelectedSigungu(e.target.value)}
|
||||||
>
|
disabled={!selectedSido}>
|
||||||
<div className="theme-info">
|
<option value="">시/군/구 (전체)</option>
|
||||||
<h4>{theme.name}</h4>
|
{sigunguList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
|
||||||
<div className="theme-meta">
|
</select>
|
||||||
<p><strong>1인당 요금:</strong> {theme.price.toLocaleString()}원</p>
|
<select value={selectedStore?.id || ''}
|
||||||
<p><strong>난이도:</strong> {theme.difficulty}</p>
|
onChange={e => setSelectedStore(storeList.find(s => s.id === e.target.value) || null)}
|
||||||
<p><strong>참여 가능 인원:</strong> {theme.minParticipants} ~ {theme.maxParticipants}명</p>
|
disabled={storeList.length === 0}>
|
||||||
<p><strong>예상 소요 시간:</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}분</p>
|
<option value="">매장 선택</option>
|
||||||
<p><strong>이용 가능 시간:</strong> {theme.availableMinutes}분</p>
|
{storeList.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
</div>
|
</select>
|
||||||
<button className="theme-detail-button" onClick={(e) => { e.stopPropagation(); openThemeModal(theme); }}>상세보기</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`step-section ${!selectedTheme ? 'disabled' : ''}`}>
|
<div className={`step-section ${!selectedStore ? 'disabled' : ''}`}>
|
||||||
<h3>3. 시간 선택</h3>
|
<h3>3. 시간 선택</h3>
|
||||||
|
<div className="schedule-list">
|
||||||
|
{Object.keys(schedulesByTheme).length > 0 ? (
|
||||||
|
Object.entries(schedulesByTheme).map(([themeName, schedules]) => (
|
||||||
|
<div key={themeName} className="theme-schedule-group">
|
||||||
|
<div className="theme-header">
|
||||||
|
<h4>{themeName}</h4>
|
||||||
|
<button onClick={() => openThemeModal(schedules[0].themeId)}
|
||||||
|
className="theme-detail-button">상세보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="time-slots">
|
<div className="time-slots">
|
||||||
{schedules.length > 0 ? schedules.map(schedule => (
|
{schedules.map(schedule => (
|
||||||
<div
|
<div
|
||||||
key={schedule.id}
|
key={schedule.id}
|
||||||
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||||||
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||||||
>
|
>
|
||||||
{schedule.time}
|
{`${schedule.startFrom} ~ ${schedule.endAt}`}
|
||||||
<span className="time-availability">{getStatusText(schedule.status)}</span>
|
<span className="time-availability">{getStatusText(schedule.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
)) : <div className="no-times">선택 가능한 시간이 없습니다.</div>}
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="no-times">선택한 조건으로 예약 가능한 시간이 없습니다.</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="next-step-button-container">
|
<div className="next-step-button-container">
|
||||||
<button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}>
|
<button className="next-step-button" disabled={!selectedSchedule} onClick={handleNextStep}>
|
||||||
예약하기
|
예약하기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isThemeModalOpen && selectedTheme && (
|
{isThemeModalOpen && modalThemeDetails && (
|
||||||
<div className="modal-overlay" onClick={() => setIsThemeModalOpen(false)}>
|
<div className="modal-overlay" onClick={() => setIsThemeModalOpen(false)}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<button className="modal-close-button" onClick={() => setIsThemeModalOpen(false)}>×</button>
|
<button className="modal-close-button" onClick={() => setIsThemeModalOpen(false)}>×</button>
|
||||||
<img src={selectedTheme.thumbnailUrl} alt={selectedTheme.name} className="modal-theme-thumbnail" />
|
<img src={modalThemeDetails.thumbnailUrl} alt={modalThemeDetails.name}
|
||||||
<h2>{selectedTheme.name}</h2>
|
className="modal-theme-thumbnail"/>
|
||||||
<div className="modal-section">
|
<h2>{modalThemeDetails.name}</h2>
|
||||||
|
<div className="modal-section modal-info-grid">
|
||||||
<h3>테마 정보</h3>
|
<h3>테마 정보</h3>
|
||||||
<p><strong>난이도:</strong> {selectedTheme.difficulty}</p>
|
<p><strong>난이도:</strong><span>{DifficultyKoreanMap[modalThemeDetails.difficulty]}</span></p>
|
||||||
<p><strong>참여 인원:</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</p>
|
<p><strong>이용 가능 인원:</strong><span>{modalThemeDetails.minParticipants} ~ {modalThemeDetails.maxParticipants}명</span></p>
|
||||||
<p><strong>소요 시간:</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</p>
|
<p><strong>1인당 요금:</strong><span>{modalThemeDetails.price.toLocaleString()}원</span></p>
|
||||||
<p><strong>1인당 요금:</strong> {selectedTheme.price.toLocaleString()}원</p>
|
<p><strong>예상 시간:</strong><span>{modalThemeDetails.expectedMinutesFrom} ~ {modalThemeDetails.expectedMinutesTo}분</span></p>
|
||||||
|
<p><strong>이용 가능 시간:</strong><span>{modalThemeDetails.availableMinutes}분</span></p>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-section">
|
<div className="modal-section">
|
||||||
<h3>소개</h3>
|
<h3>소개</h3>
|
||||||
<p>{selectedTheme.description}</p>
|
<p>{modalThemeDetails.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isConfirmModalOpen && (
|
{isConfirmModalOpen && selectedSchedule && (
|
||||||
<div className="modal-overlay" onClick={() => setIsConfirmModalOpen(false)}>
|
<div className="modal-overlay" onClick={() => setIsConfirmModalOpen(false)}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<button className="modal-close-button" onClick={() => setIsConfirmModalOpen(false)}>×</button>
|
<button className="modal-close-button" onClick={() => setIsConfirmModalOpen(false)}>×</button>
|
||||||
<h2>예약 정보를 확인해주세요</h2>
|
<h2>예약 정보를 확인해주세요</h2>
|
||||||
<div className="modal-section">
|
<div className="modal-section modal-info-grid">
|
||||||
<p><strong>날짜:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
|
<p><strong>날짜:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
|
||||||
<p><strong>테마:</strong> {selectedTheme!!.name}</p>
|
<p><strong>매장:</strong><span>{selectedStore?.name}</span></p>
|
||||||
<p><strong>시간:</strong> {formatTime(selectedSchedule!!.time)}</p>
|
<p><strong>테마:</strong><span>{selectedSchedule.themeName}</span></p>
|
||||||
|
<p><strong>시간:</strong><span>{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}</span></p>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import {isLoginRequiredError} from '@_api/apiClient';
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
import {confirmPayment} from '@_api/payment/paymentAPI';
|
import { confirmPayment } from '@_api/payment/paymentAPI';
|
||||||
import {type PaymentConfirmRequest, PaymentType} from '@_api/payment/PaymentTypes';
|
import { type PaymentConfirmRequest, PaymentType } from '@_api/payment/PaymentTypes';
|
||||||
import {confirmReservation} from '@_api/reservation/reservationAPI';
|
import { confirmReservation } from '@_api/reservation/reservationAPI';
|
||||||
import '@_css/reservation-v2-1.css';
|
import '@_css/reservation-v2-1.css';
|
||||||
import React, {useEffect, useRef} from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import {useLocation, useNavigate} from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
|
import { formatDate } from 'src/util/DateTimeFormatter';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -19,7 +19,7 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
const paymentWidgetRef = useRef<any>(null);
|
const paymentWidgetRef = useRef<any>(null);
|
||||||
const paymentMethodsRef = useRef<any>(null);
|
const paymentMethodsRef = useRef<any>(null);
|
||||||
|
|
||||||
const { reservationId, themeName, date, startAt, price } = location.state || {};
|
const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
|
||||||
|
|
||||||
const handleError = (err: any) => {
|
const handleError = (err: any) => {
|
||||||
if (isLoginRequiredError(err)) {
|
if (isLoginRequiredError(err)) {
|
||||||
@ -51,12 +51,12 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
|
|
||||||
const paymentMethods = paymentWidget.renderPaymentMethods(
|
const paymentMethods = paymentWidget.renderPaymentMethods(
|
||||||
"#payment-method",
|
"#payment-method",
|
||||||
{ value: price },
|
{ value: totalPrice, currency: "KRW" },
|
||||||
{ variantKey: "DEFAULT" }
|
{ variantKey: "DEFAULT" }
|
||||||
);
|
);
|
||||||
paymentMethodsRef.current = paymentMethods;
|
paymentMethodsRef.current = paymentMethods;
|
||||||
};
|
};
|
||||||
}, [reservationId, price, navigate]);
|
}, [reservationId, totalPrice, navigate]);
|
||||||
|
|
||||||
const handlePayment = () => {
|
const handlePayment = () => {
|
||||||
if (!paymentWidgetRef.current || !reservationId) {
|
if (!paymentWidgetRef.current || !reservationId) {
|
||||||
@ -67,15 +67,16 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
const generateRandomString = () =>
|
const generateRandomString = () =>
|
||||||
crypto.randomUUID().replace(/-/g, '');
|
crypto.randomUUID().replace(/-/g, '');
|
||||||
|
|
||||||
|
|
||||||
paymentWidgetRef.current.requestPayment({
|
paymentWidgetRef.current.requestPayment({
|
||||||
orderId: generateRandomString(),
|
orderId: generateRandomString(),
|
||||||
orderName: `${themeName} 예약 결제`,
|
orderName: `${themeName} 예약 결제`,
|
||||||
amount: price,
|
amount: totalPrice,
|
||||||
}).then((data: any) => {
|
}).then((data: any) => {
|
||||||
const paymentData: PaymentConfirmRequest = {
|
const paymentData: PaymentConfirmRequest = {
|
||||||
paymentKey: data.paymentKey,
|
paymentKey: data.paymentKey,
|
||||||
orderId: data.orderId,
|
orderId: data.orderId,
|
||||||
amount: price, // Use the price from component state instead of widget response
|
amount: totalPrice,
|
||||||
paymentType: data.paymentType || PaymentType.NORMAL,
|
paymentType: data.paymentType || PaymentType.NORMAL,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -87,9 +88,12 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
alert('결제가 완료되었어요!');
|
alert('결제가 완료되었어요!');
|
||||||
navigate('/reservation/success', {
|
navigate('/reservation/success', {
|
||||||
state: {
|
state: {
|
||||||
themeName,
|
storeName: storeName,
|
||||||
date,
|
themeName: themeName,
|
||||||
startAt,
|
date: date,
|
||||||
|
time: time,
|
||||||
|
participantCount: participantCount,
|
||||||
|
totalPrice: totalPrice,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@ -109,10 +113,13 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
<h2 className="page-title">결제하기</h2>
|
<h2 className="page-title">결제하기</h2>
|
||||||
<div className="step-section">
|
<div className="step-section">
|
||||||
<h3>결제 정보 확인</h3>
|
<h3>결제 정보 확인</h3>
|
||||||
<p><strong>테마:</strong> {themeName}</p>
|
|
||||||
<p><strong>날짜:</strong> {formatDate(date)}</p>
|
<p><strong>날짜:</strong> {formatDate(date)}</p>
|
||||||
<p><strong>시간:</strong> {formatTime(startAt)}</p>
|
<p><strong>시간:</strong> {time}</p>
|
||||||
<p><strong>금액:</strong> {price.toLocaleString()}원</p>
|
<p><strong>테마:</strong> {themeName}</p>
|
||||||
|
<p><strong>매장:</strong> {storeName}</p>
|
||||||
|
<p><strong>인원:</strong> {participantCount}명</p>
|
||||||
|
<p><strong>1인당 금액:</strong> {themePrice.toLocaleString()}원</p>
|
||||||
|
<p><strong>총 금액:</strong> {totalPrice.toLocaleString()}원</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="step-section">
|
<div className="step-section">
|
||||||
<h3>결제 수단</h3>
|
<h3>결제 수단</h3>
|
||||||
@ -121,7 +128,7 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="next-step-button-container">
|
<div className="next-step-button-container">
|
||||||
<button onClick={handlePayment} className="next-step-button">
|
<button onClick={handlePayment} className="next-step-button">
|
||||||
{price.toLocaleString()}원 결제하기
|
{totalPrice.toLocaleString()}원 결제하기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,18 +1,13 @@
|
|||||||
import '@_css/reservation-v2-1.css'; // Reuse the new CSS
|
import '@_css/reservation-v2-1.css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {Link, useLocation} from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
|
import { formatDate } from 'src/util/DateTimeFormatter';
|
||||||
|
|
||||||
const ReservationSuccessPage: React.FC = () => {
|
const ReservationSuccessPage: React.FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { themeName, date, startAt } = (location.state as {
|
const { storeName, themeName, date, time, participantCount, totalPrice } = location.state || {};
|
||||||
themeName: string;
|
|
||||||
date: string;
|
|
||||||
startAt: string;
|
|
||||||
}) || {};
|
|
||||||
|
|
||||||
const formattedDate = formatDate(date)
|
const formattedDate = date ? formatDate(date) : '';
|
||||||
const formattedTime = formatTime(startAt);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reservation-v21-container">
|
<div className="reservation-v21-container">
|
||||||
@ -20,9 +15,12 @@ const ReservationSuccessPage: React.FC = () => {
|
|||||||
<h2 className="page-title">예약이 확정되었습니다!</h2>
|
<h2 className="page-title">예약이 확정되었습니다!</h2>
|
||||||
<div className="step-section">
|
<div className="step-section">
|
||||||
<h3>최종 예약 정보</h3>
|
<h3>최종 예약 정보</h3>
|
||||||
|
<p><strong>매장:</strong> {storeName}</p>
|
||||||
<p><strong>테마:</strong> {themeName}</p>
|
<p><strong>테마:</strong> {themeName}</p>
|
||||||
<p><strong>날짜:</strong> {formattedDate}</p>
|
<p><strong>날짜:</strong> {formattedDate}</p>
|
||||||
<p><strong>시간:</strong> {formattedTime}</p>
|
<p><strong>시간:</strong> {time}</p>
|
||||||
|
<p><strong>인원:</strong> {participantCount}명</p>
|
||||||
|
<p><strong>결제 금액:</strong> {totalPrice?.toLocaleString()}원</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="success-page-actions">
|
<div className="success-page-actions">
|
||||||
<Link to="/my-reservation" className="action-button">
|
<Link to="/my-reservation" className="action-button">
|
||||||
|
|||||||
@ -1,24 +1,21 @@
|
|||||||
import { isLoginRequiredError } from '@_api/apiClient';
|
import {isLoginRequiredError} from '@_api/apiClient';
|
||||||
|
import type {AuditInfo} from '@_api/common/commonTypes';
|
||||||
import {
|
import {
|
||||||
createSchedule,
|
createSchedule,
|
||||||
deleteSchedule,
|
deleteSchedule,
|
||||||
fetchScheduleDetailById,
|
fetchAdminSchedules,
|
||||||
fetchStoreSchedulesByDateAndTheme,
|
fetchScheduleAudit,
|
||||||
updateSchedule
|
updateSchedule
|
||||||
} from '@_api/schedule/scheduleAPI';
|
} from '@_api/schedule/scheduleAPI';
|
||||||
import {
|
import {type AdminScheduleSummaryResponse, ScheduleStatus,} from '@_api/schedule/scheduleTypes';
|
||||||
type ScheduleDetailRetrieveResponse,
|
import {getStores} from '@_api/store/storeAPI';
|
||||||
type ScheduleRetrieveResponse,
|
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
||||||
ScheduleStatus
|
import {fetchActiveThemes, fetchThemeById} from '@_api/theme/themeAPI';
|
||||||
} from '@_api/schedule/scheduleTypes';
|
import {DifficultyKoreanMap, type SimpleActiveThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||||
import { getStores } from '@_api/store/storeAPI';
|
import {useAdminAuth} from '@_context/AdminAuthContext';
|
||||||
import { type SimpleStoreResponse } from '@_api/store/storeTypes';
|
|
||||||
import { fetchActiveThemes, fetchThemeById } from '@_api/theme/themeAPI';
|
|
||||||
import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes';
|
|
||||||
import { useAdminAuth } from '@_context/AdminAuthContext';
|
|
||||||
import '@_css/admin-schedule-page.css';
|
import '@_css/admin-schedule-page.css';
|
||||||
import React, { Fragment, useEffect, useState } from 'react';
|
import React, {Fragment, useEffect, useState} from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import {useLocation, useNavigate} from 'react-router-dom';
|
||||||
|
|
||||||
const getScheduleStatusText = (status: ScheduleStatus): string => {
|
const getScheduleStatusText = (status: ScheduleStatus): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@ -35,27 +32,25 @@ const getScheduleStatusText = (status: ScheduleStatus): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
type ThemeForSchedule = {
|
type ScheduleDetail = AdminScheduleSummaryResponse & { audit?: AuditInfo };
|
||||||
id: string;
|
type EditingSchedule = ScheduleDetail & { time: string };
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AdminSchedulePage: React.FC = () => {
|
const AdminSchedulePage: React.FC = () => {
|
||||||
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
|
const [schedules, setSchedules] = useState<AdminScheduleSummaryResponse[]>([]);
|
||||||
const [themes, setThemes] = useState<ThemeForSchedule[]>([]);
|
const [themes, setThemes] = useState<SimpleActiveThemeResponse[]>([]);
|
||||||
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
|
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
|
||||||
const [selectedStoreId, setSelectedStoreId] = useState<string>('');
|
const [selectedStoreId, setSelectedStoreId] = useState<string>('');
|
||||||
const [selectedThemeId, setSelectedThemeId] = useState<string>('');
|
const [selectedTheme, setSelectedTheme] = useState<SimpleActiveThemeResponse | null>(null);
|
||||||
const [selectedDate, setSelectedDate] = useState<string>(new Date().toLocaleDateString('en-CA'));
|
const [selectedDate, setSelectedDate] = useState<string>(new Date().toLocaleDateString('en-CA'));
|
||||||
|
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [newScheduleTime, setNewScheduleTime] = useState('');
|
const [newScheduleTime, setNewScheduleTime] = useState('');
|
||||||
|
|
||||||
const [expandedScheduleId, setExpandedScheduleId] = useState<string | null>(null);
|
const [expandedScheduleId, setExpandedScheduleId] = useState<string | null>(null);
|
||||||
const [detailedSchedules, setDetailedSchedules] = useState<{ [key: string]: ScheduleDetailRetrieveResponse }>({});
|
const [detailedSchedules, setDetailedSchedules] = useState<{ [key: string]: ScheduleDetail }>({});
|
||||||
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
|
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editingSchedule, setEditingSchedule] = useState<ScheduleDetailRetrieveResponse | null>(null);
|
const [editingSchedule, setEditingSchedule] = useState<EditingSchedule | null>(null);
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedThemeDetails, setSelectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
|
const [selectedThemeDetails, setSelectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
|
||||||
@ -63,12 +58,15 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { type: adminType, storeId: adminStoreId } = useAdminAuth();
|
const {type: adminType, storeId: adminStoreId} = useAdminAuth();
|
||||||
|
|
||||||
|
const storeIdForFetch = adminType === 'HQ' ? selectedStoreId : adminStoreId;
|
||||||
|
const showThemeColumn = !selectedTheme?.id;
|
||||||
|
|
||||||
const handleError = (err: any) => {
|
const handleError = (err: any) => {
|
||||||
if (isLoginRequiredError(err)) {
|
if (isLoginRequiredError(err)) {
|
||||||
alert('로그인이 필요해요.');
|
alert('로그인이 필요해요.');
|
||||||
navigate('/admin/login', { state: { from: location } });
|
navigate('/admin/login', {state: {from: location}});
|
||||||
} else {
|
} else {
|
||||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||||
alert(message);
|
alert(message);
|
||||||
@ -83,15 +81,14 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
// Fetch themes
|
// Fetch themes
|
||||||
const themeRes = await fetchActiveThemes();
|
const themeRes = await fetchActiveThemes();
|
||||||
const themeData = themeRes.themes.map(t => ({ id: String(t.id), name: t.name }));
|
const themeData = themeRes.themes.map(t => ({id: String(t.id), name: t.name}));
|
||||||
setThemes(themeData);
|
const allThemesOption = {id: '', name: '전체'};
|
||||||
if (themeData.length > 0) {
|
setThemes([allThemesOption, ...themeData]);
|
||||||
setSelectedThemeId(themeData[0].id);
|
setSelectedTheme(allThemesOption);
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch stores for HQ admin
|
// Fetch stores for HQ admin
|
||||||
if (adminType === 'HQ') {
|
if (adminType === 'HQ') {
|
||||||
const storeRes = await getStores();
|
const storeRes = (await getStores()).stores;
|
||||||
setStores(storeRes);
|
setStores(storeRes);
|
||||||
if (storeRes.length > 0) {
|
if (storeRes.length > 0) {
|
||||||
setSelectedStoreId(String(storeRes[0].id));
|
setSelectedStoreId(String(storeRes[0].id));
|
||||||
@ -106,9 +103,8 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
}, [adminType]);
|
}, [adminType]);
|
||||||
|
|
||||||
const fetchSchedules = () => {
|
const fetchSchedules = () => {
|
||||||
const storeId = adminType === 'HQ' ? selectedStoreId : adminStoreId;
|
if (storeIdForFetch) {
|
||||||
if (storeId && selectedDate && selectedThemeId) {
|
fetchAdminSchedules(storeIdForFetch, selectedDate, selectedTheme?.id === '' ? undefined : selectedTheme?.id)
|
||||||
fetchStoreSchedulesByDateAndTheme(storeId, selectedDate, selectedThemeId)
|
|
||||||
.then(res => setSchedules(res.schedules))
|
.then(res => setSchedules(res.schedules))
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
setSchedules([]);
|
setSchedules([]);
|
||||||
@ -116,27 +112,14 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
handleError(err);
|
handleError(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
setSchedules([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSchedules();
|
fetchSchedules();
|
||||||
}, [selectedDate, selectedThemeId, selectedStoreId, adminType, adminStoreId]);
|
}, [selectedDate, selectedTheme, storeIdForFetch]);
|
||||||
|
|
||||||
const handleShowThemeDetails = async () => {
|
|
||||||
if (!selectedThemeId) return;
|
|
||||||
setIsModalOpen(true);
|
|
||||||
setIsLoadingThemeDetails(true);
|
|
||||||
try {
|
|
||||||
const details = await fetchThemeById(selectedThemeId);
|
|
||||||
setSelectedThemeDetails(details);
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error);
|
|
||||||
setIsModalOpen(false); // Close modal on error
|
|
||||||
} finally {
|
|
||||||
setIsLoadingThemeDetails(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddSchedule = async () => {
|
const handleAddSchedule = async () => {
|
||||||
if (!newScheduleTime) {
|
if (!newScheduleTime) {
|
||||||
@ -151,14 +134,14 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
alert('매장 관리자만 일정을 추가할 수 있습니다.');
|
alert('매장 관리자만 일정을 추가할 수 있습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedDate || !selectedThemeId) {
|
if (!selectedDate || !selectedTheme?.id) {
|
||||||
alert('날짜와 테마를 선택해주세요.');
|
alert('날짜와 특정 테마를 선택해주세요.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await createSchedule(adminStoreId, {
|
await createSchedule(adminStoreId, {
|
||||||
date: selectedDate,
|
date: selectedDate,
|
||||||
themeId: selectedThemeId,
|
themeId: selectedTheme.id,
|
||||||
time: newScheduleTime,
|
time: newScheduleTime,
|
||||||
});
|
});
|
||||||
fetchSchedules();
|
fetchSchedules();
|
||||||
@ -173,7 +156,7 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
if (window.confirm('정말 이 일정을 삭제하시겠습니까?')) {
|
if (window.confirm('정말 이 일정을 삭제하시겠습니까?')) {
|
||||||
try {
|
try {
|
||||||
await deleteSchedule(scheduleId);
|
await deleteSchedule(scheduleId);
|
||||||
setSchedules(schedules.filter(s => s.id !== scheduleId));
|
fetchSchedules();
|
||||||
setExpandedScheduleId(null); // Close the details view after deletion
|
setExpandedScheduleId(null); // Close the details view after deletion
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error);
|
handleError(error);
|
||||||
@ -183,16 +166,22 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
|
|
||||||
const handleToggleDetails = async (scheduleId: string) => {
|
const handleToggleDetails = async (scheduleId: string) => {
|
||||||
const isAlreadyExpanded = expandedScheduleId === scheduleId;
|
const isAlreadyExpanded = expandedScheduleId === scheduleId;
|
||||||
setIsEditing(false); // Reset editing state whenever toggling
|
setIsEditing(false);
|
||||||
if (isAlreadyExpanded) {
|
if (isAlreadyExpanded) {
|
||||||
setExpandedScheduleId(null);
|
setExpandedScheduleId(null);
|
||||||
} else {
|
} else {
|
||||||
setExpandedScheduleId(scheduleId);
|
setExpandedScheduleId(scheduleId);
|
||||||
if (!detailedSchedules[scheduleId]) {
|
const scheduleInList = schedules.find(s => s.id === scheduleId);
|
||||||
|
if (!scheduleInList) return;
|
||||||
|
|
||||||
|
if (!detailedSchedules[scheduleId]?.audit) {
|
||||||
setIsLoadingDetails(true);
|
setIsLoadingDetails(true);
|
||||||
try {
|
try {
|
||||||
const details = await fetchScheduleDetailById(scheduleId);
|
const auditInfo = await fetchScheduleAudit(scheduleId);
|
||||||
setDetailedSchedules(prev => ({ ...prev, [scheduleId]: details }));
|
setDetailedSchedules(prev => ({
|
||||||
|
...prev,
|
||||||
|
[scheduleId]: {...scheduleInList, audit: auditInfo}
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error);
|
handleError(error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -204,7 +193,15 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
|
|
||||||
const handleEditClick = () => {
|
const handleEditClick = () => {
|
||||||
if (expandedScheduleId && detailedSchedules[expandedScheduleId]) {
|
if (expandedScheduleId && detailedSchedules[expandedScheduleId]) {
|
||||||
setEditingSchedule({ ...detailedSchedules[expandedScheduleId] });
|
const scheduleToEdit = detailedSchedules[expandedScheduleId];
|
||||||
|
setEditingSchedule({
|
||||||
|
...scheduleToEdit,
|
||||||
|
time: new Date(scheduleToEdit.startFrom).toLocaleTimeString('en-CA', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
}),
|
||||||
|
});
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -215,9 +212,9 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
const { name, value } = e.target;
|
const {name, value} = e.target;
|
||||||
if (editingSchedule) {
|
if (editingSchedule) {
|
||||||
setEditingSchedule({ ...editingSchedule, [name]: value });
|
setEditingSchedule({...editingSchedule, [name]: value});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -229,19 +226,17 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
time: editingSchedule.time,
|
time: editingSchedule.time,
|
||||||
status: editingSchedule.status,
|
status: editingSchedule.status,
|
||||||
});
|
});
|
||||||
// Refresh data
|
fetchSchedules();
|
||||||
const details = await fetchScheduleDetailById(editingSchedule.id);
|
setExpandedScheduleId(null);
|
||||||
setDetailedSchedules(prev => ({ ...prev, [editingSchedule.id]: details }));
|
|
||||||
setSchedules(schedules.map(s => s.id === editingSchedule.id ? { ...s, time: details.time, status: details.status } : s));
|
|
||||||
|
|
||||||
alert('일정이 성공적으로 업데이트되었습니다.');
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
setEditingSchedule(null);
|
||||||
|
alert('일정이 성공적으로 업데이트되었습니다.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error);
|
handleError(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canModify = adminType === 'HQ';
|
const canModify = adminType === 'STORE';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-schedule-container">
|
<div className="admin-schedule-container">
|
||||||
@ -249,7 +244,7 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="schedule-controls">
|
<div className="schedule-controls">
|
||||||
{adminType === 'HQ' && (
|
{adminType === 'HQ' && (
|
||||||
<div className="form-group">
|
<div className="form-group store-selector-group">
|
||||||
<label className="form-label" htmlFor="store-filter">매장</label>
|
<label className="form-label" htmlFor="store-filter">매장</label>
|
||||||
<select
|
<select
|
||||||
id="store-filter"
|
id="store-filter"
|
||||||
@ -263,7 +258,7 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="form-group">
|
<div className="form-group date-selector-group">
|
||||||
<label className="form-label" htmlFor="date-filter">날짜</label>
|
<label className="form-label" htmlFor="date-filter">날짜</label>
|
||||||
<input
|
<input
|
||||||
id="date-filter"
|
id="date-filter"
|
||||||
@ -279,20 +274,16 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
<select
|
<select
|
||||||
id="theme-filter"
|
id="theme-filter"
|
||||||
className="form-select"
|
className="form-select"
|
||||||
value={selectedThemeId}
|
value={selectedTheme?.id || ''}
|
||||||
onChange={e => setSelectedThemeId(e.target.value)}
|
onChange={e => {
|
||||||
|
const theme = themes.find(t => t.id === e.target.value);
|
||||||
|
setSelectedTheme(theme || null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{themes.map(theme => (
|
{themes.map(theme => (
|
||||||
<option key={theme.id} value={theme.id}>{theme.name}</option>
|
<option key={theme.id} value={theme.id}>{theme.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
|
||||||
className={'btn btn-secondary theme-details-button'}
|
|
||||||
onClick={handleShowThemeDetails}
|
|
||||||
disabled={!selectedThemeId}
|
|
||||||
>
|
|
||||||
테마 상세
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -307,6 +298,7 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{showThemeColumn && <th>테마</th>}
|
||||||
<th>시간</th>
|
<th>시간</th>
|
||||||
<th>상태</th>
|
<th>상태</th>
|
||||||
<th>관리</th>
|
<th>관리</th>
|
||||||
@ -316,7 +308,8 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
{schedules.map(schedule => (
|
{schedules.map(schedule => (
|
||||||
<Fragment key={schedule.id}>
|
<Fragment key={schedule.id}>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{schedule.time}</td>
|
{showThemeColumn && <td>{schedule.themeName}</td>}
|
||||||
|
<td>{schedule.startFrom}</td>
|
||||||
<td>{getScheduleStatusText(schedule.status)}</td>
|
<td>{getScheduleStatusText(schedule.status)}</td>
|
||||||
<td className="action-buttons">
|
<td className="action-buttons">
|
||||||
<button
|
<button
|
||||||
@ -329,49 +322,74 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
{expandedScheduleId === schedule.id && (
|
{expandedScheduleId === schedule.id && (
|
||||||
<tr className="schedule-details-row">
|
<tr className="schedule-details-row">
|
||||||
<td colSpan={3}>
|
<td colSpan={showThemeColumn ? 4 : 3}>
|
||||||
{isLoadingDetails ? (
|
{isLoadingDetails ? (
|
||||||
<p>로딩 중...</p>
|
<p>로딩 중...</p>
|
||||||
) : detailedSchedules[schedule.id] ? (
|
) : detailedSchedules[schedule.id] ? (
|
||||||
<div className="details-form-container">
|
<div className="details-form-container">
|
||||||
|
{detailedSchedules[schedule.id].audit ? (
|
||||||
<div className="audit-info">
|
<div className="audit-info">
|
||||||
<h4 className="audit-title">감사 정보</h4>
|
<h4 className="audit-title">감사 정보</h4>
|
||||||
<div className="audit-body">
|
<div className="audit-body">
|
||||||
<p><strong>생성일:</strong> {new Date(detailedSchedules[schedule.id].createdAt).toLocaleString()}</p>
|
<p>
|
||||||
<p><strong>수정일:</strong> {new Date(detailedSchedules[schedule.id].updatedAt).toLocaleString()}</p>
|
<strong>생성일:</strong> {new Date(detailedSchedules[schedule.id].audit!.createdAt).toLocaleString()}
|
||||||
<p><strong>생성자:</strong> {detailedSchedules[schedule.id].createdBy.name}</p>
|
</p>
|
||||||
<p><strong>수정자:</strong> {detailedSchedules[schedule.id].updatedBy.name}</p>
|
<p>
|
||||||
|
<strong>수정일:</strong> {new Date(detailedSchedules[schedule.id].audit!.updatedAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>생성자:</strong> {detailedSchedules[schedule.id].audit!.createdBy.name}({detailedSchedules[schedule.id].audit!.createdBy.id})
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>수정자:</strong> {detailedSchedules[schedule.id].audit!.updatedBy.name}({detailedSchedules[schedule.id].audit!.updatedBy.id})
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : <p>감사 정보를 불러오는 중...</p>}
|
||||||
|
|
||||||
{isEditing && editingSchedule ? (
|
{isEditing && editingSchedule?.id === schedule.id ? (
|
||||||
// --- EDIT MODE ---
|
// --- EDIT MODE ---
|
||||||
<div className="form-card">
|
<div className="form-card">
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">시간</label>
|
<label className="form-label">시간</label>
|
||||||
<input type="time" name="time" className="form-input" value={editingSchedule.time} onChange={handleEditChange} />
|
<input type="time" name="time"
|
||||||
|
className="form-input"
|
||||||
|
value={editingSchedule.time}
|
||||||
|
onChange={handleEditChange}/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">상태</label>
|
<label className="form-label">상태</label>
|
||||||
<select name="status" className="form-select" value={editingSchedule.status} onChange={handleEditChange}>
|
<select name="status" className="form-select"
|
||||||
{Object.values(ScheduleStatus).map(s => <option key={s} value={s}>{getScheduleStatusText(s)}</option>)}
|
value={editingSchedule.status}
|
||||||
|
onChange={handleEditChange}>
|
||||||
|
{Object.values(ScheduleStatus).map(s =>
|
||||||
|
<option key={s}
|
||||||
|
value={s}>{getScheduleStatusText(s)}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-group">
|
<div className="button-group">
|
||||||
<button type="button" className="btn btn-secondary" onClick={handleCancelEdit}>취소</button>
|
<button type="button" className="btn btn-secondary"
|
||||||
<button type="button" className="btn btn-primary" onClick={handleSave}>저장</button>
|
onClick={handleCancelEdit}>취소
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary"
|
||||||
|
onClick={handleSave}>저장
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// --- VIEW MODE ---
|
// --- VIEW MODE ---
|
||||||
canModify && (
|
canModify && (
|
||||||
<div className="button-group view-mode-buttons">
|
<div className="button-group view-mode-buttons">
|
||||||
<button type="button" className="btn btn-danger" onClick={() => handleDeleteSchedule(schedule.id)}>삭제</button>
|
<button type="button" className="btn btn-danger"
|
||||||
<button type="button" className="btn btn-primary" onClick={handleEditClick}>수정</button>
|
onClick={() => handleDeleteSchedule(schedule.id)}>삭제
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary"
|
||||||
|
onClick={handleEditClick}>수정
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@ -386,6 +404,7 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
{isAdding && canModify && (
|
{isAdding && canModify && (
|
||||||
<tr className="editing-row">
|
<tr className="editing-row">
|
||||||
|
{showThemeColumn && <td></td>}
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
@ -415,15 +434,15 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
) : selectedThemeDetails ? (
|
) : selectedThemeDetails ? (
|
||||||
<div className="theme-details-modal">
|
<div className="theme-details-modal">
|
||||||
<h3 className="modal-title">{selectedThemeDetails.name}</h3>
|
<h3 className="modal-title">{selectedThemeDetails.name}</h3>
|
||||||
<img src={selectedThemeDetails.thumbnailUrl} alt={selectedThemeDetails.name} className="theme-modal-thumbnail" />
|
<img src={selectedThemeDetails.thumbnailUrl} alt={selectedThemeDetails.name}
|
||||||
|
className="theme-modal-thumbnail"/>
|
||||||
<p className="theme-modal-description">{selectedThemeDetails.description}</p>
|
<p className="theme-modal-description">{selectedThemeDetails.description}</p>
|
||||||
<div className="theme-modal-info-grid">
|
<div className="modal-info-grid">
|
||||||
<div className="info-item"><strong>난이도</strong><span>{DifficultyKoreanMap[selectedThemeDetails.difficulty]}</span></div>
|
<p><strong>난이도</strong><span>{DifficultyKoreanMap[selectedThemeDetails.difficulty]}</span></p>
|
||||||
<div className="info-item"><strong>가격</strong><span>{selectedThemeDetails.price.toLocaleString()}원</span></div>
|
<p><strong>이용 가능 인원</strong><span>{selectedThemeDetails.minParticipants} ~ {selectedThemeDetails.maxParticipants}명</span></p>
|
||||||
<div className="info-item"><strong>최소 인원</strong><span>{selectedThemeDetails.minParticipants}명</span></div>
|
<p><strong>1인당 요금</strong><span>{selectedThemeDetails.price.toLocaleString()}원</span></p>
|
||||||
<div className="info-item"><strong>최대 인원</strong><span>{selectedThemeDetails.maxParticipants}명</span></div>
|
<p><strong>예상 시간</strong><span>{selectedThemeDetails.expectedMinutesFrom} ~ {selectedThemeDetails.expectedMinutesTo}분</span></p>
|
||||||
<div className="info-item"><strong>소요 시간</strong><span>{selectedThemeDetails.availableMinutes}분</span></div>
|
<p><strong>이용 가능 시간</strong><span>{selectedThemeDetails.availableMinutes}분</span></p>
|
||||||
<div className="info-item"><strong>예상 시간</strong><span>{selectedThemeDetails.expectedMinutesFrom} ~ {selectedThemeDetails.expectedMinutesTo}분</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
import { isLoginRequiredError } from '@_api/apiClient';
|
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 { createStore, deleteStore, getStoreDetail, getStores, updateStore } from '@_api/store/storeAPI';
|
||||||
import { type StoreRegisterRequest, type StoreDetailResponse, type SimpleStoreResponse, type UpdateStoreRequest } from '@_api/store/storeTypes';
|
import {
|
||||||
|
type SimpleStoreResponse,
|
||||||
|
type StoreDetailResponse,
|
||||||
|
type StoreRegisterRequest,
|
||||||
|
type UpdateStoreRequest
|
||||||
|
} from '@_api/store/storeTypes';
|
||||||
import { useAdminAuth } from '@_context/AdminAuthContext';
|
import { useAdminAuth } from '@_context/AdminAuthContext';
|
||||||
import '@_css/admin-store-page.css';
|
import '@_css/admin-store-page.css';
|
||||||
import React, { Fragment, useEffect, useState } from 'react';
|
import React, { Fragment, useEffect, useState } from 'react';
|
||||||
@ -9,14 +16,25 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||||||
const AdminStorePage: React.FC = () => {
|
const AdminStorePage: React.FC = () => {
|
||||||
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
|
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [newStore, setNewStore] = useState<StoreRegisterRequest>({ name: '', address: '', contact: '', businessRegNum: '', regionCode: '' });
|
const [newStore, setNewStore] = useState<StoreRegisterRequest>({
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
contact: '',
|
||||||
|
businessRegNum: '',
|
||||||
|
regionCode: ''
|
||||||
|
});
|
||||||
|
|
||||||
const [expandedStoreId, setExpandedStoreId] = useState<number | null>(null);
|
const [expandedStoreId, setExpandedStoreId] = useState<string | null>(null);
|
||||||
const [detailedStores, setDetailedStores] = useState<{ [key: number]: StoreDetailResponse }>({});
|
const [detailedStores, setDetailedStores] = useState<{ [key: string]: StoreDetailResponse }>({});
|
||||||
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
|
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editingStore, setEditingStore] = useState<UpdateStoreRequest | null>(null);
|
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 navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { type: adminType } = useAdminAuth();
|
const { type: adminType } = useAdminAuth();
|
||||||
@ -34,12 +52,12 @@ const AdminStorePage: React.FC = () => {
|
|||||||
|
|
||||||
const fetchStores = async () => {
|
const fetchStores = async () => {
|
||||||
try {
|
try {
|
||||||
const storesData = await getStores();
|
const storesData = (await getStores(selectedSido || undefined, selectedSigungu || undefined)).stores;
|
||||||
setStores(storesData);
|
setStores(storesData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error);
|
handleError(error);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (adminType !== 'HQ') {
|
if (adminType !== 'HQ') {
|
||||||
@ -47,9 +65,38 @@ const AdminStorePage: React.FC = () => {
|
|||||||
navigate('/admin');
|
navigate('/admin');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetchStores();
|
|
||||||
|
const fetchInitialData = async () => {
|
||||||
|
try {
|
||||||
|
const sidoRes = await fetchSidoList();
|
||||||
|
setSidoList(sidoRes.sidoList);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchInitialData();
|
||||||
}, [adminType, navigate]);
|
}, [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 handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setNewStore(prev => ({ ...prev, [name]: value }));
|
setNewStore(prev => ({ ...prev, [name]: value }));
|
||||||
@ -62,7 +109,8 @@ const AdminStorePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await createStore(newStore);
|
await createStore(newStore);
|
||||||
fetchStores();
|
const storesData = (await getStores(selectedSido || undefined, selectedSigungu || undefined)).stores;
|
||||||
|
setStores(storesData);
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
setNewStore({ name: '', address: '', contact: '', businessRegNum: '', regionCode: '' });
|
setNewStore({ name: '', address: '', contact: '', businessRegNum: '', regionCode: '' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -70,7 +118,7 @@ const AdminStorePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleDetails = async (storeId: number) => {
|
const handleToggleDetails = async (storeId: string) => {
|
||||||
const isAlreadyExpanded = expandedStoreId === storeId;
|
const isAlreadyExpanded = expandedStoreId === storeId;
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
if (isAlreadyExpanded) {
|
if (isAlreadyExpanded) {
|
||||||
@ -91,7 +139,7 @@ const AdminStorePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteStore = async (storeId: number) => {
|
const handleDeleteStore = async (storeId: string) => {
|
||||||
if (window.confirm('정말 이 매장을 삭제하시겠습니까? 관련 데이터가 모두 삭제될 수 있습니다.')) {
|
if (window.confirm('정말 이 매장을 삭제하시겠습니까? 관련 데이터가 모두 삭제될 수 있습니다.')) {
|
||||||
try {
|
try {
|
||||||
await deleteStore(storeId);
|
await deleteStore(storeId);
|
||||||
@ -120,12 +168,13 @@ const AdminStorePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async (storeId: number) => {
|
const handleSave = async (storeId: string) => {
|
||||||
if (!editingStore) return;
|
if (!editingStore) return;
|
||||||
try {
|
try {
|
||||||
const updatedStore = await updateStore(storeId, editingStore);
|
await updateStore(storeId, editingStore);
|
||||||
|
const updatedStore = await getStoreDetail(storeId);
|
||||||
setDetailedStores(prev => ({ ...prev, [storeId]: updatedStore }));
|
setDetailedStores(prev => ({ ...prev, [storeId]: updatedStore }));
|
||||||
setStores(prev => prev.map(s => s.id === storeId ? { ...s, name: updatedStore.name } : s));
|
setStores(prev => prev.map(s => s.id === String(storeId) ? { ...s, name: updatedStore.name } : s));
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setEditingStore(null);
|
setEditingStore(null);
|
||||||
alert('매장 정보가 성공적으로 업데이트되었습니다.');
|
alert('매장 정보가 성공적으로 업데이트되었습니다.');
|
||||||
@ -138,6 +187,23 @@ const AdminStorePage: React.FC = () => {
|
|||||||
<div className="admin-store-container">
|
<div className="admin-store-container">
|
||||||
<h2 className="page-title">매장 관리</h2>
|
<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="section-card">
|
||||||
<div className="table-header">
|
<div className="table-header">
|
||||||
<button className="btn btn-primary" onClick={() => setIsAdding(!isAdding)}>
|
<button className="btn btn-primary" onClick={() => setIsAdding(!isAdding)}>
|
||||||
@ -148,13 +214,38 @@ const AdminStorePage: React.FC = () => {
|
|||||||
{isAdding && (
|
{isAdding && (
|
||||||
<div className="add-store-form">
|
<div className="add-store-form">
|
||||||
<div className="form-row">
|
<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"
|
||||||
<div className="form-group"><label className="form-label">주소</label><input type="text" name="address" className="form-input" value={newStore.address} onChange={handleInputChange} /></div>
|
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>
|
||||||
<div className="form-row">
|
<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"
|
||||||
<div className="form-group"><label className="form-label">사업자등록번호</label><input type="text" name="businessRegNum" className="form-input" value={newStore.businessRegNum} onChange={handleInputChange} /></div>
|
name="contact"
|
||||||
<div className="form-group"><label className="form-label">지역 코드</label><input type="text" name="regionCode" className="form-input" value={newStore.regionCode} onChange={handleInputChange} /></div>
|
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>
|
||||||
<div className="button-group">
|
<div className="button-group">
|
||||||
<button className="btn btn-primary" onClick={handleAddStore}>저장</button>
|
<button className="btn btn-primary" onClick={handleAddStore}>저장</button>
|
||||||
@ -178,7 +269,8 @@ const AdminStorePage: React.FC = () => {
|
|||||||
<td>{store.id}</td>
|
<td>{store.id}</td>
|
||||||
<td>{store.name}</td>
|
<td>{store.name}</td>
|
||||||
<td className="action-buttons">
|
<td className="action-buttons">
|
||||||
<button className="btn btn-secondary" onClick={() => handleToggleDetails(store.id)}>
|
<button className="btn btn-secondary"
|
||||||
|
onClick={() => handleToggleDetails(store.id)}>
|
||||||
{expandedStoreId === store.id ? '닫기' : '상세'}
|
{expandedStoreId === store.id ? '닫기' : '상세'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@ -192,33 +284,71 @@ const AdminStorePage: React.FC = () => {
|
|||||||
<div className="audit-info">
|
<div className="audit-info">
|
||||||
<h4 className="audit-title">상세 정보</h4>
|
<h4 className="audit-title">상세 정보</h4>
|
||||||
<div className="audit-body">
|
<div className="audit-body">
|
||||||
<p><strong>주소:</strong> {detailedStores[store.id].address}</p>
|
<p>
|
||||||
<p><strong>연락처:</strong> {detailedStores[store.id].contact}</p>
|
<strong>주소:</strong> {detailedStores[store.id].address}
|
||||||
<p><strong>사업자등록번호:</strong> {detailedStores[store.id].businessRegNum}</p>
|
</p>
|
||||||
<p><strong>지역 코드:</strong> {detailedStores[store.id].regionCode}</p>
|
<p>
|
||||||
<p><strong>생성일:</strong> {new Date(detailedStores[store.id].createdAt).toLocaleString()}</p>
|
<strong>연락처:</strong> {detailedStores[store.id].contact}
|
||||||
<p><strong>수정일:</strong> {new Date(detailedStores[store.id].updatedAt).toLocaleString()}</p>
|
</p>
|
||||||
<p><strong>생성자:</strong> {detailedStores[store.id].createdBy.name}</p>
|
<p>
|
||||||
<p><strong>수정자:</strong> {detailedStores[store.id].updatedBy.name}</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEditing && editingStore ? (
|
{isEditing && editingStore ? (
|
||||||
<div className="details-form-card">
|
<div className="details-form-card">
|
||||||
<div className="form-row">
|
<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
|
||||||
<div className="form-group"><label className="form-label">주소</label><input type="text" name="address" className="form-input" value={editingStore.address} onChange={handleEditChange} /></div>
|
className="form-label">매장명</label><input
|
||||||
<div className="form-group"><label className="form-label">연락처</label><input type="text" name="contact" className="form-input" value={editingStore.contact} onChange={handleEditChange} /></div>
|
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>
|
||||||
<div className="button-group">
|
<div className="button-group">
|
||||||
<button className="btn btn-secondary" onClick={handleCancelEdit}>취소</button>
|
<button className="btn btn-secondary"
|
||||||
<button className="btn btn-primary" onClick={() => handleSave(store.id)}>저장</button>
|
onClick={handleCancelEdit}>취소
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary"
|
||||||
|
onClick={() => handleSave(store.id)}>저장
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="button-group">
|
<div className="button-group">
|
||||||
<button className="btn btn-danger" onClick={() => handleDeleteStore(store.id)}>삭제</button>
|
<button className="btn btn-danger"
|
||||||
<button className="btn btn-primary" onClick={() => handleEditClick(detailedStores[store.id])}>수정</button>
|
onClick={() => handleDeleteStore(store.id)}>삭제
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary"
|
||||||
|
onClick={() => handleEditClick(detailedStores[store.id])}>수정
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import {isLoginRequiredError} from '@_api/apiClient';
|
import {isLoginRequiredError} from '@_api/apiClient';
|
||||||
import {createTheme, deleteTheme, fetchAdminThemeDetail, updateTheme} from '@_api/theme/themeAPI';
|
import {createTheme, deleteTheme, fetchAdminThemeDetail, updateTheme} from '@_api/theme/themeAPI';
|
||||||
import {
|
import {
|
||||||
type AdminThemeDetailResponse,
|
|
||||||
Difficulty,
|
Difficulty,
|
||||||
DifficultyKoreanMap,
|
DifficultyKoreanMap,
|
||||||
type ThemeCreateRequest,
|
type ThemeCreateRequest,
|
||||||
@ -10,6 +9,21 @@ import {
|
|||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import {useLocation, useNavigate, useParams} from 'react-router-dom';
|
import {useLocation, useNavigate, useParams} from 'react-router-dom';
|
||||||
import '@_css/admin-theme-edit-page.css';
|
import '@_css/admin-theme-edit-page.css';
|
||||||
|
import type { AuditInfo } from '@_api/common/commonTypes';
|
||||||
|
|
||||||
|
interface ThemeFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
price: number;
|
||||||
|
minParticipants: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
availableMinutes: number;
|
||||||
|
expectedMinutesFrom: number;
|
||||||
|
expectedMinutesTo: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const AdminThemeEditPage: React.FC = () => {
|
const AdminThemeEditPage: React.FC = () => {
|
||||||
const { themeId } = useParams<{ themeId: string }>();
|
const { themeId } = useParams<{ themeId: string }>();
|
||||||
@ -18,8 +32,9 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
|
|
||||||
const isNew = themeId === 'new';
|
const isNew = themeId === 'new';
|
||||||
|
|
||||||
const [theme, setTheme] = useState<AdminThemeDetailResponse | ThemeCreateRequest | null>(null);
|
const [formData, setFormData] = useState<ThemeFormData | null>(null);
|
||||||
const [originalTheme, setOriginalTheme] = useState<AdminThemeDetailResponse | ThemeCreateRequest | null>(null);
|
const [originalFormData, setOriginalFormData] = useState<ThemeFormData | null>(null);
|
||||||
|
const [auditInfo, setAuditInfo] = useState<AuditInfo | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isEditing, setIsEditing] = useState(isNew);
|
const [isEditing, setIsEditing] = useState(isNew);
|
||||||
|
|
||||||
@ -36,7 +51,7 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
const newTheme: ThemeCreateRequest = {
|
const newTheme: ThemeFormData = {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
thumbnailUrl: '',
|
thumbnailUrl: '',
|
||||||
@ -44,38 +59,34 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
price: 0,
|
price: 0,
|
||||||
minParticipants: 2,
|
minParticipants: 2,
|
||||||
maxParticipants: 4,
|
maxParticipants: 4,
|
||||||
availableMinutes: 60,
|
availableMinutes: 80,
|
||||||
expectedMinutesFrom: 50,
|
expectedMinutesFrom: 50,
|
||||||
expectedMinutesTo: 70,
|
expectedMinutesTo: 60,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
};
|
};
|
||||||
setTheme(newTheme);
|
setFormData(newTheme);
|
||||||
setOriginalTheme(newTheme);
|
setOriginalFormData(newTheme);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
} else if (themeId) {
|
} else if (themeId) {
|
||||||
fetchAdminThemeDetail(themeId)
|
fetchAdminThemeDetail(themeId)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Map AdminThemeDetailRetrieveResponse to ThemeV2
|
const { theme, isActive, audit } = data;
|
||||||
const fetchedTheme: AdminThemeDetailResponse = {
|
const themeData: ThemeFormData = {
|
||||||
id: data.id,
|
name: theme.name,
|
||||||
name: data.name,
|
description: theme.description,
|
||||||
description: data.description,
|
thumbnailUrl: theme.thumbnailUrl,
|
||||||
thumbnailUrl: data.thumbnailUrl,
|
difficulty: theme.difficulty,
|
||||||
difficulty: data.difficulty,
|
price: theme.price,
|
||||||
price: data.price,
|
minParticipants: theme.minParticipants,
|
||||||
minParticipants: data.minParticipants,
|
maxParticipants: theme.maxParticipants,
|
||||||
maxParticipants: data.maxParticipants,
|
availableMinutes: theme.availableMinutes,
|
||||||
availableMinutes: data.availableMinutes,
|
expectedMinutesFrom: theme.expectedMinutesFrom,
|
||||||
expectedMinutesFrom: data.expectedMinutesFrom,
|
expectedMinutesTo: theme.expectedMinutesTo,
|
||||||
expectedMinutesTo: data.expectedMinutesTo,
|
isActive: isActive,
|
||||||
isActive: data.isActive,
|
|
||||||
createDate: data.createdAt, // Map createdAt to createDate
|
|
||||||
updatedDate: data.updatedAt, // Map updatedAt to updatedDate
|
|
||||||
createdBy: data.createdBy,
|
|
||||||
updatedBy: data.updatedBy,
|
|
||||||
};
|
};
|
||||||
setTheme(fetchedTheme);
|
setFormData(themeData);
|
||||||
setOriginalTheme(fetchedTheme);
|
setOriginalFormData(themeData);
|
||||||
|
setAuditInfo(audit);
|
||||||
})
|
})
|
||||||
.catch(handleError)
|
.catch(handleError)
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
@ -91,15 +102,15 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
} else if (type === 'checkbox') {
|
} else if (type === 'checkbox') {
|
||||||
processedValue = (e.target as HTMLInputElement).checked;
|
processedValue = (e.target as HTMLInputElement).checked;
|
||||||
} else if (type === 'number') {
|
} else if (type === 'number') {
|
||||||
processedValue = value === '' ? '' : Number(value);
|
processedValue = value === '' ? 0 : Number(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(prev => prev ? { ...prev, [name]: processedValue } : null);
|
setFormData(prev => prev ? { ...prev, [name]: processedValue } : null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
const handleCancelEdit = () => {
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
setTheme(originalTheme);
|
setFormData(originalFormData);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} else {
|
} else {
|
||||||
navigate('/admin/theme');
|
navigate('/admin/theme');
|
||||||
@ -107,22 +118,21 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
console.log('handleSubmit called');
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!theme) return;
|
if (!formData) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
await createTheme(theme as ThemeCreateRequest);
|
await createTheme(formData as ThemeCreateRequest);
|
||||||
alert('테마가 성공적으로 생성되었습니다.');
|
alert('테마가 성공적으로 생성되었습니다.');
|
||||||
navigate(`/admin/theme`);
|
navigate(`/admin/theme`);
|
||||||
} else {
|
} else {
|
||||||
if (!themeId) {
|
if (!themeId) {
|
||||||
throw new Error('themeId is undefined');
|
throw new Error('themeId is undefined');
|
||||||
}
|
}
|
||||||
await updateTheme(themeId, theme as ThemeUpdateRequest);
|
await updateTheme(themeId, formData as ThemeUpdateRequest);
|
||||||
alert('테마가 성공적으로 업데이트되었습니다.');
|
alert('테마가 성공적으로 업데이트되었습니다.');
|
||||||
setOriginalTheme(theme);
|
setOriginalFormData(formData);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
navigate(`/admin/theme`);
|
navigate(`/admin/theme`);
|
||||||
}
|
}
|
||||||
@ -148,7 +158,7 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
return <div className="admin-theme-edit-container"><p>로딩 중...</p></div>;
|
return <div className="admin-theme-edit-container"><p>로딩 중...</p></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!theme) {
|
if (!formData) {
|
||||||
return <div className="admin-theme-edit-container"><p>테마 정보를 찾을 수 없습니다.</p></div>;
|
return <div className="admin-theme-edit-container"><p>테마 정보를 찾을 수 없습니다.</p></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,15 +172,15 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<div className="form-group full-width">
|
<div className="form-group full-width">
|
||||||
<label className="form-label" htmlFor="name">테마 이름</label>
|
<label className="form-label" htmlFor="name">테마 이름</label>
|
||||||
<input id="name" name="name" type="text" className="form-input" value={theme.name} onChange={handleChange} required disabled={!isEditing} />
|
<input id="name" name="name" type="text" className="form-input" value={formData.name} onChange={handleChange} required disabled={!isEditing} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group full-width">
|
<div className="form-group full-width">
|
||||||
<label className="form-label" htmlFor="description">설명</label>
|
<label className="form-label" htmlFor="description">설명</label>
|
||||||
<textarea id="description" name="description" className="form-textarea" value={theme.description} onChange={handleChange} required disabled={!isEditing} />
|
<textarea id="description" name="description" className="form-textarea" value={formData.description} onChange={handleChange} required disabled={!isEditing} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group full-width">
|
<div className="form-group full-width">
|
||||||
<label className="form-label" htmlFor="thumbnailUrl">썸네일 URL</label>
|
<label className="form-label" htmlFor="thumbnailUrl">썸네일 URL</label>
|
||||||
<input id="thumbnailUrl" name="thumbnailUrl" type="text" className="form-input" value={theme.thumbnailUrl} onChange={handleChange} required disabled={!isEditing} />
|
<input id="thumbnailUrl" name="thumbnailUrl" type="text" className="form-input" value={formData.thumbnailUrl} onChange={handleChange} required disabled={!isEditing} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -178,13 +188,13 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="difficulty">난이도</label>
|
<label className="form-label" htmlFor="difficulty">난이도</label>
|
||||||
<select id="difficulty" name="difficulty" className="form-select" value={theme.difficulty} onChange={handleChange} disabled={!isEditing}>
|
<select id="difficulty" name="difficulty" className="form-select" value={formData.difficulty} onChange={handleChange} disabled={!isEditing}>
|
||||||
{Object.values(Difficulty).map(d => <option key={d} value={d}>{DifficultyKoreanMap[d]}</option>)}
|
{Object.values(Difficulty).map(d => <option key={d} value={d}>{DifficultyKoreanMap[d]}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="isActive">공개 여부</label>
|
<label className="form-label" htmlFor="isActive">공개 여부</label>
|
||||||
<select id="isActive" name="isActive" className="form-select" value={String(theme.isActive)} onChange={handleChange} disabled={!isEditing}>
|
<select id="isActive" name="isActive" className="form-select" value={String(formData.isActive)} onChange={handleChange} disabled={!isEditing}>
|
||||||
<option value="true">공개</option>
|
<option value="true">공개</option>
|
||||||
<option value="false">비공개</option>
|
<option value="false">비공개</option>
|
||||||
</select>
|
</select>
|
||||||
@ -195,11 +205,11 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="price">1인당 요금 (원)</label>
|
<label className="form-label" htmlFor="price">1인당 요금 (원)</label>
|
||||||
<input id="price" name="price" type="number" className="form-input" value={theme.price} onChange={handleChange} required disabled={!isEditing} />
|
<input id="price" name="price" type="number" className="form-input" value={formData.price} onChange={handleChange} required disabled={!isEditing} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="availableMinutes">총 이용시간 (분)</label>
|
<label className="form-label" htmlFor="availableMinutes">총 이용시간 (분)</label>
|
||||||
<input id="availableMinutes" name="availableMinutes" type="number" className="form-input" value={theme.availableMinutes} onChange={handleChange} required disabled={!isEditing} />
|
<input id="availableMinutes" name="availableMinutes" type="number" className="form-input" value={formData.availableMinutes} onChange={handleChange} required disabled={!isEditing} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -207,22 +217,22 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="expectedMinutesFrom">최소 예상 시간 (분)</label>
|
<label className="form-label" htmlFor="expectedMinutesFrom">최소 예상 시간 (분)</label>
|
||||||
<input id="expectedMinutesFrom" name="expectedMinutesFrom" type="number" className="form-input" value={theme.expectedMinutesFrom} onChange={handleChange} required disabled={!isEditing} />
|
<input id="expectedMinutesFrom" name="expectedMinutesFrom" type="number" className="form-input" value={formData.expectedMinutesFrom} onChange={handleChange} required disabled={!isEditing} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="expectedMinutesTo">최대 예상 시간 (분)</label>
|
<label className="form-label" htmlFor="expectedMinutesTo">최대 예상 시간 (분)</label>
|
||||||
<input id="expectedMinutesTo" name="expectedMinutesTo" type="number" className="form-input" value={theme.expectedMinutesTo} onChange={handleChange} required disabled={!isEditing} />
|
<input id="expectedMinutesTo" name="expectedMinutesTo" type="number" className="form-input" value={formData.expectedMinutesTo} onChange={handleChange} required disabled={!isEditing} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="minParticipants">최소 인원 (명)</label>
|
<label className="form-label" htmlFor="minParticipants">최소 인원 (명)</label>
|
||||||
<input id="minParticipants" name="minParticipants" type="number" className="form-input" value={theme.minParticipants} onChange={handleChange} required disabled={!isEditing} />
|
<input id="minParticipants" name="minParticipants" type="number" className="form-input" value={formData.minParticipants} onChange={handleChange} required disabled={!isEditing} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="maxParticipants">최대 인원 (명)</label>
|
<label className="form-label" htmlFor="maxParticipants">최대 인원 (명)</label>
|
||||||
<input id="maxParticipants" name="maxParticipants" type="number" className="form-input" value={theme.maxParticipants} onChange={handleChange} required disabled={!isEditing} />
|
<input id="maxParticipants" name="maxParticipants" type="number" className="form-input" value={formData.maxParticipants} onChange={handleChange} required disabled={!isEditing} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -236,20 +246,20 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="main-actions">
|
<div className="main-actions">
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/admin/theme')}>목록</button>
|
<button type="button" className="btn btn-secondary" onClick={() => navigate('/admin/theme')}>목록</button>
|
||||||
<button type="button" className="btn btn-primary" onClick={(e) => { e.preventDefault(); console.log('setIsEditing(true) called'); setIsEditing(true); }}>수정</button>
|
<button type="button" className="btn btn-primary" onClick={(e) => { e.preventDefault(); setIsEditing(true); }}>수정</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{!isNew && 'id' in theme && (
|
{!isNew && auditInfo && (
|
||||||
<div className="audit-info">
|
<div className="audit-info">
|
||||||
<h4 className="audit-title">감사 정보</h4>
|
<h4 className="audit-title">감사 정보</h4>
|
||||||
<div className="audit-body">
|
<div className="audit-body">
|
||||||
<p><strong>생성일:</strong> {new Date(theme.createDate).toLocaleString()}</p>
|
<p><strong>생성일:</strong> {new Date(auditInfo.createdAt).toLocaleString()}</p>
|
||||||
<p><strong>수정일:</strong> {new Date(theme.updatedDate).toLocaleString()}</p>
|
<p><strong>수정일:</strong> {new Date(auditInfo.updatedAt).toLocaleString()}</p>
|
||||||
<p><strong>생성자:</strong> {theme.createdBy.name}</p>
|
<p><strong>생성자:</strong> {auditInfo.createdBy.name}</p>
|
||||||
<p><strong>수정자:</strong> {theme.updatedBy.name}</p>
|
<p><strong>수정자:</strong> {auditInfo.updatedBy.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import {useLocation, useNavigate} from 'react-router-dom';
|
import {useLocation, useNavigate} from 'react-router-dom';
|
||||||
import {fetchAdminThemes} from '@_api/theme/themeAPI';
|
import {fetchAdminThemes} from '@_api/theme/themeAPI';
|
||||||
import type {AdminThemeSummaryRetrieveResponse} from '@_api/theme/themeTypes';
|
import {DifficultyKoreanMap, type AdminThemeSummaryResponse} from '@_api/theme/themeTypes';
|
||||||
import {isLoginRequiredError} from '@_api/apiClient';
|
import {isLoginRequiredError} from '@_api/apiClient';
|
||||||
import '@_css/admin-theme-page.css';
|
import '@_css/admin-theme-page.css';
|
||||||
|
|
||||||
const AdminThemePage: React.FC = () => {
|
const AdminThemePage: React.FC = () => {
|
||||||
const [themes, setThemes] = useState<AdminThemeSummaryRetrieveResponse[]>([]);
|
const [themes, setThemes] = useState<AdminThemeSummaryResponse[]>([]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@ -63,7 +63,7 @@ const AdminThemePage: React.FC = () => {
|
|||||||
{themes.map(theme => (
|
{themes.map(theme => (
|
||||||
<tr key={theme.id}>
|
<tr key={theme.id}>
|
||||||
<td>{theme.name}</td>
|
<td>{theme.name}</td>
|
||||||
<td>{theme.difficulty}</td>
|
<td>{DifficultyKoreanMap[theme.difficulty]}</td>
|
||||||
<td>{theme.price.toLocaleString()}원</td>
|
<td>{theme.price.toLocaleString()}원</td>
|
||||||
<td>{theme.isActive ? '공개' : '비공개'}</td>
|
<td>{theme.isActive ? '공개' : '비공개'}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@ -34,6 +34,7 @@
|
|||||||
"@_hooks/*": ["src/hooks/*"],
|
"@_hooks/*": ["src/hooks/*"],
|
||||||
"@_pages/*": ["src/pages/*"],
|
"@_pages/*": ["src/pages/*"],
|
||||||
"@_types/*": ["/src/types/*"],
|
"@_types/*": ["/src/types/*"],
|
||||||
|
"@_util/*": ["src/util/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user