generated from pricelees/issue-pr-template
[#39] '시간' -> '일정' 스키마 변경으로 테마별 시간 지정 #40
27
frontend/package-lock.json
generated
27
frontend/package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"axios": "^1.7.2",
|
||||
"bootstrap": "^5.3.3",
|
||||
"flatpickr": "^4.6.13",
|
||||
"json-bigint": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-flatpickr": "^3.10.13",
|
||||
@ -18,6 +19,7 @@
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/react-flatpickr": "^3.8.11",
|
||||
@ -1323,6 +1325,13 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-bigint": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.4.tgz",
|
||||
"integrity": "sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@ -1690,6 +1699,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz",
|
||||
@ -2833,6 +2851,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-bigint": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"axios": "^1.7.2",
|
||||
"bootstrap": "^5.3.3",
|
||||
"flatpickr": "^4.6.13",
|
||||
"json-bigint": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-flatpickr": "^3.10.13",
|
||||
@ -20,6 +21,7 @@
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/react-flatpickr": "^3.8.11",
|
||||
|
||||
@ -24,6 +24,7 @@ import HomePageV2 from './pages/v2/HomePageV2';
|
||||
import LoginPageV2 from './pages/v2/LoginPageV2';
|
||||
import SignupPageV2 from './pages/v2/SignupPageV2';
|
||||
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
|
||||
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
|
||||
|
||||
const AdminRoutes = () => (
|
||||
<AdminLayout>
|
||||
@ -34,6 +35,7 @@ const AdminRoutes = () => (
|
||||
<Route path="/theme" element={<AdminThemePage />} />
|
||||
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
|
||||
<Route path="/waiting" element={<AdminWaitingPage />} />
|
||||
<Route path="/schedule" element={<AdminSchedulePage />} />
|
||||
</Routes>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
@ -1,8 +1,28 @@
|
||||
import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios';
|
||||
import JSONbig from 'json-bigint';
|
||||
|
||||
// Create a JSONbig instance that stores big integers as strings
|
||||
const JSONbigString = JSONbig({ storeAsString: true });
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 10000,
|
||||
// transformResponse is used to parse JSON with big integers (Long type from backend) as strings.
|
||||
// This prevents precision loss in JavaScript.
|
||||
transformResponse: [(data) => {
|
||||
// Do not transform if data is not a string or is empty
|
||||
if (!data || typeof data !== 'string') {
|
||||
return data;
|
||||
}
|
||||
try {
|
||||
// Use the configured JSONbig instance to parse the data
|
||||
return JSONbigString.parse(data);
|
||||
} catch (e) {
|
||||
// If parsing fails, it might not be JSON, so return original data
|
||||
// This is the default behavior of axios if parsing fails
|
||||
return data;
|
||||
}
|
||||
}],
|
||||
});
|
||||
|
||||
export const isLoginRequiredError = (error: any): boolean => {
|
||||
|
||||
32
frontend/src/api/schedule/scheduleAPI.ts
Normal file
32
frontend/src/api/schedule/scheduleAPI.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import apiClient from '../apiClient';
|
||||
import type {
|
||||
AvailableThemeIdListResponse,
|
||||
ScheduleCreateRequest,
|
||||
ScheduleCreateResponse, ScheduleDetailRetrieveResponse,
|
||||
ScheduleRetrieveListResponse,
|
||||
ScheduleUpdateRequest
|
||||
} from './scheduleTypes';
|
||||
|
||||
export const findAvailableThemesByDate = async (date: string): Promise<AvailableThemeIdListResponse> => {
|
||||
return await apiClient.get<AvailableThemeIdListResponse>(`/schedules/themes?date=${date}`);
|
||||
};
|
||||
|
||||
export const findSchedules = async (date: string, themeId: string): Promise<ScheduleRetrieveListResponse> => {
|
||||
return await apiClient.get<ScheduleRetrieveListResponse>(`/schedules?date=${date}&themeId=${themeId}`);
|
||||
};
|
||||
|
||||
export const findScheduleById = async (id: string): Promise<ScheduleDetailRetrieveResponse> => {
|
||||
return await apiClient.get<ScheduleDetailRetrieveResponse>(`/schedules/${id}`);
|
||||
}
|
||||
|
||||
export const createSchedule = async (request: ScheduleCreateRequest): Promise<ScheduleCreateResponse> => {
|
||||
return await apiClient.post<ScheduleCreateResponse>('/schedules', request);
|
||||
};
|
||||
|
||||
export const updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise<void> => {
|
||||
await apiClient.patch(`/schedules/${id}`, request);
|
||||
};
|
||||
|
||||
export const deleteSchedule = async (id: string): Promise<void> => {
|
||||
await apiClient.del(`/schedules/${id}`);
|
||||
};
|
||||
48
frontend/src/api/schedule/scheduleTypes.ts
Normal file
48
frontend/src/api/schedule/scheduleTypes.ts
Normal file
@ -0,0 +1,48 @@
|
||||
export enum ScheduleStatus {
|
||||
AVAILABLE = 'AVAILABLE',
|
||||
PENDING = 'PENDING',
|
||||
RESERVED = 'RESERVED',
|
||||
BLOCKED = 'BLOCKED',
|
||||
}
|
||||
|
||||
export interface AvailableThemeIdListResponse {
|
||||
themeIds: string[];
|
||||
}
|
||||
|
||||
export interface ScheduleRetrieveResponse {
|
||||
id: string;
|
||||
time: string; // "HH:mm"
|
||||
status: ScheduleStatus;
|
||||
}
|
||||
|
||||
export interface ScheduleRetrieveListResponse {
|
||||
schedules: ScheduleRetrieveResponse[];
|
||||
}
|
||||
|
||||
export interface ScheduleCreateRequest {
|
||||
date: string; // "yyyy-MM-dd"
|
||||
time: string; // "HH:mm"
|
||||
themeId: string;
|
||||
}
|
||||
|
||||
export interface ScheduleCreateResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ScheduleUpdateRequest {
|
||||
date?: string; // "yyyy-MM-dd"
|
||||
time?: string; // "HH:mm"
|
||||
themeId?: string;
|
||||
status?: ScheduleStatus;
|
||||
}
|
||||
|
||||
export interface ScheduleDetailRetrieveResponse {
|
||||
id: string;
|
||||
date: string; // "yyyy-MM-dd"
|
||||
time: string; // "HH:mm"
|
||||
status: ScheduleStatus;
|
||||
createdAt: string; // or Date
|
||||
createdBy: string;
|
||||
updatedAt: string; // or Date
|
||||
updatedBy: string;
|
||||
}
|
||||
@ -4,7 +4,8 @@ import type {
|
||||
AdminThemeSummaryRetrieveListResponse,
|
||||
ThemeCreateRequest,
|
||||
ThemeCreateRequestV2, ThemeCreateResponse,
|
||||
ThemeCreateResponseV2, ThemeRetrieveListResponse,
|
||||
ThemeCreateResponseV2, ThemeListRetrieveRequest, ThemeRetrieveListResponse,
|
||||
ThemeRetrieveListResponseV2,
|
||||
ThemeUpdateRequest,
|
||||
UserThemeRetrieveListResponse
|
||||
} from './themeTypes';
|
||||
@ -48,3 +49,7 @@ export const deleteTheme = async (id: string): Promise<void> => {
|
||||
export const fetchUserThemes = async (): Promise<UserThemeRetrieveListResponse> => {
|
||||
return await apiClient.get<UserThemeRetrieveListResponse>('/v2/themes');
|
||||
};
|
||||
|
||||
export const findThemesByIds = async (request: ThemeListRetrieveRequest): Promise<ThemeRetrieveListResponseV2> => {
|
||||
return await apiClient.post<ThemeRetrieveListResponseV2>('/themes/retrieve', request);
|
||||
};
|
||||
|
||||
@ -123,6 +123,28 @@ export interface UserThemeRetrieveListResponse {
|
||||
themes: UserThemeRetrieveResponse[];
|
||||
}
|
||||
|
||||
export interface ThemeListRetrieveRequest {
|
||||
themeIds: string[];
|
||||
}
|
||||
|
||||
export interface ThemeRetrieveResponseV2 {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnailUrl: string;
|
||||
description: string;
|
||||
difficulty: Difficulty;
|
||||
price: number;
|
||||
minParticipants: number;
|
||||
maxParticipants: number;
|
||||
availableMinutes: number;
|
||||
expectedMinutesFrom: number;
|
||||
expectedMinutesTo: number;
|
||||
}
|
||||
|
||||
export interface ThemeRetrieveListResponseV2 {
|
||||
themes: ThemeRetrieveResponseV2[];
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
export enum Difficulty {
|
||||
VERY_EASY = 'VERY_EASY',
|
||||
|
||||
214
frontend/src/css/admin-schedule-page.css
Normal file
214
frontend/src/css/admin-schedule-page.css
Normal file
@ -0,0 +1,214 @@
|
||||
.admin-schedule-container {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schedule-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.schedule-controls .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.schedule-controls .form-label {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.schedule-controls .form-input,
|
||||
.schedule-controls .form-select {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.editing-row td {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.editing-row .form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Styles for schedule details row */
|
||||
.schedule-details-row td {
|
||||
padding: 0;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.details-form-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.details-form-container .form-card {
|
||||
background-color: #fff;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.details-form-container .form-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.details-form-container .form-row {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.details-form-container .form-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.details-form-container .form-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.details-form-container .form-input,
|
||||
.details-form-container .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
box-sizing: border-box; /* Ensures padding/border are included in height */
|
||||
}
|
||||
|
||||
.details-form-container .button-group {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.audit-info {
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
margin-bottom: 1.5rem; /* Add margin to separate from buttons */
|
||||
}
|
||||
|
||||
.audit-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #343a40;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.audit-body p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.audit-body p strong {
|
||||
color: #212529;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
@ -40,45 +40,116 @@
|
||||
color: #191F28;
|
||||
}
|
||||
|
||||
/* Date Selector */
|
||||
.date-selector {
|
||||
/* Date Carousel */
|
||||
.date-carousel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.date-options-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: hidden;
|
||||
flex-grow: 1;
|
||||
justify-content: space-between;
|
||||
margin: 0px 15px;
|
||||
}
|
||||
|
||||
.carousel-arrow, .today-button {
|
||||
background-color: #F2F4F6;
|
||||
border: 1px solid #E5E8EB;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #4E5968;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.today-button {
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
width: auto;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.carousel-arrow:hover, .today-button:hover {
|
||||
background-color: #E5E8EB;
|
||||
}
|
||||
|
||||
.date-option {
|
||||
cursor: pointer;
|
||||
padding: 12px 16px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
border: 2px solid transparent;
|
||||
background-color: #F2F4F6;
|
||||
transition: all 0.2s ease-in-out;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.date-option:hover {
|
||||
background-color: #E5E8EB;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.date-option.active {
|
||||
background-color: #3182F6;
|
||||
color: #ffffff;
|
||||
border-color: #3182F6;
|
||||
font-weight: 600;
|
||||
border: 1px solid #007bff;
|
||||
background-color: #e7f3ff;
|
||||
}
|
||||
|
||||
.date-option .day-of-week {
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.date-option .day {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
.date-option.active .day-of-week {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.date-option .day-circle {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.date-option.active .day-circle {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.date-option.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.date-option.disabled .day-circle {
|
||||
background-color: #E5E8EB;
|
||||
color: #B0B8C1;
|
||||
}
|
||||
|
||||
|
||||
/* Theme List */
|
||||
.theme-list {
|
||||
display: grid;
|
||||
|
||||
@ -60,3 +60,20 @@
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* =================================== */
|
||||
/* Button Group */
|
||||
/* =================================== */
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: flex-end; /* Aligns buttons to the right by default */
|
||||
gap: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
.button-group.full-width {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.button-group.full-width .btn {
|
||||
flex-grow: 1;
|
||||
}
|
||||
@ -81,11 +81,15 @@ a {
|
||||
margin-top: 72px;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
margin: 10px 0px !important;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
justify-content: center !important;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@ const LoginPage: React.FC = () => {
|
||||
<div className="form-group">
|
||||
<input type="password" className="form-control" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="d-flex justify-content-between">
|
||||
<div className="button-group full-width">
|
||||
<button className="btn btn-outline-custom" onClick={() => navigate('/signup')}>Sign Up</button>
|
||||
<button className="btn btn-custom" onClick={handleLogin}>Login</button>
|
||||
</div>
|
||||
|
||||
@ -25,6 +25,7 @@ const AdminNavbar: React.FC = () => {
|
||||
<Link className="nav-link" to="/admin/waiting">대기</Link>
|
||||
<Link className="nav-link" to="/admin/theme">테마</Link>
|
||||
<Link className="nav-link" to="/admin/time">시간</Link>
|
||||
<Link className="nav-link" to="/admin/schedule">일정</Link>
|
||||
</div>
|
||||
<div className="nav-actions">
|
||||
{!loggedIn ? (
|
||||
|
||||
315
frontend/src/pages/admin/AdminSchedulePage.tsx
Normal file
315
frontend/src/pages/admin/AdminSchedulePage.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { createSchedule, deleteSchedule, findScheduleById, findSchedules, updateSchedule } from '@_api/schedule/scheduleAPI';
|
||||
import { ScheduleStatus, type ScheduleDetailRetrieveResponse, type ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
|
||||
import { fetchAdminThemes } from '@_api/theme/themeAPI';
|
||||
import type { AdminThemeSummaryRetrieveResponse } from '@_api/theme/themeTypes';
|
||||
import '@_css/admin-schedule-page.css';
|
||||
import React, { Fragment, useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
const getScheduleStatusText = (status: ScheduleStatus): string => {
|
||||
switch (status) {
|
||||
case ScheduleStatus.AVAILABLE:
|
||||
return '예약 가능';
|
||||
case ScheduleStatus.PENDING:
|
||||
return '예약 진행 중';
|
||||
case ScheduleStatus.RESERVED:
|
||||
return '예약 완료';
|
||||
case ScheduleStatus.BLOCKED:
|
||||
return '예약 불가';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const AdminSchedulePage: React.FC = () => {
|
||||
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
|
||||
const [themes, setThemes] = useState<AdminThemeSummaryRetrieveResponse[]>([]);
|
||||
const [selectedThemeId, setSelectedThemeId] = useState<string>('');
|
||||
const [selectedDate, setSelectedDate] = useState<string>(new Date().toLocaleDateString('en-CA'));
|
||||
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newScheduleTime, setNewScheduleTime] = useState('');
|
||||
|
||||
const [expandedScheduleId, setExpandedScheduleId] = useState<string | null>(null);
|
||||
const [detailedSchedules, setDetailedSchedules] = useState<{ [key: string]: ScheduleDetailRetrieveResponse }>({});
|
||||
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editingSchedule, setEditingSchedule] = useState<ScheduleDetailRetrieveResponse | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleError = (err: any) => {
|
||||
if (isLoginRequiredError(err)) {
|
||||
alert('로그인이 필요해요.');
|
||||
navigate('/login', { state: { from: location } });
|
||||
} else {
|
||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||
alert(message);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdminThemes()
|
||||
.then(res => {
|
||||
setThemes(res.themes);
|
||||
if (res.themes.length > 0) {
|
||||
setSelectedThemeId(String(res.themes[0].id));
|
||||
}
|
||||
})
|
||||
.catch(handleError);
|
||||
}, []);
|
||||
|
||||
const fetchSchedules = () => {
|
||||
if (selectedDate && selectedThemeId) {
|
||||
findSchedules(selectedDate, selectedThemeId)
|
||||
.then(res => setSchedules(res.schedules))
|
||||
.catch(err => {
|
||||
setSchedules([]);
|
||||
if (err.response?.status !== 404) {
|
||||
handleError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSchedules();
|
||||
}, [selectedDate, selectedThemeId]);
|
||||
|
||||
const handleAddSchedule = async () => {
|
||||
if (!newScheduleTime) {
|
||||
alert('시간을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!/\d{2}:\d{2}/.test(newScheduleTime)) {
|
||||
alert('시간 형식이 올바르지 않습니다. HH:MM 형식으로 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createSchedule({
|
||||
date: selectedDate,
|
||||
themeId: selectedThemeId,
|
||||
time: newScheduleTime,
|
||||
});
|
||||
fetchSchedules();
|
||||
setIsAdding(false);
|
||||
setNewScheduleTime('');
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSchedule = async (scheduleId: string) => {
|
||||
if (window.confirm('정말 이 일정을 삭제하시겠습니까?')) {
|
||||
try {
|
||||
await deleteSchedule(scheduleId);
|
||||
setSchedules(schedules.filter(s => s.id !== scheduleId));
|
||||
setExpandedScheduleId(null); // Close the details view after deletion
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleDetails = async (scheduleId: string) => {
|
||||
const isAlreadyExpanded = expandedScheduleId === scheduleId;
|
||||
setIsEditing(false); // Reset editing state whenever toggling
|
||||
if (isAlreadyExpanded) {
|
||||
setExpandedScheduleId(null);
|
||||
} else {
|
||||
setExpandedScheduleId(scheduleId);
|
||||
if (!detailedSchedules[scheduleId]) {
|
||||
setIsLoadingDetails(true);
|
||||
try {
|
||||
const details = await findScheduleById(scheduleId);
|
||||
setDetailedSchedules(prev => ({ ...prev, [scheduleId]: details }));
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
} finally {
|
||||
setIsLoadingDetails(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
if (expandedScheduleId && detailedSchedules[expandedScheduleId]) {
|
||||
setEditingSchedule({ ...detailedSchedules[expandedScheduleId] });
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
setEditingSchedule(null);
|
||||
};
|
||||
|
||||
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
if (editingSchedule) {
|
||||
setEditingSchedule({ ...editingSchedule, [name]: value });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingSchedule) return;
|
||||
|
||||
try {
|
||||
await updateSchedule(editingSchedule.id, {
|
||||
time: editingSchedule.time,
|
||||
status: editingSchedule.status,
|
||||
});
|
||||
// Refresh data
|
||||
const details = await findScheduleById(editingSchedule.id);
|
||||
setDetailedSchedules(prev => ({ ...prev, [editingSchedule.id]: details }));
|
||||
setSchedules(schedules.map(s => s.id === editingSchedule.id ? { ...s, time: details.time, status: details.status } : s));
|
||||
|
||||
alert('일정이 성공적으로 업데이트되었습니다.');
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-schedule-container">
|
||||
<h2 className="page-title">일정 관리</h2>
|
||||
|
||||
<div className="schedule-controls">
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="date-filter">날짜</label>
|
||||
<input
|
||||
id="date-filter"
|
||||
type="date"
|
||||
className="form-input"
|
||||
value={selectedDate}
|
||||
onChange={e => setSelectedDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="theme-filter">테마</label>
|
||||
<select
|
||||
id="theme-filter"
|
||||
className="form-select"
|
||||
value={selectedThemeId}
|
||||
onChange={e => setSelectedThemeId(e.target.value)}
|
||||
>
|
||||
{themes.map(theme => (
|
||||
<option key={theme.id} value={theme.id}>{theme.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-card">
|
||||
<div className="table-header">
|
||||
<button className="btn btn-primary" onClick={() => setIsAdding(true)}>일정 추가</button>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>상태</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{schedules.map(schedule => (
|
||||
<Fragment key={schedule.id}>
|
||||
<tr>
|
||||
<td>{schedule.time}</td>
|
||||
<td>{getScheduleStatusText(schedule.status)}</td>
|
||||
<td className="action-buttons">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handleToggleDetails(schedule.id)}
|
||||
>
|
||||
{expandedScheduleId === schedule.id ? '닫기' : '상세'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedScheduleId === schedule.id && (
|
||||
<tr className="schedule-details-row">
|
||||
<td colSpan={3}>
|
||||
{isLoadingDetails ? (
|
||||
<p>로딩 중...</p>
|
||||
) : detailedSchedules[schedule.id] ? (
|
||||
<div className="details-form-container">
|
||||
<div className="audit-info">
|
||||
<h4 className="audit-title">감사 정보</h4>
|
||||
<div className="audit-body">
|
||||
<p><strong>생성일:</strong> {new Date(detailedSchedules[schedule.id].createdAt).toLocaleString()}</p>
|
||||
<p><strong>수정일:</strong> {new Date(detailedSchedules[schedule.id].updatedAt).toLocaleString()}</p>
|
||||
<p><strong>생성자:</strong> {detailedSchedules[schedule.id].createdBy}</p>
|
||||
<p><strong>수정자:</strong> {detailedSchedules[schedule.id].updatedBy}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing && editingSchedule ? (
|
||||
// --- EDIT MODE ---
|
||||
<div className="form-card">
|
||||
<div className="form-section">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">시간</label>
|
||||
<input type="time" name="time" className="form-input" value={editingSchedule.time} onChange={handleEditChange} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">상태</label>
|
||||
<select name="status" className="form-select" value={editingSchedule.status} onChange={handleEditChange}>
|
||||
{Object.values(ScheduleStatus).map(s => <option key={s} value={s}>{getScheduleStatusText(s)}</option>)}
|
||||
</select>
|
||||
</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>
|
||||
) : (
|
||||
// --- VIEW MODE ---
|
||||
<div className="button-group view-mode-buttons">
|
||||
<button type="button" className="btn btn-danger" onClick={() => handleDeleteSchedule(schedule.id)}>삭제</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleEditClick}>수정</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p>상세 정보를 불러올 수 없습니다.</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{isAdding && (
|
||||
<tr className="editing-row">
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSchedulePage;
|
||||
@ -1,18 +1,18 @@
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { createPendingReservation } from '@_api/reservation/reservationAPI';
|
||||
import { fetchUserThemes } from '@_api/theme/themeAPI';
|
||||
import { fetchTimesWithAvailability } from '@_api/time/timeAPI';
|
||||
import type { TimeWithAvailabilityResponse } from '@_api/time/timeTypes';
|
||||
import { findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI';
|
||||
import type { ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
|
||||
import { findThemesByIds } from '@_api/theme/themeAPI';
|
||||
import { Difficulty } from '@_api/theme/themeTypes';
|
||||
import '@_css/reservation-v2-1.css';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
|
||||
|
||||
// New theme type based on the provided schema
|
||||
interface ThemeV21 {
|
||||
id: string; // Changed to number to match API
|
||||
id: string;
|
||||
name: string;
|
||||
difficulty: string;
|
||||
difficulty: Difficulty;
|
||||
description: string;
|
||||
thumbnailUrl: string;
|
||||
price: number;
|
||||
@ -23,13 +23,30 @@ interface ThemeV21 {
|
||||
availableMinutes: number;
|
||||
}
|
||||
|
||||
const getDifficultyText = (difficulty: Difficulty): string => {
|
||||
switch (difficulty) {
|
||||
case Difficulty.VERY_EASY:
|
||||
return '매우 쉬움';
|
||||
case Difficulty.EASY:
|
||||
return '쉬움';
|
||||
case Difficulty.NORMAL:
|
||||
return '보통';
|
||||
case Difficulty.HARD:
|
||||
return '어려움';
|
||||
case Difficulty.VERY_HARD:
|
||||
return '매우 어려움';
|
||||
default:
|
||||
return difficulty;
|
||||
}
|
||||
};
|
||||
|
||||
const ReservationStep1PageV21: React.FC = () => {
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
const [viewDate, setViewDate] = useState<Date>(new Date()); // For carousel
|
||||
const [themes, setThemes] = useState<ThemeV21[]>([]);
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeV21 | null>(null);
|
||||
const [times, setTimes] = useState<TimeWithAvailabilityResponse[]>([]);
|
||||
const [selectedTime, setSelectedTime] = useState<TimeWithAvailabilityResponse | null>(null);
|
||||
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
|
||||
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleRetrieveResponse | null>(null);
|
||||
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
||||
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
@ -47,49 +64,47 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await fetchUserThemes();
|
||||
// Map UserThemeRetrieveResponse to ThemeV21
|
||||
const mappedThemes: ThemeV21[] = response.themes.map(theme => ({
|
||||
id: theme.id,
|
||||
name: theme.name,
|
||||
difficulty: theme.difficulty,
|
||||
description: theme.description,
|
||||
thumbnailUrl: theme.thumbnailUrl,
|
||||
price: theme.price,
|
||||
minParticipants: theme.minParticipants,
|
||||
maxParticipants: theme.maxParticipants,
|
||||
expectedMinutesFrom: theme.expectedMinutesFrom,
|
||||
expectedMinutesTo: theme.expectedMinutesTo,
|
||||
availableMinutes: theme.availableMinutes,
|
||||
}));
|
||||
setThemes(mappedThemes);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
if (selectedDate) {
|
||||
const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd
|
||||
findAvailableThemesByDate(dateStr)
|
||||
.then(res => {
|
||||
const themeIds: string[] = res.themeIds;
|
||||
if (themeIds.length > 0) {
|
||||
return findThemesByIds({ themeIds });
|
||||
} else {
|
||||
return Promise.resolve({ themes: [] });
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
})
|
||||
.then(themeResponse => {
|
||||
setThemes(themeResponse.themes as ThemeV21[]);
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
setSelectedTheme(null);
|
||||
setSchedules([]);
|
||||
setSelectedSchedule(null);
|
||||
});
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && selectedTheme) {
|
||||
const dateStr = selectedDate.toLocaleDateString('en-CA');
|
||||
fetchTimesWithAvailability(dateStr, selectedTheme.id)
|
||||
findSchedules(dateStr, selectedTheme.id)
|
||||
.then(res => {
|
||||
setTimes(res.times);
|
||||
setSelectedTime(null);
|
||||
setSchedules(res.schedules);
|
||||
setSelectedSchedule(null);
|
||||
})
|
||||
.catch(handleError);
|
||||
}
|
||||
}, [selectedDate, selectedTheme]);
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (!selectedDate || !selectedTheme || !selectedTime) {
|
||||
if (!selectedDate || !selectedTheme || !selectedSchedule) {
|
||||
alert('날짜, 테마, 시간을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!selectedTime.isAvailable) {
|
||||
if (selectedSchedule.status !== 'AVAILABLE') {
|
||||
alert('예약할 수 없는 시간입니다.');
|
||||
return;
|
||||
}
|
||||
@ -97,45 +112,85 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleConfirmPayment = () => {
|
||||
if (!selectedDate || !selectedTheme || !selectedTime) return;
|
||||
if (!selectedDate || !selectedTheme || !selectedSchedule) return;
|
||||
|
||||
const reservationData = {
|
||||
date: selectedDate.toLocaleDateString('en-CA'),
|
||||
themeId: selectedTheme.id,
|
||||
timeId: selectedTime.id,
|
||||
scheduleId: selectedSchedule.id,
|
||||
};
|
||||
|
||||
createPendingReservation(reservationData)
|
||||
.then((res) => {
|
||||
navigate('/v2/reservation/payment', { state: { reservation: res } });
|
||||
navigate('/v2-1/reservation/payment', { state: { reservation: res } });
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => setIsModalOpen(false));
|
||||
|
||||
.finally(() => setIsConfirmModalOpen(false));
|
||||
};
|
||||
|
||||
const renderDateOptions = () => {
|
||||
const handleDateSelect = (date: Date) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (date < today) {
|
||||
alert("지난 날짜는 선택할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
setSelectedDate(date);
|
||||
}
|
||||
|
||||
const renderDateCarousel = () => {
|
||||
const dates = [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() + i);
|
||||
const date = new Date(viewDate);
|
||||
date.setDate(viewDate.getDate() + i);
|
||||
dates.push(date);
|
||||
}
|
||||
|
||||
return dates.map(date => {
|
||||
const handlePrev = () => {
|
||||
const newViewDate = new Date(viewDate);
|
||||
newViewDate.setDate(viewDate.getDate() - 1);
|
||||
if (newViewDate < today) {
|
||||
alert("지난 날짜는 조회할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
setViewDate(newViewDate);
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
const newViewDate = new Date(viewDate);
|
||||
newViewDate.setDate(viewDate.getDate() + 1);
|
||||
setViewDate(newViewDate);
|
||||
}
|
||||
|
||||
const goToToday = () => {
|
||||
setViewDate(new Date());
|
||||
setSelectedDate(new Date());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="date-carousel">
|
||||
<button onClick={handlePrev} className="carousel-arrow">‹</button>
|
||||
<div className="date-options-container">
|
||||
{dates.map(date => {
|
||||
const isSelected = selectedDate.toDateString() === date.toDateString();
|
||||
const isPast = date < today;
|
||||
return (
|
||||
<div
|
||||
key={date.toISOString()}
|
||||
className={`date-option ${isSelected ? 'active' : ''}`}
|
||||
onClick={() => setSelectedDate(date)}
|
||||
className={`date-option ${isSelected ? 'active' : ''} ${isPast ? 'disabled' : ''}`}
|
||||
onClick={() => handleDateSelect(date)}
|
||||
>
|
||||
<div className="day-of-week">{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}</div>
|
||||
<div className="day">{date.getDate()}</div>
|
||||
<div className="day-circle">{date.getDate()}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button onClick={handleNext} className="carousel-arrow">›</button>
|
||||
<button onClick={goToToday} className="today-button">오늘</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const openThemeModal = (theme: ThemeV21) => {
|
||||
@ -143,7 +198,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
setIsThemeModalOpen(true);
|
||||
};
|
||||
|
||||
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || !selectedTime.isAvailable;
|
||||
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== 'AVAILABLE';
|
||||
|
||||
return (
|
||||
<div className="reservation-v21-container">
|
||||
@ -151,7 +206,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
|
||||
<div className="step-section">
|
||||
<h3>1. 날짜 선택</h3>
|
||||
<div className="date-selector">{renderDateOptions()}</div>
|
||||
{renderDateCarousel()}
|
||||
</div>
|
||||
|
||||
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
|
||||
@ -166,9 +221,9 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
<div className="theme-info">
|
||||
<h4>{theme.name}</h4>
|
||||
<div className="theme-meta">
|
||||
<p><strong>난이도:</strong> {theme.difficulty}</p>
|
||||
<p><strong>참여 인원:</strong> {theme.minParticipants} ~ {theme.maxParticipants}명</p>
|
||||
<p><strong>가격:</strong> {theme.price.toLocaleString()}원</p>
|
||||
<p><strong>난이도:</strong> {getDifficultyText(theme.difficulty)}</p>
|
||||
<p><strong>참여 가능 인원:</strong> {theme.minParticipants} ~ {theme.maxParticipants}명</p>
|
||||
<p><strong>예상 소요 시간:</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}분</p>
|
||||
<p><strong>이용 가능 시간:</strong> {theme.availableMinutes}분</p>
|
||||
</div>
|
||||
@ -182,14 +237,14 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
<div className={`step-section ${!selectedTheme ? 'disabled' : ''}`}>
|
||||
<h3>3. 시간 선택</h3>
|
||||
<div className="time-slots">
|
||||
{times.length > 0 ? times.map(time => (
|
||||
{schedules.length > 0 ? schedules.map(schedule => (
|
||||
<div
|
||||
key={time.id}
|
||||
className={`time-slot ${selectedTime?.id === time.id ? 'active' : ''} ${!time.isAvailable ? 'disabled' : ''}`}
|
||||
onClick={() => time.isAvailable && setSelectedTime(time)}
|
||||
key={schedule.id}
|
||||
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== 'AVAILABLE' ? 'disabled' : ''}`}
|
||||
onClick={() => schedule.status === 'AVAILABLE' && setSelectedSchedule(schedule)}
|
||||
>
|
||||
{time.startAt}
|
||||
<span className="time-availability">{time.isAvailable ? '예약가능' : '예약불가'}</span>
|
||||
{schedule.time}
|
||||
<span className="time-availability">{schedule.status === 'AVAILABLE' ? '예약가능' : '예약불가'}</span>
|
||||
</div>
|
||||
)) : <div className="no-times">선택 가능한 시간이 없습니다.</div>}
|
||||
</div>
|
||||
@ -209,7 +264,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
<h2>{selectedTheme.name}</h2>
|
||||
<div className="modal-section">
|
||||
<h3>테마 정보</h3>
|
||||
<p><strong>난이도:</strong> {selectedTheme.difficulty}</p>
|
||||
<p><strong>난이도:</strong> {getDifficultyText(selectedTheme.difficulty)}</p>
|
||||
<p><strong>참여 인원:</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</p>
|
||||
<p><strong>소요 시간:</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</p>
|
||||
<p><strong>가격:</strong> {selectedTheme.price.toLocaleString()}원</p>
|
||||
@ -230,7 +285,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
<div className="modal-section">
|
||||
<p><strong>날짜:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
|
||||
<p><strong>테마:</strong> {selectedTheme!!.name}</p>
|
||||
<p><strong>시간:</strong> {formatTime(selectedTime!!.startAt)}</p>
|
||||
<p><strong>시간:</strong> {formatTime(selectedSchedule!!.time)}</p>
|
||||
<p><strong>결제금액:</strong> {selectedTheme!!.price.toLocaleString()}원</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user