From fa9b8589507f07046a2a8de2a8de84087ac89985 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 4 Sep 2025 11:30:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=B0=8F=20=EC=98=88=EC=95=BD=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=20=EC=9D=BC=EC=A0=95(Schedule)=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 27 ++ frontend/package.json | 2 + frontend/src/App.tsx | 76 +++-- frontend/src/api/apiClient.ts | 20 ++ frontend/src/api/schedule/scheduleAPI.ts | 32 ++ frontend/src/api/schedule/scheduleTypes.ts | 48 +++ frontend/src/api/theme/themeAPI.ts | 7 +- frontend/src/api/theme/themeTypes.ts | 22 ++ frontend/src/css/admin-schedule-page.css | 214 ++++++++++++ frontend/src/css/reservation-v2-1.css | 109 ++++-- frontend/src/css/style.css | 17 + frontend/src/css/toss-style.css | 8 +- frontend/src/pages/LoginPage.tsx | 2 +- frontend/src/pages/admin/AdminNavbar.tsx | 1 + .../src/pages/admin/AdminSchedulePage.tsx | 315 ++++++++++++++++++ .../src/pages/v2/ReservationStep1PageV21.tsx | 199 +++++++---- 16 files changed, 967 insertions(+), 132 deletions(-) create mode 100644 frontend/src/api/schedule/scheduleAPI.ts create mode 100644 frontend/src/api/schedule/scheduleTypes.ts create mode 100644 frontend/src/css/admin-schedule-page.css create mode 100644 frontend/src/pages/admin/AdminSchedulePage.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 57d002b4..c5641f69 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index e200fb80..eccb9944 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 55f77c3e..e85af96a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => ( @@ -34,51 +35,52 @@ const AdminRoutes = () => ( } /> } /> } /> + } /> ); function App() { - return ( - - - - - - - } /> - - - } /> - } /> - } /> - } /> - } /> - } /> + return ( + + + + + + + } /> + + + } /> + } /> + } /> + } /> + } /> + } /> - {/* V2 Pages */} - } /> - } /> - } /> + {/* V2 Pages */} + } /> + } /> + } /> - {/* V2 Reservation Flow */} - } /> - } /> - } /> + {/* V2 Reservation Flow */} + } /> + } /> + } /> - {/* V2.1 Reservation Flow */} - } /> - } /> - } /> - - - } /> - - - - ); + {/* V2.1 Reservation Flow */} + } /> + } /> + } /> + + + } /> + + + + ); } export default App; \ No newline at end of file diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index f72d764c..243b84ae 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -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 => { diff --git a/frontend/src/api/schedule/scheduleAPI.ts b/frontend/src/api/schedule/scheduleAPI.ts new file mode 100644 index 00000000..403a5204 --- /dev/null +++ b/frontend/src/api/schedule/scheduleAPI.ts @@ -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 => { + return await apiClient.get(`/schedules/themes?date=${date}`); +}; + +export const findSchedules = async (date: string, themeId: string): Promise => { + return await apiClient.get(`/schedules?date=${date}&themeId=${themeId}`); +}; + +export const findScheduleById = async (id: string): Promise => { + return await apiClient.get(`/schedules/${id}`); +} + +export const createSchedule = async (request: ScheduleCreateRequest): Promise => { + return await apiClient.post('/schedules', request); +}; + +export const updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise => { + await apiClient.patch(`/schedules/${id}`, request); +}; + +export const deleteSchedule = async (id: string): Promise => { + await apiClient.del(`/schedules/${id}`); +}; diff --git a/frontend/src/api/schedule/scheduleTypes.ts b/frontend/src/api/schedule/scheduleTypes.ts new file mode 100644 index 00000000..73550b46 --- /dev/null +++ b/frontend/src/api/schedule/scheduleTypes.ts @@ -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; +} diff --git a/frontend/src/api/theme/themeAPI.ts b/frontend/src/api/theme/themeAPI.ts index b36ca302..1c7566ca 100644 --- a/frontend/src/api/theme/themeAPI.ts +++ b/frontend/src/api/theme/themeAPI.ts @@ -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 => { export const fetchUserThemes = async (): Promise => { return await apiClient.get('/v2/themes'); }; + +export const findThemesByIds = async (request: ThemeListRetrieveRequest): Promise => { + return await apiClient.post('/themes/retrieve', request); +}; diff --git a/frontend/src/api/theme/themeTypes.ts b/frontend/src/api/theme/themeTypes.ts index dd24ed8e..aa022f84 100644 --- a/frontend/src/api/theme/themeTypes.ts +++ b/frontend/src/api/theme/themeTypes.ts @@ -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', diff --git a/frontend/src/css/admin-schedule-page.css b/frontend/src/css/admin-schedule-page.css new file mode 100644 index 00000000..350d6c33 --- /dev/null +++ b/frontend/src/css/admin-schedule-page.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/css/reservation-v2-1.css b/frontend/src/css/reservation-v2-1.css index a68804d4..6e56c6ba 100644 --- a/frontend/src/css/reservation-v2-1.css +++ b/frontend/src/css/reservation-v2-1.css @@ -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; @@ -346,4 +417,4 @@ } .modal-actions .confirm-button:hover { background-color: #1B64DA; -} +} \ No newline at end of file diff --git a/frontend/src/css/style.css b/frontend/src/css/style.css index 81506574..56e4d777 100644 --- a/frontend/src/css/style.css +++ b/frontend/src/css/style.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/css/toss-style.css b/frontend/src/css/toss-style.css index a79080d5..7b87c4cd 100644 --- a/frontend/src/css/toss-style.css +++ b/frontend/src/css/toss-style.css @@ -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; } diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 207c0b59..e9472583 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -35,7 +35,7 @@ const LoginPage: React.FC = () => {
setPassword(e.target.value)} />
-
+
diff --git a/frontend/src/pages/admin/AdminNavbar.tsx b/frontend/src/pages/admin/AdminNavbar.tsx index e9b8db32..20444525 100644 --- a/frontend/src/pages/admin/AdminNavbar.tsx +++ b/frontend/src/pages/admin/AdminNavbar.tsx @@ -25,6 +25,7 @@ const AdminNavbar: React.FC = () => { 대기 테마 시간 + 일정
{!loggedIn ? ( diff --git a/frontend/src/pages/admin/AdminSchedulePage.tsx b/frontend/src/pages/admin/AdminSchedulePage.tsx new file mode 100644 index 00000000..28657fe6 --- /dev/null +++ b/frontend/src/pages/admin/AdminSchedulePage.tsx @@ -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([]); + const [themes, setThemes] = useState([]); + const [selectedThemeId, setSelectedThemeId] = useState(''); + const [selectedDate, setSelectedDate] = useState(new Date().toLocaleDateString('en-CA')); + + const [isAdding, setIsAdding] = useState(false); + const [newScheduleTime, setNewScheduleTime] = useState(''); + + const [expandedScheduleId, setExpandedScheduleId] = useState(null); + const [detailedSchedules, setDetailedSchedules] = useState<{ [key: string]: ScheduleDetailRetrieveResponse }>({}); + const [isLoadingDetails, setIsLoadingDetails] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editingSchedule, setEditingSchedule] = useState(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) => { + 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 ( +
+

일정 관리

+ +
+
+ + setSelectedDate(e.target.value)} + /> +
+
+ + +
+
+ +
+
+ +
+
+ + + + + + + + + + {schedules.map(schedule => ( + + + + + + + {expandedScheduleId === schedule.id && ( + + + + )} + + ))} + {isAdding && ( + + + + + + )} + +
시간상태관리
{schedule.time}{getScheduleStatusText(schedule.status)} + +
+ {isLoadingDetails ? ( +

로딩 중...

+ ) : detailedSchedules[schedule.id] ? ( +
+
+

감사 정보

+
+

생성일: {new Date(detailedSchedules[schedule.id].createdAt).toLocaleString()}

+

수정일: {new Date(detailedSchedules[schedule.id].updatedAt).toLocaleString()}

+

생성자: {detailedSchedules[schedule.id].createdBy}

+

수정자: {detailedSchedules[schedule.id].updatedBy}

+
+
+ + {isEditing && editingSchedule ? ( + // --- EDIT MODE --- +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ ) : ( + // --- VIEW MODE --- +
+ + +
+ )} +
+ ) : ( +

상세 정보를 불러올 수 없습니다.

+ )} +
+ setNewScheduleTime(e.target.value)} + /> + + + +
+
+
+
+ ); +}; + +export default AdminSchedulePage; diff --git a/frontend/src/pages/v2/ReservationStep1PageV21.tsx b/frontend/src/pages/v2/ReservationStep1PageV21.tsx index f717cd11..51286bb0 100644 --- a/frontend/src/pages/v2/ReservationStep1PageV21.tsx +++ b/frontend/src/pages/v2/ReservationStep1PageV21.tsx @@ -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(new Date()); + const [viewDate, setViewDate] = useState(new Date()); // For carousel const [themes, setThemes] = useState([]); const [selectedTheme, setSelectedTheme] = useState(null); - const [times, setTimes] = useState([]); - const [selectedTime, setSelectedTime] = useState(null); + const [schedules, setSchedules] = useState([]); + const [selectedSchedule, setSelectedSchedule] = useState(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); - } - }; - fetchData(); - }, []); + 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: [] }); + } + }) + .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 isSelected = selectedDate.toDateString() === date.toDateString(); - return ( -
setSelectedDate(date)} - > -
{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}
-
{date.getDate()}
+ 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 ( +
+ +
+ {dates.map(date => { + const isSelected = selectedDate.toDateString() === date.toDateString(); + const isPast = date < today; + return ( +
handleDateSelect(date)} + > +
{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}
+
{date.getDate()}
+
+ ); + })}
- ); - }); + + +
+ ); }; 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 (
@@ -151,7 +206,7 @@ const ReservationStep1PageV21: React.FC = () => {

1. 날짜 선택

-
{renderDateOptions()}
+ {renderDateCarousel()}
@@ -166,9 +221,9 @@ const ReservationStep1PageV21: React.FC = () => {

{theme.name}

-

난이도: {theme.difficulty}

-

참여 인원: {theme.minParticipants} ~ {theme.maxParticipants}명

가격: {theme.price.toLocaleString()}원

+

난이도: {getDifficultyText(theme.difficulty)}

+

참여 가능 인원: {theme.minParticipants} ~ {theme.maxParticipants}명

예상 소요 시간: {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}분

이용 가능 시간: {theme.availableMinutes}분

@@ -182,14 +237,14 @@ const ReservationStep1PageV21: React.FC = () => {

3. 시간 선택

- {times.length > 0 ? times.map(time => ( + {schedules.length > 0 ? schedules.map(schedule => (
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} - {time.isAvailable ? '예약가능' : '예약불가'} + {schedule.time} + {schedule.status === 'AVAILABLE' ? '예약가능' : '예약불가'}
)) :
선택 가능한 시간이 없습니다.
}
@@ -209,7 +264,7 @@ const ReservationStep1PageV21: React.FC = () => {

{selectedTheme.name}

테마 정보

-

난이도: {selectedTheme.difficulty}

+

난이도: {getDifficultyText(selectedTheme.difficulty)}

참여 인원: {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명

소요 시간: {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분

가격: {selectedTheme.price.toLocaleString()}원

@@ -230,7 +285,7 @@ const ReservationStep1PageV21: React.FC = () => {

날짜: {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}

테마: {selectedTheme!!.name}

-

시간: {formatTime(selectedTime!!.startAt)}

+

시간: {formatTime(selectedSchedule!!.time)}

결제금액: {selectedTheme!!.price.toLocaleString()}원

@@ -244,4 +299,4 @@ const ReservationStep1PageV21: React.FC = () => { ); }; -export default ReservationStep1PageV21; \ No newline at end of file +export default ReservationStep1PageV21;