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

Merged
pricelees merged 116 commits from feat/#44 into main 2025-09-20 03:15:06 +00:00
26 changed files with 1279 additions and 1027 deletions
Showing only changes of commit dc37ae6d1a - Show all commits

View File

@ -1,5 +1,6 @@
import axios, {type AxiosError, type AxiosRequestConfig, type Method} from 'axios'; import axios, {type AxiosError, type AxiosRequestConfig, type Method} from 'axios';
import JSONbig from 'json-bigint'; import JSONbig from 'json-bigint';
import { PrincipalType } from './auth/authTypes';
// Create a JSONbig instance that stores big integers as strings // Create a JSONbig instance that stores big integers as strings
const JSONbigString = JSONbig({ storeAsString: true }); const JSONbigString = JSONbig({ storeAsString: true });
@ -38,7 +39,7 @@ async function request<T>(
method: Method, method: Method,
endpoint: string, endpoint: string,
data: object = {}, data: object = {},
isRequiredAuth: boolean = false type: PrincipalType,
): Promise<T> { ): Promise<T> {
const config: AxiosRequestConfig = { const config: AxiosRequestConfig = {
method, method,
@ -48,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,
}; };

View File

@ -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', {});
}

View File

@ -1,6 +1,24 @@
import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes"; import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes";
import type {UserContactRetrieveResponse} from "@_api/user/userTypes"; import type {UserContactRetrieveResponse} from "@_api/user/userTypes";
export interface ReservationData {
scheduleId: string;
store: {
id: string;
name: string;
}
theme: {
id: string;
name: string;
price: number;
minParticipants: number;
maxParticipants: number;
}
date: string; // "yyyy-MM-dd"
startFrom: string; // "HH:mm ~ HH:mm"
endAt: string;
}
export const ReservationStatus = { export const ReservationStatus = {
PENDING: 'PENDING', PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED', CONFIRMED: 'CONFIRMED',

View File

@ -1,37 +1,49 @@
import apiClient from '../apiClient'; import apiClient from "@_api/apiClient";
import type { import type { AdminScheduleSummaryListResponse, ScheduleCreateRequest, ScheduleCreateResponse, ScheduleStatus, ScheduleUpdateRequest, ScheduleWithThemeListResponse } from "./scheduleTypes";
AvailableThemeIdListResponse, import type { AuditInfo } from "@_api/common/commonTypes";
ScheduleCreateRequest,
ScheduleCreateResponse,
ScheduleDetailRetrieveResponse,
ScheduleRetrieveListResponse,
ScheduleUpdateRequest
} from './scheduleTypes';
export const 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}`);
}; }
if (themeId && themeId.trim() !== '') {
queryParams.push(`themeId=${themeId}`);
}
// 기본 URL에 쿼리 파라미터 추가
const baseUrl = `/admin/stores/${storeId}/schedules`;
const fullUrl = queryParams.length > 0
? `${baseUrl}?${queryParams.join('&')}`
: baseUrl;
export const fetchScheduleDetailById = async (id: string): Promise<ScheduleDetailRetrieveResponse> => { return await apiClient.adminGet<AdminScheduleSummaryListResponse>(fullUrl);
return await apiClient.get<ScheduleDetailRetrieveResponse>(`/admin/schedules/${id}`); }
};
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}`);
}; };

View File

@ -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[];
}

View File

@ -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`, {});
}; };

View File

@ -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;
} }

View File

@ -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');
};

View File

@ -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 {
@ -135,4 +102,4 @@ export interface SimpleActiveThemeResponse {
export interface SimpleActiveThemeListResponse { export interface SimpleActiveThemeListResponse {
themes: SimpleActiveThemeResponse[]; themes: SimpleActiveThemeResponse[];
} }

View File

@ -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');

View File

@ -1,11 +1,13 @@
/* New CSS content */
.admin-schedule-container { .admin-schedule-container {
padding: 2rem; padding: 2rem;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
font-size: 0.95rem; /* Slightly smaller base font */
} }
.page-title { .page-title {
font-size: 2rem; font-size: 1.8rem; /* smaller */
font-weight: bold; font-weight: bold;
margin-bottom: 2rem; margin-bottom: 2rem;
text-align: center; text-align: center;
@ -18,7 +20,7 @@
padding: 1.5rem; padding: 1.5rem;
background-color: #f9f9f9; background-color: #f9f9f9;
border-radius: 8px; border-radius: 8px;
align-items: center; align-items: flex-end; /* Align to bottom */
} }
.schedule-controls .form-group { .schedule-controls .form-group {
@ -26,18 +28,29 @@
flex-direction: column; flex-direction: column;
} }
/* Width adjustments */
.schedule-controls .store-selector-group,
.schedule-controls .date-selector-group {
flex: 1 1 180px;
}
.schedule-controls .theme-selector-group {
flex: 2 1 300px;
}
.schedule-controls .form-label { .schedule-controls .form-label {
font-size: 0.9rem; font-size: 0.85rem; /* smaller */
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #555; color: #555;
} }
.schedule-controls .form-input, .schedule-controls .form-input,
.schedule-controls .form-select { .schedule-controls .form-select {
padding: 0.75rem; padding: 0.6rem; /* smaller */
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
font-size: 1rem; font-size: 0.9rem; /* smaller */
} }
.section-card { .section-card {
@ -63,10 +76,11 @@ table {
} }
th, td { th, td {
padding: 1rem; padding: 0.8rem; /* smaller */
text-align: left; text-align: left;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
vertical-align: middle; vertical-align: middle;
font-size: 0.9rem; /* smaller */
} }
th { th {
@ -75,11 +89,11 @@ th {
} }
.btn { .btn {
padding: 0.5rem 1rem; padding: 0.4rem 0.8rem; /* smaller */
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.85rem; /* smaller */
transition: background-color 0.2s; transition: background-color 0.2s;
white-space: nowrap; white-space: nowrap;
} }
@ -174,8 +188,8 @@ th {
font-size: 1rem; font-size: 1rem;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 4px; border-radius: 4px;
height: 3rem; height: auto; /* remove fixed height */
box-sizing: border-box; /* Ensures padding/border are included in height */ box-sizing: border-box;
} }
.details-form-container .button-group { .details-form-container .button-group {
@ -190,7 +204,7 @@ th {
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 8px; border-radius: 8px;
background-color: #fff; background-color: #fff;
margin-bottom: 1.5rem; /* Add margin to separate from buttons */ margin-bottom: 1.5rem;
} }
.audit-title { .audit-title {
@ -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;
}

View File

@ -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;

View File

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

View File

@ -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;
}

View File

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

View File

@ -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">

View File

@ -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' && (

View File

@ -1,19 +1,20 @@
import {isLoginRequiredError} from '@_api/apiClient'; import { isLoginRequiredError } from '@_api/apiClient';
import {createPendingReservation} from '@_api/reservation/reservationAPI'; import { createPendingReservation } from '@_api/reservation/reservationAPI';
import {fetchContact} from '@_api/user/userAPI'; import type { ReservationData } from '@_api/reservation/reservationTypes';
import { fetchContact } from '@_api/user/userAPI';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import React, {useEffect, useState} from 'react'; import React, { useEffect, useState } from 'react';
import {useLocation, useNavigate} from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import {formatDate, formatTime} from 'src/util/DateTimeFormatter'; import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
const ReservationFormPage: React.FC = () => { const ReservationFormPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { scheduleId, theme, date, time } = location.state || {}; const reservationData = location.state as ReservationData;
const [reserverName, setReserverName] = useState(''); const [reserverName, setReserverName] = useState('');
const [reserverContact, setReserverContact] = useState(''); const [reserverContact, setReserverContact] = useState('');
const [participantCount, setParticipantCount] = useState(theme.minParticipants || 1); const [participantCount, setParticipantCount] = useState(reservationData.theme.minParticipants || 2);
const [requirement, setRequirement] = useState(''); const [requirement, setRequirement] = useState('');
const [isLoadingUserInfo, setIsLoadingUserInfo] = useState(true); const [isLoadingUserInfo, setIsLoadingUserInfo] = useState(true);
@ -50,30 +51,29 @@ const ReservationFormPage: React.FC = () => {
return; return;
} }
const reservationData = { createPendingReservation({
scheduleId, scheduleId: reservationData.scheduleId,
reserverName, reserverName,
reserverContact, reserverContact,
participantCount, participantCount,
requirement, requirement,
}; }).then(res => {
navigate('/reservation/payment', {
createPendingReservation(reservationData) state: {
.then(res => { reservationId: res.id,
navigate('/reservation/payment', { storeName: reservationData.store.name,
state: { themeName: reservationData.theme.name,
reservationId: res.id, date: reservationData.date,
themeName: theme.name, time: formatTime(reservationData.startFrom) + ' ~ ' + formatTime(reservationData.endAt),
date: date, themePrice: reservationData.theme.price,
startAt: time, totalPrice: reservationData.theme.price * participantCount,
price: 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>
@ -86,22 +86,23 @@ const ReservationFormPage: React.FC = () => {
return ( return (
<div className="reservation-v21-container"> <div className="reservation-v21-container">
<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> {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">
<h3> </h3> <h3> </h3>
<div className="form-group"> <div className="form-group">
<label htmlFor="reserverName"></label> <label htmlFor="reserverName"></label>
<input <input
type="text" type="text"
id="reserverName" id="reserverName"
value={reserverName} value={reserverName}
onChange={e => setReserverName(e.target.value)} onChange={e => setReserverName(e.target.value)}
disabled={isLoadingUserInfo} disabled={isLoadingUserInfo}
placeholder={isLoadingUserInfo ? "로딩 중..." : "예약자명을 입력하세요"} placeholder={isLoadingUserInfo ? "로딩 중..." : "예약자명을 입력하세요"}
@ -109,11 +110,11 @@ const ReservationFormPage: React.FC = () => {
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="reserverContact"></label> <label htmlFor="reserverContact"></label>
<input <input
type="tel" type="tel"
id="reserverContact" id="reserverContact"
value={reserverContact} value={reserverContact}
onChange={e => setReserverContact(e.target.value)} onChange={e => setReserverContact(e.target.value)}
disabled={isLoadingUserInfo} disabled={isLoadingUserInfo}
placeholder={isLoadingUserInfo ? "로딩 중..." : "'-' 없이 입력"} placeholder={isLoadingUserInfo ? "로딩 중..." : "'-' 없이 입력"}
/> />
@ -121,12 +122,12 @@ const ReservationFormPage: React.FC = () => {
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<div className="participant-control"> <div className="participant-control">
<input <input
type="number" type="number"
value={participantCount} value={participantCount}
onChange={e => setParticipantCount(Math.max(theme.minParticipants, Math.min(theme.maxParticipants, Number(e.target.value))))} onChange={e => setParticipantCount(Math.max(reservationData.theme.minParticipants, Math.min(reservationData.theme.maxParticipants, Number(e.target.value))))}
min={theme.minParticipants} min={reservationData.theme.minParticipants}
max={theme.maxParticipants} max={reservationData.theme.maxParticipants}
/> />
</div> </div>
</div> </div>

View File

@ -1,30 +1,44 @@
import {isLoginRequiredError} from '@_api/apiClient'; import {isLoginRequiredError} from '@_api/apiClient';
import {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) } else {
.then(res => { setSigunguList([]);
setSchedules(res.schedules);
setSelectedSchedule(null);
})
.catch((err) => {
if (isLoginRequiredError(err)) {
setSchedules([]);
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
setSelectedSchedule(null);
});
} }
}, [selectedDate, selectedTheme]); setSelectedSigungu('');
}, [selectedSido]);
const handleNextStep = () => { useEffect(() => {
if (!selectedDate || !selectedTheme || !selectedSchedule) { getStores(selectedSido, selectedSigungu)
alert('날짜, 테마, 시간을 모두 선택해주세요.'); .then(res => setStoreList(res.stores))
.catch(handleError);
setSelectedStore(null);
}, [selectedSido, selectedSigungu]);
useEffect(() => {
if (selectedDate && selectedStore) {
const dateStr = selectedDate.toLocaleDateString('en-CA');
fetchSchedules(selectedStore.id, dateStr)
.then(res => {
const grouped = res.schedules.reduce((acc, schedule) => {
const key = schedule.themeName;
if (!acc[key]) acc[key] = [];
acc[key].push(schedule);
return acc;
}, {} as Record<string, ScheduleWithThemeResponse[]>);
setSchedulesByTheme(grouped);
})
.catch(handleError);
} else {
setSchedulesByTheme({});
}
setSelectedSchedule(null);
}, [selectedDate, selectedStore]);
const handleDateSelect = (date: Date) => {
const today = new Date();
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="time-slots"> <div className="schedule-list">
{schedules.length > 0 ? schedules.map(schedule => ( {Object.keys(schedulesByTheme).length > 0 ? (
<div Object.entries(schedulesByTheme).map(([themeName, schedules]) => (
key={schedule.id} <div key={themeName} className="theme-schedule-group">
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`} <div className="theme-header">
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)} <h4>{themeName}</h4>
> <button onClick={() => openThemeModal(schedules[0].themeId)}
{schedule.time} className="theme-detail-button">
<span className="time-availability">{getStatusText(schedule.status)}</span> </button>
</div> </div>
)) : <div className="no-times"> .</div>} <div className="time-slots">
{schedules.map(schedule => (
<div
key={schedule.id}
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
>
{`${schedule.startFrom} ~ ${schedule.endAt}`}
<span className="time-availability">{getStatusText(schedule.status)}</span>
</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>
@ -300,4 +323,4 @@ const ReservationStep1Page: React.FC = () => {
); );
}; };
export default ReservationStep1Page; export default ReservationStep1Page;

View File

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

View File

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

View File

@ -1,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>
@ -306,101 +297,129 @@ const AdminSchedulePage: React.FC = () => {
<div className="table-container"> <div className="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th></th> {showThemeColumn && <th></th>}
<th></th> <th></th>
<th></th> <th></th>
</tr> <th></th>
</tr>
</thead> </thead>
<tbody> <tbody>
{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>{getScheduleStatusText(schedule.status)}</td> <td>{schedule.startFrom}</td>
<td className="action-buttons"> <td>{getScheduleStatusText(schedule.status)}</td>
<button <td className="action-buttons">
className="btn btn-secondary" <button
onClick={() => handleToggleDetails(schedule.id)} className="btn btn-secondary"
> onClick={() => handleToggleDetails(schedule.id)}
{expandedScheduleId === schedule.id ? '닫기' : '상세'} >
</button> {expandedScheduleId === schedule.id ? '닫기' : '상세'}
</td> </button>
</tr> </td>
{expandedScheduleId === schedule.id && ( </tr>
<tr className="schedule-details-row"> {expandedScheduleId === schedule.id && (
<td colSpan={3}> <tr className="schedule-details-row">
{isLoadingDetails ? ( <td colSpan={showThemeColumn ? 4 : 3}>
<p> ...</p> {isLoadingDetails ? (
) : detailedSchedules[schedule.id] ? ( <p> ...</p>
<div className="details-form-container"> ) : detailedSchedules[schedule.id] ? (
<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"
</div> className="form-input"
<div className="form-group"> value={editingSchedule.time}
<label className="form-label"></label> onChange={handleEditChange}/>
<select name="status" className="form-select" value={editingSchedule.status} onChange={handleEditChange}> </div>
{Object.values(ScheduleStatus).map(s => <option key={s} value={s}>{getScheduleStatusText(s)}</option>)} <div className="form-group">
</select> <label className="form-label"></label>
</div> <select name="status" className="form-select"
value={editingSchedule.status}
onChange={handleEditChange}>
{Object.values(ScheduleStatus).map(s =>
<option key={s}
value={s}>{getScheduleStatusText(s)}</option>)}
</select>
</div> </div>
</div> </div>
<div className="button-group">
<button type="button" className="btn btn-secondary" onClick={handleCancelEdit}></button>
<button type="button" className="btn btn-primary" onClick={handleSave}></button>
</div>
</div> </div>
) : ( <div className="button-group">
// --- VIEW MODE --- <button type="button" className="btn btn-secondary"
canModify && ( onClick={handleCancelEdit}>
<div className="button-group view-mode-buttons"> </button>
<button type="button" className="btn btn-danger" onClick={() => handleDeleteSchedule(schedule.id)}></button> <button type="button" className="btn btn-primary"
<button type="button" className="btn btn-primary" onClick={handleEditClick}></button> onClick={handleSave}>
</div> </button>
) </div>
)} </div>
</div> ) : (
) : ( // --- VIEW MODE ---
<p> .</p> canModify && (
)} <div className="button-group view-mode-buttons">
</td> <button type="button" className="btn btn-danger"
</tr> onClick={() => handleDeleteSchedule(schedule.id)}>
)} </button>
</Fragment> <button type="button" className="btn btn-primary"
))} onClick={handleEditClick}>
{isAdding && canModify && ( </button>
<tr className="editing-row"> </div>
<td> )
<input )}
type="time" </div>
className="form-input" ) : (
value={newScheduleTime} <p> .</p>
onChange={e => setNewScheduleTime(e.target.value)} )}
/> </td>
</td> </tr>
<td></td> )}
<td className="action-buttons"> </Fragment>
<button className="btn btn-primary" onClick={handleAddSchedule}></button> ))}
<button className="btn btn-secondary" onClick={() => setIsAdding(false)}></button> {isAdding && canModify && (
</td> <tr className="editing-row">
</tr> {showThemeColumn && <td></td>}
)} <td>
<input
type="time"
className="form-input"
value={newScheduleTime}
onChange={e => setNewScheduleTime(e.target.value)}
/>
</td>
<td></td>
<td className="action-buttons">
<button className="btn btn-primary" onClick={handleAddSchedule}></button>
<button className="btn btn-secondary" onClick={() => setIsAdding(false)}></button>
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -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>
) : ( ) : (

View File

@ -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>

View File

@ -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>
)} )}

View File

@ -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>

View File

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