generated from pricelees/issue-pr-template
<!-- 제목 양식 --> <!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) --> ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #37 ## ✨ 작업 내용 <!-- 어떤 작업을 했는지 알려주세요! --> - 가격, 시간 등 테마를 정의하는데 필요하다고 느껴지는 필드 추가 - JPA Auditing으로 감사 정보 확인 기능 추가 - 프론트엔드 페이지 디자인 변경 및 새로운 API 반영 ## 🧪 테스트 <!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! --> 6db81feb9b 을 바탕으로 향후 다른 모든 기능의 테스트를 통합 테스트로 전환할 예정. 지금은 불필요한 테스트가 너무 많다고 느껴짐. ## 📚 참고 자료 및 기타 <!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! --> - FInder / Writer / Validator 구조를 수정할 필요가 있음. 복잡하고 가독성이 낮은 로직만 별도로 빼는 것이 더 효율적이라고 판단됨. Reviewed-on: #38 Co-authored-by: pricelees <priceelees@gmail.com> Co-committed-by: pricelees <priceelees@gmail.com>
This commit is contained in:
parent
ef58752cec
commit
bdc99c7883
@ -17,6 +17,13 @@ import ReservationStep1Page from './pages/v2/ReservationStep1Page';
|
|||||||
import ReservationStep2Page from './pages/v2/ReservationStep2Page';
|
import ReservationStep2Page from './pages/v2/ReservationStep2Page';
|
||||||
import ReservationSuccessPage from './pages/v2/ReservationSuccessPage';
|
import ReservationSuccessPage from './pages/v2/ReservationSuccessPage';
|
||||||
import MyReservationPageV2 from './pages/v2/MyReservationPageV2';
|
import MyReservationPageV2 from './pages/v2/MyReservationPageV2';
|
||||||
|
import ReservationStep1PageV21 from './pages/v2/ReservationStep1PageV21';
|
||||||
|
import ReservationStep2PageV21 from './pages/v2/ReservationStep2PageV21';
|
||||||
|
import ReservationSuccessPageV21 from './pages/v2/ReservationSuccessPageV21';
|
||||||
|
import HomePageV2 from './pages/v2/HomePageV2';
|
||||||
|
import LoginPageV2 from './pages/v2/LoginPageV2';
|
||||||
|
import SignupPageV2 from './pages/v2/SignupPageV2';
|
||||||
|
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
|
||||||
|
|
||||||
const AdminRoutes = () => (
|
const AdminRoutes = () => (
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
@ -25,6 +32,7 @@ const AdminRoutes = () => (
|
|||||||
<Route path="/reservation" element={<AdminReservationPage />} />
|
<Route path="/reservation" element={<AdminReservationPage />} />
|
||||||
<Route path="/time" element={<AdminTimePage />} />
|
<Route path="/time" element={<AdminTimePage />} />
|
||||||
<Route path="/theme" element={<AdminThemePage />} />
|
<Route path="/theme" element={<AdminThemePage />} />
|
||||||
|
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
|
||||||
<Route path="/waiting" element={<AdminWaitingPage />} />
|
<Route path="/waiting" element={<AdminWaitingPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
@ -50,10 +58,20 @@ function App() {
|
|||||||
<Route path="/my-reservation" element={<MyReservationPage />} />
|
<Route path="/my-reservation" element={<MyReservationPage />} />
|
||||||
<Route path="/my-reservation/v2" element={<MyReservationPageV2 />} />
|
<Route path="/my-reservation/v2" element={<MyReservationPageV2 />} />
|
||||||
|
|
||||||
|
{/* V2 Pages */}
|
||||||
|
<Route path="/v2/home" element={<HomePageV2 />} />
|
||||||
|
<Route path="/v2/login" element={<LoginPageV2 />} />
|
||||||
|
<Route path="/v2/signup" element={<SignupPageV2 />} />
|
||||||
|
|
||||||
{/* V2 Reservation Flow */}
|
{/* V2 Reservation Flow */}
|
||||||
<Route path="/v2/reservation" element={<ReservationStep1Page />} />
|
<Route path="/v2/reservation" element={<ReservationStep1Page />} />
|
||||||
<Route path="/v2/reservation/payment" element={<ReservationStep2Page />} />
|
<Route path="/v2/reservation/payment" element={<ReservationStep2Page />} />
|
||||||
<Route path="/v2/reservation/success" element={<ReservationSuccessPage />} />
|
<Route path="/v2/reservation/success" element={<ReservationSuccessPage />} />
|
||||||
|
|
||||||
|
{/* V2.1 Reservation Flow */}
|
||||||
|
<Route path="/v2-1/reservation" element={<ReservationStep1PageV21 />} />
|
||||||
|
<Route path="/v2-1/reservation/payment" element={<ReservationStep2PageV21 />} />
|
||||||
|
<Route path="/v2-1/reservation/success" element={<ReservationSuccessPageV21 />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
} />
|
} />
|
||||||
|
|||||||
@ -28,16 +28,16 @@ async function request<T>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isRequiredAuth) {
|
|
||||||
const accessToken = localStorage.getItem('accessToken');
|
const accessToken = localStorage.getItem('accessToken');
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
if (!config.headers) {
|
if (!config.headers) {
|
||||||
config.headers = {};
|
config.headers = {};
|
||||||
}
|
|
||||||
config.headers['Authorization'] = `Bearer ${accessToken}`;
|
|
||||||
}
|
}
|
||||||
|
config.headers['Authorization'] = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (method.toUpperCase() !== 'GET') {
|
if (method.toUpperCase() !== 'GET') {
|
||||||
config.data = data;
|
config.data = data;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import apiClient from "@_api/apiClient";
|
import apiClient from '@_api/apiClient';
|
||||||
import type { ThemeCreateRequest, ThemeCreateResponse, ThemeRetrieveListResponse } from "./themeTypes";
|
import type {
|
||||||
|
AdminThemeDetailRetrieveResponse,
|
||||||
|
AdminThemeSummaryRetrieveListResponse,
|
||||||
|
ThemeCreateRequest,
|
||||||
|
ThemeCreateRequestV2, ThemeCreateResponse,
|
||||||
|
ThemeCreateResponseV2, ThemeRetrieveListResponse,
|
||||||
|
ThemeUpdateRequest,
|
||||||
|
UserThemeRetrieveListResponse
|
||||||
|
} from './themeTypes';
|
||||||
|
|
||||||
export const createTheme = async (data: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
|
export const createTheme = async (data: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
|
||||||
return await apiClient.post<ThemeCreateResponse>('/themes', data, true);
|
return await apiClient.post<ThemeCreateResponse>('/themes', data, true);
|
||||||
@ -16,3 +24,27 @@ export const mostReservedThemes = async (count: number = 10): Promise<ThemeRetri
|
|||||||
export const delTheme = async (id: string): Promise<void> => {
|
export const delTheme = async (id: string): Promise<void> => {
|
||||||
return await apiClient.del(`/themes/${id}`, true);
|
return await apiClient.del(`/themes/${id}`, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryRetrieveListResponse> => {
|
||||||
|
return await apiClient.get<AdminThemeSummaryRetrieveListResponse>('/admin/themes');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailRetrieveResponse> => {
|
||||||
|
return await apiClient.get<AdminThemeDetailRetrieveResponse>(`/admin/themes/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createThemeV2 = async (themeData: ThemeCreateRequestV2): Promise<ThemeCreateResponseV2> => {
|
||||||
|
return await apiClient.post<ThemeCreateResponseV2>('/admin/themes', themeData);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTheme = async (id: string, themeData: ThemeUpdateRequest): Promise<void> => {
|
||||||
|
await apiClient.patch<any>(`/admin/themes/${id}`, themeData);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTheme = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.del<any>(`/admin/themes/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchUserThemes = async (): Promise<UserThemeRetrieveListResponse> => {
|
||||||
|
return await apiClient.get<UserThemeRetrieveListResponse>('/v2/themes');
|
||||||
|
};
|
||||||
|
|||||||
@ -21,3 +21,113 @@ export interface ThemeRetrieveResponse {
|
|||||||
export interface ThemeRetrieveListResponse {
|
export interface ThemeRetrieveListResponse {
|
||||||
themes: ThemeRetrieveResponse[];
|
themes: ThemeRetrieveResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface ThemeV2 {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
price: number;
|
||||||
|
minParticipants: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
availableMinutes: number;
|
||||||
|
expectedMinutesFrom: number;
|
||||||
|
expectedMinutesTo: number;
|
||||||
|
isOpen: boolean;
|
||||||
|
createDate: string; // Assuming ISO string format
|
||||||
|
updatedDate: string; // Assuming ISO string format
|
||||||
|
createdBy: string;
|
||||||
|
updatedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeCreateRequestV2 {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
price: number;
|
||||||
|
minParticipants: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
availableMinutes: number;
|
||||||
|
expectedMinutesFrom: number;
|
||||||
|
expectedMinutesTo: number;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeCreateResponseV2 {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeUpdateRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
difficulty?: Difficulty;
|
||||||
|
price?: number;
|
||||||
|
minParticipants?: number;
|
||||||
|
maxParticipants?: number;
|
||||||
|
availableMinutes?: number;
|
||||||
|
expectedMinutesFrom?: number;
|
||||||
|
expectedMinutesTo?: number;
|
||||||
|
isOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminThemeSummaryRetrieveResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
price: number;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminThemeSummaryRetrieveListResponse {
|
||||||
|
themes: AdminThemeSummaryRetrieveResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminThemeDetailRetrieveResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
price: number;
|
||||||
|
minParticipants: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
availableMinutes: number;
|
||||||
|
expectedMinutesFrom: number;
|
||||||
|
expectedMinutesTo: number;
|
||||||
|
isOpen: boolean;
|
||||||
|
createdAt: string; // LocalDateTime in Kotlin, map to string (ISO format)
|
||||||
|
createdBy: string;
|
||||||
|
updatedAt: string; // LocalDateTime in Kotlin, map to string (ISO format)
|
||||||
|
updatedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserThemeRetrieveResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
description: string;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
price: number;
|
||||||
|
minParticipants: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
availableMinutes: number;
|
||||||
|
expectedMinutesFrom: number;
|
||||||
|
expectedMinutesTo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserThemeRetrieveListResponse {
|
||||||
|
themes: UserThemeRetrieveResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
export enum Difficulty {
|
||||||
|
VERY_EASY = 'VERY_EASY',
|
||||||
|
EASY = 'EASY',
|
||||||
|
NORMAL = 'NORMAL',
|
||||||
|
HARD = 'HARD',
|
||||||
|
VERY_HARD = 'VERY_HARD',
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from 'src/context/AuthContext';
|
import { useAuth } from 'src/context/AuthContext';
|
||||||
|
import 'src/css/navbar.css';
|
||||||
|
|
||||||
const Navbar: React.FC = () => {
|
const Navbar: React.FC = () => {
|
||||||
const { loggedIn, userName, logout } = useAuth();
|
const { loggedIn, userName, logout } = useAuth();
|
||||||
@ -14,39 +15,31 @@ const Navbar: React.FC = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout failed:', error);
|
console.error('Logout failed:', error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="navbar navbar-expand-lg navbar-light bg-light">
|
<nav className="navbar-container">
|
||||||
<Link className="navbar-brand" to="/">
|
<div className="nav-links">
|
||||||
<img src="/image/service-logo.png" alt="LOGO" style={{ height: '40px' }} />
|
<Link className="nav-link" to="/">홈</Link>
|
||||||
</Link>
|
<Link className="nav-link" to="/v2/reservation">예약하기</Link>
|
||||||
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
</div>
|
||||||
<span className="navbar-toggler-icon"></span>
|
<div className="nav-actions">
|
||||||
</button>
|
{!loggedIn ? (
|
||||||
<div className="collapse navbar-collapse" id="navbarSupportedContent">
|
<>
|
||||||
<ul className="navbar-nav ms-auto">
|
<button className="btn btn-secondary" onClick={() => navigate('/v2/login')}>로그인</button>
|
||||||
<li className="nav-item">
|
<button className="btn btn-primary" onClick={() => navigate('/v2/signup')}>회원가입</button>
|
||||||
<Link className="nav-link" to="/v2/reservation">Reservation</Link>
|
</>
|
||||||
</li>
|
) : (
|
||||||
{!loggedIn ? (
|
<div className="profile-info">
|
||||||
<li className="nav-item">
|
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
|
||||||
<Link className="nav-link" to="/login">Login</Link>
|
<span>{userName}</span>
|
||||||
</li>
|
<div className="dropdown-menu">
|
||||||
) : (
|
<Link className="dropdown-item" to="/my-reservation/v2">내 예약</Link>
|
||||||
<li className="nav-item dropdown">
|
<div className="dropdown-divider" />
|
||||||
<a className="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<a className="dropdown-item" href="#" onClick={handleLogout}>로그아웃</a>
|
||||||
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
|
</div>
|
||||||
<span id="profile-name">{userName}</span>
|
</div>
|
||||||
</a>
|
)}
|
||||||
<ul className="dropdown-menu" aria-labelledby="navbarDropdown">
|
|
||||||
<li><Link className="dropdown-item" to="/my-reservation/v2">My Reservation</Link></li>
|
|
||||||
<li><hr className="dropdown-divider" /></li>
|
|
||||||
<li><a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
17
frontend/src/css/admin-page.css
Normal file
17
frontend/src/css/admin-page.css
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/* /src/css/admin-page.css */
|
||||||
|
.admin-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 40px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: #f4f6f8;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-container .page-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333d4b;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
160
frontend/src/css/admin-reservation-page.css
Normal file
160
frontend/src/css/admin-reservation-page.css
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
/* /src/css/admin-reservation-page.css */
|
||||||
|
.admin-reservation-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 40px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: #f4f6f8;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-reservation-container .page-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333d4b;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-reservation-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reservations-main {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
width: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card .card-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333d4b;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container th,
|
||||||
|
.table-container td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e5e8eb;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container th {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
color: #505a67;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container tr:hover {
|
||||||
|
background-color: #f4f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4E5968;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
border: 1px solid #E5E8EB;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3182F6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #1B64DA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
color: #4E5968;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #E5E8EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #e53e3e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c53030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section .btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editing-row td {
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editing-row .btn {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
236
frontend/src/css/admin-theme-edit-page.css
Normal file
236
frontend/src/css/admin-theme-edit-page.css
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color: #007bff;
|
||||||
|
--secondary-color: #6c757d;
|
||||||
|
--danger-color: #dc3545;
|
||||||
|
--light-gray-color: #f8f9fa;
|
||||||
|
--dark-gray-color: #343a40;
|
||||||
|
--border-color: #dee2e6;
|
||||||
|
--input-bg-color: #fff;
|
||||||
|
--text-color: #212529;
|
||||||
|
--label-color: #495057;
|
||||||
|
--white-color: #ffffff;
|
||||||
|
--box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
--border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-theme-edit-container {
|
||||||
|
padding: 2rem 0;
|
||||||
|
background-color: var(--light-gray-color);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered-layout {
|
||||||
|
max-width: 1024px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.centered-layout {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--dark-gray-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
background-color: var(--white-color);
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--box-shadow);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group.full-width {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--label-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-textarea,
|
||||||
|
.form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--input-bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
height: 3rem; /* 48px */
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:disabled,
|
||||||
|
.form-textarea:disabled,
|
||||||
|
.form-select:disabled {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-textarea:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
height: auto;
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s, transform 0.1s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-actions .btn {
|
||||||
|
padding: 0.85rem 2.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: var(--white-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: var(--white-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: var(--white-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-info {
|
||||||
|
background-color: var(--white-color);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--box-shadow);
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark-gray-color);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-body p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--label-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-body p strong {
|
||||||
|
color: var(--dark-gray-color);
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-text {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--danger-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-text:hover {
|
||||||
|
color: #a71d2a;
|
||||||
|
}
|
||||||
121
frontend/src/css/admin-theme-page.css
Normal file
121
frontend/src/css/admin-theme-page.css
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
/* /src/css/admin-theme-page.css */
|
||||||
|
.admin-theme-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 40px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: #f4f6f8;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-theme-container .page-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333d4b;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container th,
|
||||||
|
.table-container td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e5e8eb;
|
||||||
|
vertical-align: middle;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container th {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
color: #505a67;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container tr:hover {
|
||||||
|
background-color: #f4f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
border: 1px solid #E5E8EB;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3182F6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #1B64DA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
color: #4E5968;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #E5E8EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #e53e3e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c53030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editing-row td {
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editing-row .btn {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
120
frontend/src/css/admin-time-page.css
Normal file
120
frontend/src/css/admin-time-page.css
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
/* /src/css/admin-time-page.css */
|
||||||
|
.admin-time-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 40px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: #f4f6f8;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-time-container .page-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333d4b;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container th,
|
||||||
|
.table-container td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e5e8eb;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container th {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
color: #505a67;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container tr:hover {
|
||||||
|
background-color: #f4f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
border: 1px solid #E5E8EB;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3182F6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #1B64DA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
color: #4E5968;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #E5E8EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #e53e3e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c53030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editing-row td {
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editing-row .btn {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
81
frontend/src/css/admin-waiting-page.css
Normal file
81
frontend/src/css/admin-waiting-page.css
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
/* /src/css/admin-waiting-page.css */
|
||||||
|
.admin-waiting-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 40px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: #f4f6f8;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-waiting-container .page-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333d4b;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container th,
|
||||||
|
.table-container td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e5e8eb;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container th {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
color: #505a67;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container tr:hover {
|
||||||
|
background-color: #f4f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #1B64DA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #e53e3e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c53030;
|
||||||
|
}
|
||||||
66
frontend/src/css/home-page-v2.css
Normal file
66
frontend/src/css/home-page-v2.css
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/* /src/css/home-page-v2.css */
|
||||||
|
.home-container-v2 {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: #f4f6f8;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-container-v2 .page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333d4b;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-ranking-list-v2 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-ranking-item-v2 {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-ranking-item-v2:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-ranking-item-v2 .thumbnail {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e8eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-ranking-item-v2 .theme-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-ranking-item-v2 .theme-name {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333d4b;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-ranking-item-v2 .theme-description {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #505a67;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
74
frontend/src/css/login-page-v2.css
Normal file
74
frontend/src/css/login-page-v2.css
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
/* /src/css/login-page-v2.css */
|
||||||
|
.login-container-v2 {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 80px auto;
|
||||||
|
padding: 40px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container-v2 .page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #191F28;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-v2 .form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-v2 .form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 1px solid #E5E8EB;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-v2 .form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3182F6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-v2 .button-group {
|
||||||
|
margin-top: 30px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-v2 .btn {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-v2 .btn-primary {
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-v2 .btn-primary:hover {
|
||||||
|
background-color: #1B64DA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-v2 .btn-secondary {
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
color: #4E5968;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-v2 .btn-secondary:hover {
|
||||||
|
background-color: #E5E8EB;
|
||||||
|
}
|
||||||
117
frontend/src/css/navbar.css
Normal file
117
frontend/src/css/navbar.css
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
/* /src/css/navbar.css */
|
||||||
|
.navbar-container {
|
||||||
|
background-color: #ffffff;
|
||||||
|
height: 60px;
|
||||||
|
padding: 0 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #e5e8eb;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .nav-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #4E5968;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .nav-link:hover {
|
||||||
|
color: #191F28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .nav-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .btn-primary {
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .btn-primary:hover {
|
||||||
|
background-color: #1B64DA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .btn-secondary {
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
color: #4E5968;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .btn-secondary:hover {
|
||||||
|
background-color: #E5E8EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .profile-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #333d4b;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
padding: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .profile-image {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .dropdown-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .profile-info:hover .dropdown-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .dropdown-item {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: #333d4b;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .dropdown-item:hover {
|
||||||
|
background-color: #f4f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container .dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 8px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #e5e8eb;
|
||||||
|
}
|
||||||
349
frontend/src/css/reservation-v2-1.css
Normal file
349
frontend/src/css/reservation-v2-1.css
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
/* General Container */
|
||||||
|
.reservation-v21-container {
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 40px auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07);
|
||||||
|
font-family: 'Toss Product Sans', sans-serif;
|
||||||
|
color: #333D4B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
color: #191F28;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step Sections */
|
||||||
|
.step-section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid #E5E8EB;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-section.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: #F9FAFB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-section h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #191F28;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date Selector */
|
||||||
|
.date-selector {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option:hover {
|
||||||
|
background-color: #E5E8EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option.active {
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #3182F6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option .day-of-week {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option .day {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme List */
|
||||||
|
.theme-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid #E5E8EB;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
background-color: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card.active {
|
||||||
|
border-color: #3182F6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-info {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-info h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-info p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6B7684;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-meta {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #4E5968;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-meta p {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
.theme-meta strong {
|
||||||
|
color: #333D4B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-detail-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
color: #4E5968;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-detail-button:hover {
|
||||||
|
background-color: #E5E8EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time Slots */
|
||||||
|
.time-slots {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slot {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slot:hover {
|
||||||
|
background-color: #E5E8EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slot.active {
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slot.disabled {
|
||||||
|
background-color: #F9FAFB;
|
||||||
|
color: #B0B8C1;
|
||||||
|
cursor: not-allowed;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-availability {
|
||||||
|
font-size: 12px;
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-times {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #8A94A2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Next Step Button */
|
||||||
|
.next-step-button-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-step-button {
|
||||||
|
padding: 14px 28px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-step-button:disabled {
|
||||||
|
background-color: #B0B8C1;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-step-button:hover:not(:disabled) {
|
||||||
|
background-color: #1B64DA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #8A94A2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-theme-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
color: #191F28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #E5E8EB;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section p {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #4E5968;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section p strong {
|
||||||
|
color: #333D4B;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button {
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions .cancel-button {
|
||||||
|
background-color: #E5E8EB;
|
||||||
|
color: #4E5968;
|
||||||
|
}
|
||||||
|
.modal-actions .cancel-button:hover {
|
||||||
|
background-color: #D1D6DB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions .confirm-button {
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.modal-actions .confirm-button:hover {
|
||||||
|
background-color: #1B64DA;
|
||||||
|
}
|
||||||
65
frontend/src/css/signup-page-v2.css
Normal file
65
frontend/src/css/signup-page-v2.css
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
/* /src/css/signup-page-v2.css */
|
||||||
|
.signup-container-v2 {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 80px auto;
|
||||||
|
padding: 40px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-container-v2 .page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #191F28;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-form-v2 .form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-form-v2 .form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4E5968;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-form-v2 .form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 1px solid #E5E8EB;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-form-v2 .form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3182F6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-form-v2 .btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #3182F6;
|
||||||
|
color: #ffffff;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-form-v2 .btn-primary:hover {
|
||||||
|
background-color: #1B64DA;
|
||||||
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import type { LoginRequest } from '@_api/auth/authTypes';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
const LoginPage: React.FC = () => {
|
const LoginPage: React.FC = () => {
|
||||||
@ -14,8 +13,7 @@ const LoginPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
try {
|
try {
|
||||||
const request: LoginRequest = { email, password };
|
await login({email, password});
|
||||||
await login(request);
|
|
||||||
|
|
||||||
alert('로그인에 성공했어요!');
|
alert('로그인에 성공했어요!');
|
||||||
navigate(from, { replace: true });
|
navigate(from, { replace: true });
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import '../../css/navbar.css';
|
||||||
|
|
||||||
const AdminNavbar: React.FC = () => {
|
const AdminNavbar: React.FC = () => {
|
||||||
const { loggedIn, userName, logout } = useAuth();
|
const { loggedIn, userName, logout } = useAuth();
|
||||||
@ -13,48 +14,30 @@ const AdminNavbar: React.FC = () => {
|
|||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Logout failed", error);
|
console.error("Logout failed", error);
|
||||||
// Handle logout error if needed
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="navbar navbar-expand-lg navbar-light bg-light">
|
<nav className="navbar-container">
|
||||||
<Link className="navbar-brand" to="/admin">
|
<div className="nav-links">
|
||||||
<img src="/image/admin-logo.png" alt="LOGO" style={{ height: '40px' }} />
|
<Link className="nav-link" to="/admin">홈</Link>
|
||||||
</Link>
|
<Link className="nav-link" to="/admin/reservation">예약</Link>
|
||||||
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
<Link className="nav-link" to="/admin/waiting">대기</Link>
|
||||||
<span className="navbar-toggler-icon"></span>
|
<Link className="nav-link" to="/admin/theme">테마</Link>
|
||||||
</button>
|
<Link className="nav-link" to="/admin/time">시간</Link>
|
||||||
<div className="collapse navbar-collapse" id="navbarSupportedContent">
|
</div>
|
||||||
<ul className="navbar-nav ms-auto">
|
<div className="nav-actions">
|
||||||
<li className="nav-item">
|
{!loggedIn ? (
|
||||||
<Link className="nav-link" to="/admin/reservation">Reservation</Link>
|
<button className="btn btn-primary" onClick={() => navigate('/v2/login')}>Login</button>
|
||||||
</li>
|
) : (
|
||||||
<li className="nav-item">
|
<div className="profile-info">
|
||||||
<Link className="nav-link" to="/admin/waiting">Waiting</Link>
|
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
|
||||||
</li>
|
<span>{userName || 'Profile'}</span>
|
||||||
<li className="nav-item">
|
<div className="dropdown-menu">
|
||||||
<Link className="nav-link" to="/admin/theme">Theme</Link>
|
<a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a>
|
||||||
</li>
|
</div>
|
||||||
<li className="nav-item">
|
</div>
|
||||||
<Link className="nav-link" to="/admin/time">Time</Link>
|
)}
|
||||||
</li>
|
|
||||||
{!loggedIn ? (
|
|
||||||
<li className="nav-item">
|
|
||||||
<Link className="nav-link" to="/login">Login</Link>
|
|
||||||
</li>
|
|
||||||
) : (
|
|
||||||
<li className="nav-item dropdown">
|
|
||||||
<a className="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
|
|
||||||
<span id="profile-name">{userName || 'Profile'}</span>
|
|
||||||
</a>
|
|
||||||
<ul className="dropdown-menu" aria-labelledby="navbarDropdown">
|
|
||||||
<li><a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import '../../css/admin-page.css';
|
||||||
|
|
||||||
const AdminPage: React.FC = () => {
|
const AdminPage: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className="content-container">
|
<div className="admin-container">
|
||||||
<h2 className="content-container-title">방탈출 어드민</h2>
|
<h2 className="page-title">방탈출 어드민</h2>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
266
frontend/src/pages/admin/AdminThemeEditPage.tsx
Normal file
266
frontend/src/pages/admin/AdminThemeEditPage.tsx
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
|
import {
|
||||||
|
createThemeV2,
|
||||||
|
deleteTheme,
|
||||||
|
fetchAdminThemeDetail,
|
||||||
|
updateTheme
|
||||||
|
} from '@_api/theme/themeAPI';
|
||||||
|
import { Difficulty, type ThemeCreateRequestV2, type ThemeUpdateRequest, type ThemeV2 } from '@_api/theme/themeTypes';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import '../../css/admin-theme-edit-page.css';
|
||||||
|
|
||||||
|
const AdminThemeEditPage: React.FC = () => {
|
||||||
|
const { themeId } = useParams<{ themeId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isNew = themeId === 'new';
|
||||||
|
|
||||||
|
const [theme, setTheme] = useState<ThemeV2 | ThemeCreateRequestV2 | null>(null);
|
||||||
|
const [originalTheme, setOriginalTheme] = useState<ThemeV2 | ThemeCreateRequestV2 | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isEditing, setIsEditing] = useState(isNew);
|
||||||
|
|
||||||
|
const handleError = (err: any) => {
|
||||||
|
if (isLoginRequiredError(err)) {
|
||||||
|
alert('로그인이 필요해요.');
|
||||||
|
navigate('/login', { state: { from: location } });
|
||||||
|
} else {
|
||||||
|
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||||
|
alert(message);
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew) {
|
||||||
|
const newTheme: ThemeCreateRequestV2 = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
thumbnailUrl: '',
|
||||||
|
difficulty: Difficulty.NORMAL,
|
||||||
|
price: 0,
|
||||||
|
minParticipants: 2,
|
||||||
|
maxParticipants: 4,
|
||||||
|
availableMinutes: 60,
|
||||||
|
expectedMinutesFrom: 50,
|
||||||
|
expectedMinutesTo: 70,
|
||||||
|
isOpen: true,
|
||||||
|
};
|
||||||
|
setTheme(newTheme);
|
||||||
|
setOriginalTheme(newTheme);
|
||||||
|
setIsLoading(false);
|
||||||
|
} else if (themeId) {
|
||||||
|
fetchAdminThemeDetail(themeId)
|
||||||
|
.then(data => {
|
||||||
|
// Map AdminThemeDetailRetrieveResponse to ThemeV2
|
||||||
|
const fetchedTheme: ThemeV2 = {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
thumbnailUrl: data.thumbnailUrl,
|
||||||
|
difficulty: data.difficulty,
|
||||||
|
price: data.price,
|
||||||
|
minParticipants: data.minParticipants,
|
||||||
|
maxParticipants: data.maxParticipants,
|
||||||
|
availableMinutes: data.availableMinutes,
|
||||||
|
expectedMinutesFrom: data.expectedMinutesFrom,
|
||||||
|
expectedMinutesTo: data.expectedMinutesTo,
|
||||||
|
isOpen: data.isOpen,
|
||||||
|
createDate: data.createdAt, // Map createdAt to createDate
|
||||||
|
updatedDate: data.updatedAt, // Map updatedAt to updatedDate
|
||||||
|
createdBy: data.createdBy,
|
||||||
|
updatedBy: data.updatedBy,
|
||||||
|
};
|
||||||
|
setTheme(fetchedTheme);
|
||||||
|
setOriginalTheme(fetchedTheme);
|
||||||
|
})
|
||||||
|
.catch(handleError)
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}
|
||||||
|
}, [themeId, isNew, navigate]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value, type } = e.target;
|
||||||
|
let processedValue: string | number | boolean = value;
|
||||||
|
|
||||||
|
if (name === 'isOpen') {
|
||||||
|
processedValue = value === 'true';
|
||||||
|
} else if (type === 'checkbox') {
|
||||||
|
processedValue = (e.target as HTMLInputElement).checked;
|
||||||
|
} else if (type === 'number') {
|
||||||
|
processedValue = value === '' ? '' : Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(prev => prev ? { ...prev, [name]: processedValue } : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
if (!isNew) {
|
||||||
|
setTheme(originalTheme);
|
||||||
|
setIsEditing(false);
|
||||||
|
} else {
|
||||||
|
navigate('/admin/theme');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
console.log('handleSubmit called');
|
||||||
|
e.preventDefault();
|
||||||
|
if (!theme) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
await createThemeV2(theme as ThemeCreateRequestV2);
|
||||||
|
alert('테마가 성공적으로 생성되었습니다.');
|
||||||
|
navigate(`/admin/theme`);
|
||||||
|
} else {
|
||||||
|
if (!themeId) {
|
||||||
|
throw new Error('themeId is undefined');
|
||||||
|
}
|
||||||
|
await updateTheme(themeId, theme as ThemeUpdateRequest);
|
||||||
|
alert('테마가 성공적으로 업데이트되었습니다.');
|
||||||
|
setOriginalTheme(theme);
|
||||||
|
setIsEditing(false);
|
||||||
|
navigate(`/admin/theme`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (isNew || !themeId) return;
|
||||||
|
if (window.confirm('정말로 이 테마를 삭제하시겠습니까?')) {
|
||||||
|
try {
|
||||||
|
await deleteTheme(themeId);
|
||||||
|
alert('테마가 삭제되었습니다.');
|
||||||
|
navigate('/admin/theme');
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="admin-theme-edit-container"><p>로딩 중...</p></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!theme) {
|
||||||
|
return <div className="admin-theme-edit-container"><p>테마 정보를 찾을 수 없습니다.</p></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-theme-edit-container">
|
||||||
|
<div className="centered-layout">
|
||||||
|
<header className="page-header">
|
||||||
|
<h2 className="page-title">{isNew ? '새 테마 추가' : '테마 정보 수정'}</h2>
|
||||||
|
</header>
|
||||||
|
<form onSubmit={handleSubmit} className="form-card">
|
||||||
|
<div className="form-section">
|
||||||
|
<div className="form-group full-width">
|
||||||
|
<label className="form-label" htmlFor="name">테마 이름</label>
|
||||||
|
<input id="name" name="name" type="text" className="form-input" value={theme.name} onChange={handleChange} required disabled={!isEditing} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group full-width">
|
||||||
|
<label className="form-label" htmlFor="description">설명</label>
|
||||||
|
<textarea id="description" name="description" className="form-textarea" value={theme.description} onChange={handleChange} required disabled={!isEditing} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group full-width">
|
||||||
|
<label className="form-label" htmlFor="thumbnailUrl">썸네일 URL</label>
|
||||||
|
<input id="thumbnailUrl" name="thumbnailUrl" type="text" className="form-input" value={theme.thumbnailUrl} onChange={handleChange} required disabled={!isEditing} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="difficulty">난이도</label>
|
||||||
|
<select id="difficulty" name="difficulty" className="form-select" value={theme.difficulty} onChange={handleChange} disabled={!isEditing}>
|
||||||
|
{Object.values(Difficulty).map(d => <option key={d} value={d}>{d}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="isOpen">공개 여부</label>
|
||||||
|
<select id="isOpen" name="isOpen" className="form-select" value={String(theme.isOpen)} onChange={handleChange} disabled={!isEditing}>
|
||||||
|
<option value="true">공개</option>
|
||||||
|
<option value="false">비공개</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="price">가격 (원)</label>
|
||||||
|
<input id="price" name="price" type="number" className="form-input" value={theme.price} onChange={handleChange} required disabled={!isEditing} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="availableMinutes">총 이용시간 (분)</label>
|
||||||
|
<input id="availableMinutes" name="availableMinutes" type="number" className="form-input" value={theme.availableMinutes} onChange={handleChange} required disabled={!isEditing} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="expectedMinutesFrom">최소 예상 시간 (분)</label>
|
||||||
|
<input id="expectedMinutesFrom" name="expectedMinutesFrom" type="number" className="form-input" value={theme.expectedMinutesFrom} onChange={handleChange} required disabled={!isEditing} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="expectedMinutesTo">최대 예상 시간 (분)</label>
|
||||||
|
<input id="expectedMinutesTo" name="expectedMinutesTo" type="number" className="form-input" value={theme.expectedMinutesTo} onChange={handleChange} required disabled={!isEditing} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="minParticipants">최소 인원 (명)</label>
|
||||||
|
<input id="minParticipants" name="minParticipants" type="number" className="form-input" value={theme.minParticipants} onChange={handleChange} required disabled={!isEditing} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="maxParticipants">최대 인원 (명)</label>
|
||||||
|
<input id="maxParticipants" name="maxParticipants" type="number" className="form-input" value={theme.maxParticipants} onChange={handleChange} required disabled={!isEditing} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="button-group">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="main-actions">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={handleCancelEdit}>취소</button>
|
||||||
|
<button type="submit" className="btn btn-primary">저장</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="main-actions">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => navigate('/admin/theme')}>목록</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={(e) => { e.preventDefault(); console.log('setIsEditing(true) called'); setIsEditing(true); }}>수정</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{!isNew && 'id' in theme && (
|
||||||
|
<div className="audit-info">
|
||||||
|
<h4 className="audit-title">감사 정보</h4>
|
||||||
|
<div className="audit-body">
|
||||||
|
<p><strong>생성일:</strong> {new Date(theme.createDate).toLocaleString()}</p>
|
||||||
|
<p><strong>수정일:</strong> {new Date(theme.updatedDate).toLocaleString()}</p>
|
||||||
|
<p><strong>생성자:</strong> {theme.createdBy}</p>
|
||||||
|
<p><strong>수정자:</strong> {theme.updatedBy}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isNew && !isEditing && (
|
||||||
|
<div className="delete-section">
|
||||||
|
<button className="btn-delete-text" onClick={handleDelete}>이 테마를 삭제합니다.</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminThemeEditPage;
|
||||||
@ -14,6 +14,7 @@ import type { MemberRetrieveResponse } from '@_api/member/memberTypes';
|
|||||||
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
|
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
|
||||||
import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
|
import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
|
||||||
import { isLoginRequiredError } from '@_api/apiClient';
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
|
import '../../css/admin-reservation-page.css';
|
||||||
|
|
||||||
const AdminReservationPage: React.FC = () => {
|
const AdminReservationPage: React.FC = () => {
|
||||||
const [reservations, setReservations] = useState<ReservationRetrieveResponse[]>([]);
|
const [reservations, setReservations] = useState<ReservationRetrieveResponse[]>([]);
|
||||||
@ -102,94 +103,97 @@ const AdminReservationPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-container">
|
<div className="admin-reservation-container">
|
||||||
<h2 className="content-container-title">방탈출 예약 페이지</h2>
|
<h2 className="page-title">예약 관리</h2>
|
||||||
<div className="d-flex">
|
<div className="admin-reservation-content">
|
||||||
<div className="table-container flex-grow-1 mr-3">
|
<div className="reservations-main section-card">
|
||||||
<div className="table-header d-flex justify-content-end">
|
<div className="table-header">
|
||||||
<button id="add-button" className="btn btn-custom mb-2" onClick={handleAddClick}>예약 추가</button>
|
<button className="btn btn-primary" onClick={handleAddClick}>예약 추가</button>
|
||||||
</div>
|
</div>
|
||||||
<table className="table">
|
<div className="table-container">
|
||||||
<thead>
|
<table>
|
||||||
<tr>
|
<thead>
|
||||||
<th>예약번호</th>
|
|
||||||
<th>예약자</th>
|
|
||||||
<th>테마</th>
|
|
||||||
<th>날짜</th>
|
|
||||||
<th>시간</th>
|
|
||||||
<th>결제 완료 여부</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{reservations.map(r => (
|
|
||||||
<tr key={r.id}>
|
|
||||||
<td>{r.id}</td>
|
|
||||||
<td>{r.member.name}</td>
|
|
||||||
<td>{r.theme.name}</td>
|
|
||||||
<td>{r.date}</td>
|
|
||||||
<td>{r.time.startAt}</td>
|
|
||||||
<td>{r.status === 'CONFIRMED' ? '결제 완료' : '결제 대기'}</td>
|
|
||||||
<td><button className="btn btn-danger" onClick={() => deleteReservation(r.id)}>삭제</button></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{isEditing && (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<th>예약번호</th>
|
||||||
<td>
|
<th>예약자</th>
|
||||||
<select className="form-control" onChange={e => setNewReservation({ ...newReservation, memberId: e.target.value })}>
|
<th>테마</th>
|
||||||
<option value="">멤버 선택</option>
|
<th>날짜</th>
|
||||||
{members.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
<th>시간</th>
|
||||||
</select>
|
<th>상태</th>
|
||||||
</td>
|
<th></th>
|
||||||
<td>
|
|
||||||
<select className="form-control" onChange={e => setNewReservation({ ...newReservation, themeId: e.target.value })}>
|
|
||||||
<option value="">테마 선택</option>
|
|
||||||
{themes.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td><input type="date" className="form-control" onChange={e => setNewReservation({ ...newReservation, date: e.target.value })} /></td>
|
|
||||||
<td>
|
|
||||||
<select className="form-control" onChange={e => setNewReservation({ ...newReservation, timeId: e.target.value })}>
|
|
||||||
<option value="">시간 선택</option>
|
|
||||||
{times.map(t => <option key={t.id} value={t.id}>{t.startAt}</option>)}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
<td>
|
|
||||||
<button className="btn btn-custom" onClick={handleSaveClick}>확인</button>
|
|
||||||
<button className="btn btn-secondary" onClick={handleCancelClick}>취소</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{reservations.map(r => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td>{r.id}</td>
|
||||||
|
<td>{r.member.name}</td>
|
||||||
|
<td>{r.theme.name}</td>
|
||||||
|
<td>{r.date}</td>
|
||||||
|
<td>{r.time.startAt}</td>
|
||||||
|
<td>{r.status === 'CONFIRMED' ? '결제 완료' : '결제 대기'}</td>
|
||||||
|
<td><button className="btn btn-danger" onClick={() => deleteReservation(r.id)}>삭제</button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{isEditing && (
|
||||||
|
<tr className="editing-row">
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<select className="form-select" onChange={e => setNewReservation({ ...newReservation, memberId: e.target.value })}>
|
||||||
|
<option value="">멤버 선택</option>
|
||||||
|
{members.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select className="form-select" onChange={e => setNewReservation({ ...newReservation, themeId: e.target.value })}>
|
||||||
|
<option value="">테마 선택</option>
|
||||||
|
{themes.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input type="date" className="form-input" onChange={e => setNewReservation({ ...newReservation, date: e.target.value })} /></td>
|
||||||
|
<td>
|
||||||
|
<select className="form-select" onChange={e => setNewReservation({ ...newReservation, timeId: e.target.value })}>
|
||||||
|
<option value="">시간 선택</option>
|
||||||
|
{times.map(t => <option key={t.id} value={t.id}>{t.startAt}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<button className="btn btn-primary" onClick={handleSaveClick}>확인</button>
|
||||||
|
<button className="btn btn-secondary" onClick={handleCancelClick}>취소</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="filter-section ml-3">
|
<div className="filter-section section-card">
|
||||||
|
<h3 className="card-title">검색 필터</h3>
|
||||||
<form id="filter-form" onSubmit={applyFilter}>
|
<form id="filter-form" onSubmit={applyFilter}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="member">예약자</label>
|
<label className="form-label" htmlFor="member">예약자</label>
|
||||||
<select id="member" name="memberId" className="form-control" onChange={handleFilterChange}>
|
<select id="member" name="memberId" className="form-select" onChange={handleFilterChange}>
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
{members.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
{members.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="theme">테마</label>
|
<label className="form-label" htmlFor="theme">테마</label>
|
||||||
<select id="theme" name="themeId" className="form-control" onChange={handleFilterChange}>
|
<select id="theme" name="themeId" className="form-select" onChange={handleFilterChange}>
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
{themes.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
{themes.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="date-from">From</label>
|
<label className="form-label" htmlFor="date-from">From</label>
|
||||||
<input type="date" id="date-from" name="dateFrom" className="form-control" onChange={handleFilterChange} />
|
<input type="date" id="date-from" name="dateFrom" className="form-input" onChange={handleFilterChange} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="date-to">To</label>
|
<label className="form-label" htmlFor="date-to">To</label>
|
||||||
<input type="date" id="date-to" name="dateTo" className="form-control" onChange={handleFilterChange} />
|
<input type="date" id="date-to" name="dateTo" className="form-input" onChange={handleFilterChange} />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn btn-primary float-end">적용</button>
|
<button type="submit" className="btn btn-primary">적용</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { createTheme, fetchThemes, delTheme } from '@_api/theme/themeAPI';
|
import { fetchAdminThemes } from '@_api/theme/themeAPI';
|
||||||
|
import type {AdminThemeSummaryRetrieveResponse} from '@_api/theme/themeTypes';
|
||||||
import { isLoginRequiredError } from '@_api/apiClient';
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
|
import '../../css/admin-theme-page.css';
|
||||||
|
|
||||||
const AdminThemePage: React.FC = () => {
|
const AdminThemePage: React.FC = () => {
|
||||||
const [themes, setThemes] = useState<any[]>([]);
|
const [themes, setThemes] = useState<AdminThemeSummaryRetrieveResponse[]>([]);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [newTheme, setNewTheme] = useState({ name: '', description: '', thumbnail: '' });
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@ -23,87 +23,58 @@ const AdminThemePage: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
await fetchThemes()
|
try {
|
||||||
.then(response => setThemes(response.themes))
|
const response = await fetchAdminThemes();
|
||||||
.catch(handleError);
|
setThemes(response.themes);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddClick = () => {
|
const handleAddClick = () => {
|
||||||
setIsEditing(true);
|
navigate('/admin/theme/edit/new');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelClick = () => {
|
const handleManageClick = (themeId: number) => {
|
||||||
setIsEditing(false);
|
navigate(`/admin/theme/edit/${themeId}`);
|
||||||
setNewTheme({ name: '', description: '', thumbnail: '' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveClick = async () => {
|
|
||||||
await createTheme(newTheme)
|
|
||||||
.then((response) => {
|
|
||||||
setThemes([...themes, response]);
|
|
||||||
alert('테마를 추가했어요.');
|
|
||||||
handleCancelClick();
|
|
||||||
})
|
|
||||||
.catch(handleError);
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteTheme = async (id: string) => {
|
|
||||||
if (!window.confirm('정말 삭제하시겠어요?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await delTheme(id)
|
|
||||||
.then(() => {
|
|
||||||
setThemes(themes.filter(theme => theme.id !== id));
|
|
||||||
alert('테마를 삭제했어요.');
|
|
||||||
})
|
|
||||||
.catch(handleError);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-container">
|
<div className="admin-theme-container">
|
||||||
<h2 className="content-container-title">테마 관리 페이지</h2>
|
<h2 className="page-title">테마 관리</h2>
|
||||||
<div className="table-header">
|
<div className="section-card">
|
||||||
<button id="add-button" className="btn btn-custom mb-2 float-end" onClick={handleAddClick}>테마 추가</button>
|
<div className="table-header">
|
||||||
|
<button className="btn btn-primary" onClick={handleAddClick}>테마 추가</button>
|
||||||
|
</div>
|
||||||
|
<div className="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>난이도</th>
|
||||||
|
<th>가격</th>
|
||||||
|
<th>공개여부</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{themes.map(theme => (
|
||||||
|
<tr key={theme.id}>
|
||||||
|
<td>{theme.name}</td>
|
||||||
|
<td>{theme.difficulty}</td>
|
||||||
|
<td>{theme.price.toLocaleString()}원</td>
|
||||||
|
<td>{theme.isOpen ? '공개' : '비공개'}</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn btn-secondary" onClick={() => handleManageClick(theme.id)}>관리</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-container" />
|
|
||||||
<table className="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">순서</th>
|
|
||||||
<th scope="col">제목</th>
|
|
||||||
<th scope="col">설명</th>
|
|
||||||
<th scope="col">썸네일 URL</th>
|
|
||||||
<th scope="col"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="table-body">
|
|
||||||
{themes.map(theme => (
|
|
||||||
<tr key={theme.id}>
|
|
||||||
<td>{theme.id}</td>
|
|
||||||
<td>{theme.name}</td>
|
|
||||||
<td>{theme.description}</td>
|
|
||||||
<td>{theme.thumbnail}</td>
|
|
||||||
<td>
|
|
||||||
<button className="btn btn-danger" onClick={() => deleteTheme(theme.id)}>삭제</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{isEditing && (
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td><input type="text" className="form-control" placeholder="이름" value={newTheme.name} onChange={e => setNewTheme({ ...newTheme, name: e.target.value })} /></td>
|
|
||||||
<td><input type="text" className="form-control" placeholder="설명" value={newTheme.description} onChange={e => setNewTheme({ ...newTheme, description: e.target.value })} /></td>
|
|
||||||
<td><input type="text" className="form-control" placeholder="썸네일 URL" value={newTheme.thumbnail} onChange={e => setNewTheme({ ...newTheme, thumbnail: e.target.value })} /></td>
|
|
||||||
<td>
|
|
||||||
<button className="btn btn-custom" onClick={handleSaveClick}>확인</button>
|
|
||||||
<button className="btn btn-secondary" onClick={handleCancelClick}>취소</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { TimeCreateRequest } from '@_api/time/timeTypes';
|
|||||||
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 { isLoginRequiredError } from '@_api/apiClient';
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
|
import '../../css/admin-time-page.css';
|
||||||
|
|
||||||
const AdminTimePage: React.FC = () => {
|
const AdminTimePage: React.FC = () => {
|
||||||
const [times, setTimes] = useState<any[]>([]);
|
const [times, setTimes] = useState<any[]>([]);
|
||||||
@ -76,42 +77,45 @@ const AdminTimePage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-container">
|
<div className="admin-time-container">
|
||||||
<h2 className="content-container-title">시간 관리 페이지</h2>
|
<h2 className="page-title">시간 관리</h2>
|
||||||
<div className="table-header">
|
<div className="section-card">
|
||||||
<button id="add-button" className="btn btn-custom mb-2 float-end" onClick={handleAddClick}>예약시간 추가</button>
|
<div className="table-header">
|
||||||
|
<button className="btn btn-primary" onClick={handleAddClick}>시간 추가</button>
|
||||||
|
</div>
|
||||||
|
<div className="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>시간</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{times.map(time => (
|
||||||
|
<tr key={time.id}>
|
||||||
|
<td>{time.id}</td>
|
||||||
|
<td>{time.startAt}</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn btn-danger" onClick={() => deleteTime(time.id)}>삭제</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{isEditing && (
|
||||||
|
<tr className="editing-row">
|
||||||
|
<td></td>
|
||||||
|
<td><input type="time" className="form-input" value={newTime} onChange={e => setNewTime(e.target.value)} /></td>
|
||||||
|
<td>
|
||||||
|
<button className="btn btn-primary" onClick={handleSaveClick}>확인</button>
|
||||||
|
<button className="btn btn-secondary" onClick={handleCancelClick}>취소</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-container" />
|
|
||||||
<table className="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">순서</th>
|
|
||||||
<th scope="col">시간</th>
|
|
||||||
<th scope="col"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="table-body">
|
|
||||||
{times.map(time => (
|
|
||||||
<tr key={time.id}>
|
|
||||||
<td>{time.id}</td>
|
|
||||||
<td>{time.startAt}</td>
|
|
||||||
<td>
|
|
||||||
<button className="btn btn-danger" onClick={() => deleteTime(time.id)}>삭제</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{isEditing && (
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td><input type="time" className="form-control" value={newTime} onChange={e => setNewTime(e.target.value)} /></td>
|
|
||||||
<td>
|
|
||||||
<button className="btn btn-custom" onClick={handleSaveClick}>확인</button>
|
|
||||||
<button className="btn btn-secondary" onClick={handleCancelClick}>취소</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { ReservationRetrieveResponse } from '@_api/reservation/reservationT
|
|||||||
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 { isLoginRequiredError } from '@_api/apiClient';
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
|
import '../../css/admin-waiting-page.css';
|
||||||
|
|
||||||
const AdminWaitingPage: React.FC = () => {
|
const AdminWaitingPage: React.FC = () => {
|
||||||
const [waitings, setWaitings] = useState<ReservationRetrieveResponse[]>([]);
|
const [waitings, setWaitings] = useState<ReservationRetrieveResponse[]>([]);
|
||||||
@ -48,36 +49,39 @@ const AdminWaitingPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-container">
|
<div className="admin-waiting-container">
|
||||||
<h2 className="content-container-title">예약 대기 관리 페이지</h2>
|
<h2 className="page-title">예약 대기 관리</h2>
|
||||||
<div className="table-container" />
|
<div className="section-card">
|
||||||
<table className="table">
|
<div className="table-container">
|
||||||
<thead>
|
<table>
|
||||||
<tr>
|
<thead>
|
||||||
<th scope="col">예약대기 번호</th>
|
<tr>
|
||||||
<th scope="col">예약자</th>
|
<th>예약대기 번호</th>
|
||||||
<th scope="col">테마</th>
|
<th>예약자</th>
|
||||||
<th scope="col">날짜</th>
|
<th>테마</th>
|
||||||
<th scope="col">시간</th>
|
<th>날짜</th>
|
||||||
<th scope="col"></th>
|
<th>시간</th>
|
||||||
</tr>
|
<th></th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody id="table-body">
|
</thead>
|
||||||
{waitings.map(w => (
|
<tbody>
|
||||||
<tr key={w.id}>
|
{waitings.map(w => (
|
||||||
<td>{w.id}</td>
|
<tr key={w.id}>
|
||||||
<td>{w.member.name}</td>
|
<td>{w.id}</td>
|
||||||
<td>{w.theme.name}</td>
|
<td>{w.member.name}</td>
|
||||||
<td>{w.date}</td>
|
<td>{w.theme.name}</td>
|
||||||
<td>{w.time.startAt}</td>
|
<td>{w.date}</td>
|
||||||
<td>
|
<td>{w.time.startAt}</td>
|
||||||
<button className="btn btn-primary mr-2" onClick={() => approveWaiting(w.id)}>승인</button>
|
<td>
|
||||||
<button className="btn btn-danger" onClick={() => denyWaiting(w.id)}>거절</button>
|
<button className="btn btn-primary" onClick={() => approveWaiting(w.id)}>승인</button>
|
||||||
</td>
|
<button className="btn btn-danger" onClick={() => denyWaiting(w.id)}>거절</button>
|
||||||
</tr>
|
</td>
|
||||||
))}
|
</tr>
|
||||||
</tbody>
|
))}
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
39
frontend/src/pages/v2/HomePageV2.tsx
Normal file
39
frontend/src/pages/v2/HomePageV2.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { mostReservedThemes } from '../../api/theme/themeAPI';
|
||||||
|
import '../../css/home-page-v2.css';
|
||||||
|
|
||||||
|
const HomePageV2: React.FC = () => {
|
||||||
|
const [ranking, setRanking] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await mostReservedThemes(10);
|
||||||
|
setRanking(response.themes);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching ranking:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="home-container-v2">
|
||||||
|
<h2 className="page-title">인기 테마</h2>
|
||||||
|
<div className="theme-ranking-list-v2">
|
||||||
|
{ranking.map(theme => (
|
||||||
|
<div key={theme.id} className="theme-ranking-item-v2">
|
||||||
|
<img className="thumbnail" src={theme.thumbnail} alt={theme.name} />
|
||||||
|
<div className="theme-info">
|
||||||
|
<h5 className="theme-name">{theme.name}</h5>
|
||||||
|
<p className="theme-description">{theme.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomePageV2;
|
||||||
63
frontend/src/pages/v2/LoginPageV2.tsx
Normal file
63
frontend/src/pages/v2/LoginPageV2.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import '../../css/login-page-v2.css';
|
||||||
|
|
||||||
|
const LoginPageV2: React.FC = () => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const from = location.state?.from?.pathname || '/';
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await login({email, password});
|
||||||
|
|
||||||
|
alert('로그인에 성공했어요!');
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.message || '로그인에 실패했어요. 이메일과 비밀번호를 확인해주세요.';
|
||||||
|
alert(message);
|
||||||
|
console.error('로그인 실패:', error);
|
||||||
|
setPassword('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-container-v2">
|
||||||
|
<h2 className="page-title">로그인</h2>
|
||||||
|
<form className="login-form-v2" onSubmit={handleLogin}>
|
||||||
|
<div className="form-group">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="이메일"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="비밀번호"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="button-group">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => navigate('/signup')}>회원가입</button>
|
||||||
|
<button type="submit" className="btn btn-primary">로그인</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPageV2;
|
||||||
247
frontend/src/pages/v2/ReservationStep1PageV21.tsx
Normal file
247
frontend/src/pages/v2/ReservationStep1PageV21.tsx
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
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 '@_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
|
||||||
|
name: string;
|
||||||
|
difficulty: string;
|
||||||
|
description: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
price: number;
|
||||||
|
minParticipants: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
expectedMinutesFrom: number;
|
||||||
|
expectedMinutesTo: number;
|
||||||
|
availableMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const ReservationStep1PageV21: React.FC = () => {
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
|
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 [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
||||||
|
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
||||||
|
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(() => {
|
||||||
|
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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDate && selectedTheme) {
|
||||||
|
const dateStr = selectedDate.toLocaleDateString('en-CA');
|
||||||
|
fetchTimesWithAvailability(dateStr, selectedTheme.id)
|
||||||
|
.then(res => {
|
||||||
|
setTimes(res.times);
|
||||||
|
setSelectedTime(null);
|
||||||
|
})
|
||||||
|
.catch(handleError);
|
||||||
|
}
|
||||||
|
}, [selectedDate, selectedTheme]);
|
||||||
|
|
||||||
|
const handleNextStep = () => {
|
||||||
|
if (!selectedDate || !selectedTheme || !selectedTime) {
|
||||||
|
alert('날짜, 테마, 시간을 모두 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedTime.isAvailable) {
|
||||||
|
alert('예약할 수 없는 시간입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsConfirmModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmPayment = () => {
|
||||||
|
if (!selectedDate || !selectedTheme || !selectedTime) return;
|
||||||
|
|
||||||
|
const reservationData = {
|
||||||
|
date: selectedDate.toLocaleDateString('en-CA'),
|
||||||
|
themeId: selectedTheme.id,
|
||||||
|
timeId: selectedTime.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
createPendingReservation(reservationData)
|
||||||
|
.then((res) => {
|
||||||
|
navigate('/v2/reservation/payment', { state: { reservation: res } });
|
||||||
|
})
|
||||||
|
.catch(handleError)
|
||||||
|
.finally(() => setIsModalOpen(false));
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDateOptions = () => {
|
||||||
|
const dates = [];
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(today.getDate() + i);
|
||||||
|
dates.push(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates.map(date => {
|
||||||
|
const isSelected = selectedDate.toDateString() === date.toDateString();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={date.toISOString()}
|
||||||
|
className={`date-option ${isSelected ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedDate(date)}
|
||||||
|
>
|
||||||
|
<div className="day-of-week">{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}</div>
|
||||||
|
<div className="day">{date.getDate()}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openThemeModal = (theme: ThemeV21) => {
|
||||||
|
setSelectedTheme(theme);
|
||||||
|
setIsThemeModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || !selectedTime.isAvailable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="reservation-v21-container">
|
||||||
|
<h2 className="page-title">예약하기</h2>
|
||||||
|
|
||||||
|
<div className="step-section">
|
||||||
|
<h3>1. 날짜 선택</h3>
|
||||||
|
<div className="date-selector">{renderDateOptions()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
|
||||||
|
<h3>2. 테마 선택</h3>
|
||||||
|
<div className="theme-list">
|
||||||
|
{themes.map(theme => (
|
||||||
|
<div
|
||||||
|
key={theme.id}
|
||||||
|
className={`theme-card ${selectedTheme?.id === theme.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedTheme(theme)}
|
||||||
|
>
|
||||||
|
<div className="theme-info">
|
||||||
|
<h4>{theme.name}</h4>
|
||||||
|
<div className="theme-meta">
|
||||||
|
<p><strong>난이도:</strong> {theme.difficulty}</p>
|
||||||
|
<p><strong>참여 인원:</strong> {theme.minParticipants} ~ {theme.maxParticipants}명</p>
|
||||||
|
<p><strong>가격:</strong> {theme.price.toLocaleString()}원</p>
|
||||||
|
<p><strong>예상 소요 시간:</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}분</p>
|
||||||
|
<p><strong>이용 가능 시간:</strong> {theme.availableMinutes}분</p>
|
||||||
|
</div>
|
||||||
|
<button className="theme-detail-button" onClick={(e) => { e.stopPropagation(); openThemeModal(theme); }}>상세보기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`step-section ${!selectedTheme ? 'disabled' : ''}`}>
|
||||||
|
<h3>3. 시간 선택</h3>
|
||||||
|
<div className="time-slots">
|
||||||
|
{times.length > 0 ? times.map(time => (
|
||||||
|
<div
|
||||||
|
key={time.id}
|
||||||
|
className={`time-slot ${selectedTime?.id === time.id ? 'active' : ''} ${!time.isAvailable ? 'disabled' : ''}`}
|
||||||
|
onClick={() => time.isAvailable && setSelectedTime(time)}
|
||||||
|
>
|
||||||
|
{time.startAt}
|
||||||
|
<span className="time-availability">{time.isAvailable ? '예약가능' : '예약불가'}</span>
|
||||||
|
</div>
|
||||||
|
)) : <div className="no-times">선택 가능한 시간이 없습니다.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="next-step-button-container">
|
||||||
|
<button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}>
|
||||||
|
결제하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isThemeModalOpen && selectedTheme && (
|
||||||
|
<div className="modal-overlay" onClick={() => setIsThemeModalOpen(false)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button className="modal-close-button" onClick={() => setIsThemeModalOpen(false)}>×</button>
|
||||||
|
<img src={selectedTheme.thumbnailUrl} alt={selectedTheme.name} className="modal-theme-thumbnail" />
|
||||||
|
<h2>{selectedTheme.name}</h2>
|
||||||
|
<div className="modal-section">
|
||||||
|
<h3>테마 정보</h3>
|
||||||
|
<p><strong>난이도:</strong> {selectedTheme.difficulty}</p>
|
||||||
|
<p><strong>참여 인원:</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</p>
|
||||||
|
<p><strong>소요 시간:</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</p>
|
||||||
|
<p><strong>가격:</strong> {selectedTheme.price.toLocaleString()}원</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-section">
|
||||||
|
<h3>소개</h3>
|
||||||
|
<p>{selectedTheme.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isConfirmModalOpen && (
|
||||||
|
<div className="modal-overlay" onClick={() => setIsConfirmModalOpen(false)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button className="modal-close-button" onClick={() => setIsConfirmModalOpen(false)}>×</button>
|
||||||
|
<h2>예약 정보를 확인해주세요</h2>
|
||||||
|
<div className="modal-section">
|
||||||
|
<p><strong>날짜:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
|
||||||
|
<p><strong>테마:</strong> {selectedTheme!!.name}</p>
|
||||||
|
<p><strong>시간:</strong> {formatTime(selectedTime!!.startAt)}</p>
|
||||||
|
<p><strong>결제금액:</strong> {selectedTheme!!.price.toLocaleString()}원</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
||||||
|
<button className="confirm-button" onClick={handleConfirmPayment}>결제하기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReservationStep1PageV21;
|
||||||
132
frontend/src/pages/v2/ReservationStep2PageV21.tsx
Normal file
132
frontend/src/pages/v2/ReservationStep2PageV21.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
|
import { confirmReservationPayment } from '@_api/reservation/reservationAPI';
|
||||||
|
import { PaymentType, type ReservationCreateResponse, type ReservationPaymentRequest } from '@_api/reservation/reservationTypes';
|
||||||
|
import '@_css/reservation-v2-1.css'; // Reuse the new CSS for consistency
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
PaymentWidget: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This component is designed to work with the state passed from ReservationStep1PageV21
|
||||||
|
const ReservationStep2PageV21: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const paymentWidgetRef = useRef<any>(null);
|
||||||
|
const paymentMethodsRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// The reservation object now contains the price
|
||||||
|
const reservation: ReservationCreateResponse & { price: number } | undefined = location.state?.reservation;
|
||||||
|
console.log(reservation)
|
||||||
|
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(() => {
|
||||||
|
if (!reservation) {
|
||||||
|
alert('잘못된 접근입니다.');
|
||||||
|
navigate('/v2-1/reservation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://js.tosspayments.com/v1/payment-widget';
|
||||||
|
script.async = true;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
const widgetClientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm";
|
||||||
|
const paymentWidget = window.PaymentWidget(widgetClientKey, window.PaymentWidget.ANONYMOUS);
|
||||||
|
paymentWidgetRef.current = paymentWidget;
|
||||||
|
|
||||||
|
const paymentMethods = paymentWidget.renderPaymentMethods(
|
||||||
|
"#payment-method",
|
||||||
|
{ value: reservation.price }, // Use the price from the reservation object
|
||||||
|
{ variantKey: "DEFAULT" }
|
||||||
|
);
|
||||||
|
paymentMethodsRef.current = paymentMethods;
|
||||||
|
};
|
||||||
|
}, [reservation, navigate]);
|
||||||
|
|
||||||
|
const handlePayment = () => {
|
||||||
|
if (!paymentWidgetRef.current || !reservation) {
|
||||||
|
alert('결제 위젯이 로드되지 않았거나 예약 정보가 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateRandomString = () =>
|
||||||
|
crypto.randomUUID().replace(/-/g, '');
|
||||||
|
|
||||||
|
paymentWidgetRef.current.requestPayment({
|
||||||
|
orderId: generateRandomString(),
|
||||||
|
orderName: `${reservation.themeName} 예약 결제`,
|
||||||
|
amount: reservation.price,
|
||||||
|
}).then((data: any) => {
|
||||||
|
const paymentData: ReservationPaymentRequest = {
|
||||||
|
paymentKey: data.paymentKey,
|
||||||
|
orderId: data.orderId,
|
||||||
|
amount: data.amount,
|
||||||
|
paymentType: data.paymentType || PaymentType.NORMAL,
|
||||||
|
};
|
||||||
|
confirmReservationPayment(reservation.reservationId, paymentData)
|
||||||
|
.then((res) => {
|
||||||
|
// Navigate to the new success page
|
||||||
|
navigate('/v2-1/reservation/success', {
|
||||||
|
state: {
|
||||||
|
reservation: res,
|
||||||
|
themeName: reservation.themeName,
|
||||||
|
date: reservation.date,
|
||||||
|
startAt: reservation.startAt,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(handleError);
|
||||||
|
}).catch((error: any) => {
|
||||||
|
console.error("Payment request error:", error);
|
||||||
|
alert("결제 요청 중 오류가 발생했습니다.");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!reservation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = formatDate(reservation.date)
|
||||||
|
const time = formatTime(reservation.startAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="reservation-v21-container">
|
||||||
|
<h2 className="page-title">결제하기</h2>
|
||||||
|
<div className="step-section">
|
||||||
|
<h3>결제 정보 확인</h3>
|
||||||
|
<p><strong>테마:</strong> {reservation.themeName}</p>
|
||||||
|
<p><strong>날짜:</strong> {date}</p>
|
||||||
|
<p><strong>시간:</strong> {time}</p>
|
||||||
|
<p><strong>금액:</strong> {reservation.price.toLocaleString()}원</p>
|
||||||
|
</div>
|
||||||
|
<div className="step-section">
|
||||||
|
<h3>결제 수단</h3>
|
||||||
|
<div id="payment-method" className="w-100"></div>
|
||||||
|
<div id="agreement" className="w-100"></div>
|
||||||
|
</div>
|
||||||
|
<div className="next-step-button-container">
|
||||||
|
<button onClick={handlePayment} className="next-step-button">
|
||||||
|
{reservation.price.toLocaleString()}원 결제하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReservationStep2PageV21;
|
||||||
48
frontend/src/pages/v2/ReservationSuccessPageV21.tsx
Normal file
48
frontend/src/pages/v2/ReservationSuccessPageV21.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useLocation, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import type { ReservationPaymentResponse } from '@_api/reservation/reservationTypes';
|
||||||
|
import '@_css/reservation-v2-1.css'; // Reuse the new CSS
|
||||||
|
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
|
||||||
|
|
||||||
|
const ReservationSuccessPageV21: React.FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { reservation, themeName, date, startAt } = (location.state as {
|
||||||
|
reservation: ReservationPaymentResponse;
|
||||||
|
themeName: string;
|
||||||
|
date: string;
|
||||||
|
startAt: string;
|
||||||
|
}) || {};
|
||||||
|
|
||||||
|
if (!reservation) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
navigate('/v2-1/reservation'); // Redirect to the new reservation page on error
|
||||||
|
}, [navigate]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const formattedDate = formatDate(date)
|
||||||
|
const formattedTime = formatTime(startAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="reservation-v21-container">
|
||||||
|
<div className="success-icon">✓</div>
|
||||||
|
<h2 className="page-title">예약이 확정되었습니다!</h2>
|
||||||
|
<div className="step-section">
|
||||||
|
<h3>최종 예약 정보</h3>
|
||||||
|
<p><strong>테마:</strong> {themeName}</p>
|
||||||
|
<p><strong>날짜:</strong> {formattedDate}</p>
|
||||||
|
<p><strong>시간:</strong> {formattedTime}</p>
|
||||||
|
</div>
|
||||||
|
<div className="success-page-actions">
|
||||||
|
<Link to="/my-reservation/v2" className="action-button">
|
||||||
|
내 예약 목록
|
||||||
|
</Link>
|
||||||
|
<Link to="/" className="action-button secondary">
|
||||||
|
메인으로 가기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReservationSuccessPageV21;
|
||||||
70
frontend/src/pages/v2/SignupPageV2.tsx
Normal file
70
frontend/src/pages/v2/SignupPageV2.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { signup } from '../../api/member/memberAPI';
|
||||||
|
import type { SignupRequest } from '../../api/member/memberTypes';
|
||||||
|
import '../../css/signup-page-v2.css';
|
||||||
|
|
||||||
|
const SignupPageV2: React.FC = () => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSignup = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const request: SignupRequest = { email, password, name };
|
||||||
|
try {
|
||||||
|
const response = await signup(request);
|
||||||
|
alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`);
|
||||||
|
navigate('/v2/login');
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.message || '회원가입에 실패했어요. 입력 정보를 확인해주세요.';
|
||||||
|
alert(message);
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="signup-container-v2">
|
||||||
|
<h2 className="page-title">회원가입</h2>
|
||||||
|
<form className="signup-form-v2" onSubmit={handleSignup}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">이메일</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="이메일을 입력하세요"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">비밀번호</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="비밀번호를 입력하세요"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">이름</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="이름을 입력하세요"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn-primary">가입하기</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignupPageV2;
|
||||||
35
frontend/src/util/DateTimeFormatter.ts
Normal file
35
frontend/src/util/DateTimeFormatter.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const reservationYear = date.getFullYear();
|
||||||
|
|
||||||
|
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
const dayOfWeek = days[date.getDay()];
|
||||||
|
const month = date.getMonth() + 1;
|
||||||
|
const day = date.getDate();
|
||||||
|
|
||||||
|
let datePart = '';
|
||||||
|
if (currentYear === reservationYear) {
|
||||||
|
datePart = `${month}월 ${day}일(${dayOfWeek})`;
|
||||||
|
} else {
|
||||||
|
datePart = `${reservationYear}년 ${month}월 ${day}일(${dayOfWeek})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return datePart;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatTime = (timeStr: string) => {
|
||||||
|
const [hourStr, minuteStr] = timeStr.split(':');
|
||||||
|
let hours = parseInt(hourStr, 10);
|
||||||
|
const minutes = parseInt(minuteStr, 10);
|
||||||
|
const ampm = hours >= 12 ? '오후' : '오전';
|
||||||
|
hours = hours % 12;
|
||||||
|
hours = hours ? hours : 12;
|
||||||
|
|
||||||
|
let timePart = `${ampm} ${hours}시`;
|
||||||
|
if (minutes !== 0) {
|
||||||
|
timePart += ` ${minutes}분`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timePart;
|
||||||
|
}
|
||||||
@ -3,9 +3,7 @@ package roomescape
|
|||||||
import org.springframework.boot.Banner
|
import org.springframework.boot.Banner
|
||||||
import org.springframework.boot.SpringApplication
|
import org.springframework.boot.SpringApplication
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
|
|
||||||
|
|
||||||
@EnableJpaAuditing
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
class RoomescapeApplication
|
class RoomescapeApplication
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import roomescape.member.infrastructure.persistence.MemberEntity
|
|||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
const val MDC_MEMBER_ID_KEY: String = "member_id"
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class AuthInterceptor(
|
class AuthInterceptor(
|
||||||
private val memberFinder: MemberFinder,
|
private val memberFinder: MemberFinder,
|
||||||
@ -42,7 +44,7 @@ class AuthInterceptor(
|
|||||||
throw AuthException(AuthErrorCode.ACCESS_DENIED)
|
throw AuthException(AuthErrorCode.ACCESS_DENIED)
|
||||||
}
|
}
|
||||||
|
|
||||||
MDC.put("member_id", "${member.id}")
|
MDC.put(MDC_MEMBER_ID_KEY, "${member.id}")
|
||||||
log.info { "[AuthInterceptor] 인증 완료. memberId=${member.id}, role=${member.role}" }
|
log.info { "[AuthInterceptor] 인증 완료. memberId=${member.id}, role=${member.role}" }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -51,7 +53,7 @@ class AuthInterceptor(
|
|||||||
try {
|
try {
|
||||||
val memberId = jwtHandler.getMemberIdFromToken(accessToken)
|
val memberId = jwtHandler.getMemberIdFromToken(accessToken)
|
||||||
return memberFinder.findById(memberId)
|
return memberFinder.findById(memberId)
|
||||||
.also { MDC.put("member_id", "$memberId") }
|
.also { MDC.put(MDC_MEMBER_ID_KEY, "$memberId") }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.info { "[AuthInterceptor] 회원 조회 실패. accessToken = $accessToken" }
|
log.info { "[AuthInterceptor] 회원 조회 실패. accessToken = $accessToken" }
|
||||||
val errorCode = AuthErrorCode.MEMBER_NOT_FOUND
|
val errorCode = AuthErrorCode.MEMBER_NOT_FOUND
|
||||||
|
|||||||
29
src/main/kotlin/roomescape/common/config/JpaConfig.kt
Normal file
29
src/main/kotlin/roomescape/common/config/JpaConfig.kt
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package roomescape.common.config
|
||||||
|
|
||||||
|
import org.slf4j.MDC
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import org.springframework.data.domain.AuditorAware
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
|
||||||
|
import roomescape.auth.web.support.MDC_MEMBER_ID_KEY
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableJpaAuditing
|
||||||
|
class JpaConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun auditorAware(): AuditorAware<Long> = MdcAuditorAware()
|
||||||
|
}
|
||||||
|
|
||||||
|
class MdcAuditorAware : AuditorAware<Long> {
|
||||||
|
override fun getCurrentAuditor(): Optional<Long> {
|
||||||
|
val memberIdStr: String? = MDC.get(MDC_MEMBER_ID_KEY)
|
||||||
|
|
||||||
|
if (memberIdStr == null) {
|
||||||
|
return Optional.empty()
|
||||||
|
} else {
|
||||||
|
return Optional.of(memberIdStr.toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/main/kotlin/roomescape/common/entity/BaseEntityV2.kt
Normal file
55
src/main/kotlin/roomescape/common/entity/BaseEntityV2.kt
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package roomescape.common.entity
|
||||||
|
|
||||||
|
import jakarta.persistence.Column
|
||||||
|
import jakarta.persistence.EntityListeners
|
||||||
|
import jakarta.persistence.Id
|
||||||
|
import jakarta.persistence.MappedSuperclass
|
||||||
|
import jakarta.persistence.PostLoad
|
||||||
|
import jakarta.persistence.PrePersist
|
||||||
|
import org.springframework.data.annotation.CreatedBy
|
||||||
|
import org.springframework.data.annotation.CreatedDate
|
||||||
|
import org.springframework.data.annotation.LastModifiedBy
|
||||||
|
import org.springframework.data.annotation.LastModifiedDate
|
||||||
|
import org.springframework.data.domain.Persistable
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@MappedSuperclass
|
||||||
|
@EntityListeners(AuditingEntityListener::class)
|
||||||
|
abstract class AuditingBaseEntity(
|
||||||
|
@Id
|
||||||
|
@Column(name = "id")
|
||||||
|
private val _id: Long,
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private var isNewEntity: Boolean = true
|
||||||
|
) : Persistable<Long> {
|
||||||
|
@Column(updatable = false)
|
||||||
|
@CreatedDate
|
||||||
|
lateinit var createdAt: LocalDateTime
|
||||||
|
protected set
|
||||||
|
|
||||||
|
@Column(updatable = false)
|
||||||
|
@CreatedBy
|
||||||
|
var createdBy: Long = 0L
|
||||||
|
protected set
|
||||||
|
|
||||||
|
@Column
|
||||||
|
@LastModifiedDate
|
||||||
|
lateinit var updatedAt: LocalDateTime
|
||||||
|
protected set
|
||||||
|
|
||||||
|
@Column
|
||||||
|
@LastModifiedBy
|
||||||
|
var updatedBy: Long = 0L
|
||||||
|
protected set
|
||||||
|
|
||||||
|
@PostLoad
|
||||||
|
@PrePersist
|
||||||
|
fun markNotNew() {
|
||||||
|
isNewEntity = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getId(): Long = _id
|
||||||
|
override fun isNew(): Boolean = isNewEntity
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package roomescape.common.log
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import org.slf4j.MDC
|
import org.slf4j.MDC
|
||||||
|
import roomescape.auth.web.support.MDC_MEMBER_ID_KEY
|
||||||
|
|
||||||
enum class LogType {
|
enum class LogType {
|
||||||
INCOMING_HTTP_REQUEST,
|
INCOMING_HTTP_REQUEST,
|
||||||
@ -33,7 +34,7 @@ class ApiLogMessageConverter(
|
|||||||
controllerPayload: Map<String, Any>,
|
controllerPayload: Map<String, Any>,
|
||||||
): String {
|
): String {
|
||||||
val payload: MutableMap<String, Any> = commonRequestPayload(LogType.CONTROLLER_INVOKED, request)
|
val payload: MutableMap<String, Any> = commonRequestPayload(LogType.CONTROLLER_INVOKED, request)
|
||||||
val memberId: Long? = MDC.get("member_id")?.toLong()
|
val memberId: Long? = MDC.get(MDC_MEMBER_ID_KEY)?.toLong()
|
||||||
if (memberId != null) payload["member_id"] = memberId else payload["member_id"] = "NONE"
|
if (memberId != null) payload["member_id"] = memberId else payload["member_id"] = "NONE"
|
||||||
|
|
||||||
payload.putAll(controllerPayload)
|
payload.putAll(controllerPayload)
|
||||||
@ -46,7 +47,7 @@ class ApiLogMessageConverter(
|
|||||||
payload["type"] = request.type
|
payload["type"] = request.type
|
||||||
payload["status_code"] = request.httpStatus
|
payload["status_code"] = request.httpStatus
|
||||||
|
|
||||||
MDC.get("member_id")?.toLongOrNull()
|
MDC.get(MDC_MEMBER_ID_KEY)?.toLongOrNull()
|
||||||
?.let { payload["member_id"] = it }
|
?.let { payload["member_id"] = it }
|
||||||
?: run { payload["member_id"] = "NONE" }
|
?: run { payload["member_id"] = "NONE" }
|
||||||
|
|
||||||
|
|||||||
117
src/main/kotlin/roomescape/theme/business/ThemeServiceV2.kt
Normal file
117
src/main/kotlin/roomescape/theme/business/ThemeServiceV2.kt
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
package roomescape.theme.business
|
||||||
|
|
||||||
|
import com.github.f4b6a3.tsid.TsidFactory
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import roomescape.common.config.next
|
||||||
|
import roomescape.member.business.MemberService
|
||||||
|
import roomescape.theme.exception.ThemeErrorCode
|
||||||
|
import roomescape.theme.exception.ThemeException
|
||||||
|
import roomescape.theme.infrastructure.persistence.v2.ThemeEntityV2
|
||||||
|
import roomescape.theme.infrastructure.persistence.v2.ThemeRepositoryV2
|
||||||
|
import roomescape.theme.web.*
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class ThemeServiceV2(
|
||||||
|
private val themeRepository: ThemeRepositoryV2,
|
||||||
|
private val tsidFactory: TsidFactory,
|
||||||
|
private val memberService: MemberService,
|
||||||
|
private val themeValidator: ThemeValidatorV2
|
||||||
|
) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findThemesForReservation(): ThemeRetrieveListResponseV2 {
|
||||||
|
log.info { "[ThemeService.findThemesForReservation] 예약 페이지에서의 테마 목록 조회 시작" }
|
||||||
|
|
||||||
|
return themeRepository.findOpenedThemes()
|
||||||
|
.toRetrieveListResponse()
|
||||||
|
.also { log.info { "[ThemeService.findThemesForReservation] ${it.themes.size}개 테마 조회 완료" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findAdminThemes(): AdminThemeSummaryRetrieveListResponse {
|
||||||
|
log.info { "[ThemeService.findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
|
||||||
|
|
||||||
|
return themeRepository.findAll()
|
||||||
|
.toAdminThemeSummaryListResponse()
|
||||||
|
.also { log.info { "[ThemeService.findAdminThemes] ${it.themes.size}개 테마 조회 완료" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findAdminThemeDetail(id: Long): AdminThemeDetailRetrieveResponse {
|
||||||
|
log.info { "[ThemeService.findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작" }
|
||||||
|
|
||||||
|
val theme = themeRepository.findByIdOrNull(id)
|
||||||
|
?: run {
|
||||||
|
log.warn { "[ThemeService.findAdminThemeDetail] 테마 조회 실패. id=$id" }
|
||||||
|
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
val createdBy = memberService.findById(theme.createdBy).name
|
||||||
|
val updatedBy = memberService.findById(theme.updatedBy).name
|
||||||
|
|
||||||
|
return theme.toAdminThemeDetailResponse(createdBy, updatedBy)
|
||||||
|
.also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료. id=$id, name=${theme.name}" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun createTheme(request: ThemeCreateRequestV2): ThemeCreateResponseV2 {
|
||||||
|
log.info { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" }
|
||||||
|
|
||||||
|
themeValidator.validateCanCreate(request)
|
||||||
|
|
||||||
|
val theme: ThemeEntityV2 = themeRepository.save(
|
||||||
|
request.toEntity(tsidFactory.next())
|
||||||
|
)
|
||||||
|
|
||||||
|
return ThemeCreateResponseV2(theme.id).also {
|
||||||
|
log.info { "[ThemeService.createTheme] 테마 생성 완료. id=${theme.id}, name=${theme.name}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun deleteTheme(id: Long) {
|
||||||
|
log.info { "[ThemeService.deleteTheme] 테마 삭제 시작" }
|
||||||
|
|
||||||
|
val theme = themeRepository.findByIdOrNull(id)
|
||||||
|
?: run {
|
||||||
|
log.warn { "[ThemeService.deleteTheme] 테마 조회 실패. id=$id" }
|
||||||
|
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
themeRepository.delete(theme).also {
|
||||||
|
log.info { "[ThemeService.deleteTheme] 테마 삭제 완료. id=$id, name=${theme.name}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateTheme(id: Long, request: ThemeUpdateRequest) {
|
||||||
|
log.info { "[ThemeService.updateTheme] 테마 수정 시작" }
|
||||||
|
|
||||||
|
themeValidator.validateCanUpdate(request)
|
||||||
|
|
||||||
|
val theme: ThemeEntityV2 = themeRepository.findByIdOrNull(id)
|
||||||
|
?: run {
|
||||||
|
log.warn { "[ThemeService.updateTheme] 테마 조회 실패. id=$id" }
|
||||||
|
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
theme.modifyIfNotNull(
|
||||||
|
request.name,
|
||||||
|
request.description,
|
||||||
|
request.thumbnailUrl,
|
||||||
|
request.difficulty,
|
||||||
|
request.price,
|
||||||
|
request.minParticipants,
|
||||||
|
request.maxParticipants,
|
||||||
|
request.availableMinutes,
|
||||||
|
request.expectedMinutesFrom,
|
||||||
|
request.expectedMinutesTo,
|
||||||
|
request.isOpen,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/main/kotlin/roomescape/theme/business/ThemeValidatorV2.kt
Normal file
114
src/main/kotlin/roomescape/theme/business/ThemeValidatorV2.kt
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
package roomescape.theme.business
|
||||||
|
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import roomescape.theme.exception.ThemeErrorCode
|
||||||
|
import roomescape.theme.exception.ThemeException
|
||||||
|
import roomescape.theme.infrastructure.persistence.v2.ThemeRepositoryV2
|
||||||
|
import roomescape.theme.web.ThemeCreateRequestV2
|
||||||
|
import roomescape.theme.web.ThemeUpdateRequest
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
const val MIN_PRICE = 0
|
||||||
|
const val MIN_PARTICIPANTS = 1
|
||||||
|
const val MIN_DURATION = 1
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class ThemeValidatorV2(
|
||||||
|
private val themeRepository: ThemeRepositoryV2,
|
||||||
|
) {
|
||||||
|
fun validateCanUpdate(request: ThemeUpdateRequest) {
|
||||||
|
validateProperties(
|
||||||
|
request.price,
|
||||||
|
request.availableMinutes,
|
||||||
|
request.expectedMinutesFrom,
|
||||||
|
request.expectedMinutesTo,
|
||||||
|
request.minParticipants,
|
||||||
|
request.maxParticipants
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateCanCreate(request: ThemeCreateRequestV2) {
|
||||||
|
if (themeRepository.existsByName(request.name)) {
|
||||||
|
log.info { "[ThemeValidator.validateCanCreate] 이름 중복: name=${request.name}" }
|
||||||
|
throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
validateProperties(
|
||||||
|
request.price,
|
||||||
|
request.availableMinutes,
|
||||||
|
request.expectedMinutesFrom,
|
||||||
|
request.expectedMinutesTo,
|
||||||
|
request.minParticipants,
|
||||||
|
request.maxParticipants
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateProperties(
|
||||||
|
price: Int?,
|
||||||
|
availableMinutes: Short?,
|
||||||
|
expectedMinutesFrom: Short?,
|
||||||
|
expectedMinutesTo: Short?,
|
||||||
|
minParticipants: Short?,
|
||||||
|
maxParticipants: Short?,
|
||||||
|
) {
|
||||||
|
if (isNotNullAndBelowThan(price, MIN_PRICE)) {
|
||||||
|
log.info { "[ThemeValidator.validateCanCreate] 최소 가격 미달: price=${price}" }
|
||||||
|
throw ThemeException(ThemeErrorCode.PRICE_BELOW_MINIMUM)
|
||||||
|
}
|
||||||
|
validateTimes(availableMinutes, expectedMinutesFrom, expectedMinutesTo)
|
||||||
|
validateParticipants(minParticipants, maxParticipants)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateTimes(
|
||||||
|
availableMinutes: Short?,
|
||||||
|
expectedMinutesFrom: Short?,
|
||||||
|
expectedMinutesTo: Short?
|
||||||
|
) {
|
||||||
|
if (isNotNullAndBelowThan(availableMinutes, MIN_DURATION)
|
||||||
|
|| isNotNullAndBelowThan(expectedMinutesFrom, MIN_DURATION)
|
||||||
|
|| isNotNullAndBelowThan(expectedMinutesTo, MIN_DURATION)
|
||||||
|
) {
|
||||||
|
log.info {
|
||||||
|
"[ThemeValidator.validateTimes] 최소 시간 미달: availableMinutes=$availableMinutes" +
|
||||||
|
", expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo"
|
||||||
|
}
|
||||||
|
throw ThemeException(ThemeErrorCode.DURATION_BELOW_MINIMUM)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedMinutesFrom.isNotNullAndGraterThan(expectedMinutesTo)) {
|
||||||
|
log.info { "[ThemeValidator.validateTimes] 최소 예상 시간의 최대 예상 시간 초과: expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" }
|
||||||
|
throw ThemeException(ThemeErrorCode.MIN_EXPECTED_TIME_EXCEEDS_MAX_EXPECTED_TIME)
|
||||||
|
}
|
||||||
|
if (expectedMinutesTo.isNotNullAndGraterThan(availableMinutes)) {
|
||||||
|
log.info { "[ThemeValidator.validateTimes] 예상 시간의 이용 가능 시간 초과: availableMinutes=$expectedMinutesFrom, expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" }
|
||||||
|
throw ThemeException(ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateParticipants(
|
||||||
|
minParticipants: Short?,
|
||||||
|
maxParticipants: Short?
|
||||||
|
) {
|
||||||
|
if (isNotNullAndBelowThan(minParticipants, MIN_PARTICIPANTS)
|
||||||
|
|| isNotNullAndBelowThan(maxParticipants, MIN_PARTICIPANTS)
|
||||||
|
) {
|
||||||
|
log.info { "[ThemeValidator.validateParticipants] 최소 인원 미달: minParticipants=$minParticipants, maxParticipants=$maxParticipants" }
|
||||||
|
throw ThemeException(ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM)
|
||||||
|
}
|
||||||
|
if (minParticipants.isNotNullAndGraterThan(maxParticipants)) {
|
||||||
|
log.info { "[ThemeValidator.validateParticipants] 최소 인원의 최대 인원 초과: minParticipants=$minParticipants, maxParticipants=$maxParticipants" }
|
||||||
|
throw ThemeException(ThemeErrorCode.MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isNotNullAndBelowThan(value: Number?, threshold: Int): Boolean {
|
||||||
|
return value != null && value.toInt() < threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Number?.isNotNullAndGraterThan(value: Number?): Boolean {
|
||||||
|
return this != null && value != null && (this.toInt() > value.toInt())
|
||||||
|
}
|
||||||
56
src/main/kotlin/roomescape/theme/docs/ThemeApiV2.kt
Normal file
56
src/main/kotlin/roomescape/theme/docs/ThemeApiV2.kt
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package roomescape.theme.docs
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import roomescape.auth.web.support.Admin
|
||||||
|
import roomescape.auth.web.support.LoginRequired
|
||||||
|
import roomescape.common.dto.response.CommonApiResponse
|
||||||
|
import roomescape.theme.web.AdminThemeDetailRetrieveResponse
|
||||||
|
import roomescape.theme.web.AdminThemeSummaryRetrieveListResponse
|
||||||
|
import roomescape.theme.web.ThemeCreateRequestV2
|
||||||
|
import roomescape.theme.web.ThemeCreateResponseV2
|
||||||
|
import roomescape.theme.web.ThemeUpdateRequest
|
||||||
|
import roomescape.theme.web.ThemeRetrieveListResponseV2
|
||||||
|
|
||||||
|
@Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.")
|
||||||
|
interface ThemeAPIV2 {
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@Operation(summary = "모든 테마 조회", description = "관리자 페이지에서 요약된 테마 목록을 조회합니다.", tags = ["관리자 로그인이 필요한 API"])
|
||||||
|
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||||
|
fun findAdminThemes(): ResponseEntity<CommonApiResponse<AdminThemeSummaryRetrieveListResponse>>
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@Operation(summary = "테마 상세 조회", description = "해당 테마의 상세 정보를 조회합니다.", tags = ["관리자 로그인이 필요한 API"])
|
||||||
|
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||||
|
fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity<CommonApiResponse<AdminThemeDetailRetrieveResponse>>
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"])
|
||||||
|
@ApiResponses(ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true))
|
||||||
|
fun createTheme(@Valid @RequestBody themeCreateRequestV2: ThemeCreateRequestV2): ResponseEntity<CommonApiResponse<ThemeCreateResponseV2>>
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"])
|
||||||
|
@ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true))
|
||||||
|
fun deleteTheme(@PathVariable id: Long): ResponseEntity<CommonApiResponse<Unit>>
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@Operation(summary = "테마 수정", tags = ["관리자 로그인이 필요한 API"])
|
||||||
|
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||||
|
fun updateTheme(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
@Valid @RequestBody themeUpdateRequest: ThemeUpdateRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<Unit>>
|
||||||
|
|
||||||
|
@LoginRequired
|
||||||
|
@Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
|
||||||
|
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||||
|
fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>>
|
||||||
|
}
|
||||||
@ -12,4 +12,10 @@ enum class ThemeErrorCode(
|
|||||||
THEME_NAME_DUPLICATED(HttpStatus.BAD_REQUEST, "TH002", "이미 같은 이름의 테마가 있어요."),
|
THEME_NAME_DUPLICATED(HttpStatus.BAD_REQUEST, "TH002", "이미 같은 이름의 테마가 있어요."),
|
||||||
THEME_ALREADY_RESERVED(HttpStatus.CONFLICT, "TH003", "예약된 테마라 삭제할 수 없어요."),
|
THEME_ALREADY_RESERVED(HttpStatus.CONFLICT, "TH003", "예약된 테마라 삭제할 수 없어요."),
|
||||||
INVALID_REQUEST_VALUE(HttpStatus.BAD_REQUEST, "TH004", "입력 값이 잘못되었어요."),
|
INVALID_REQUEST_VALUE(HttpStatus.BAD_REQUEST, "TH004", "입력 값이 잘못되었어요."),
|
||||||
|
PRICE_BELOW_MINIMUM(HttpStatus.BAD_REQUEST, "TH005", "테마 가격은 0원보다 커야 해요."),
|
||||||
|
PARTICIPANT_BELOW_MINIMUM(HttpStatus.BAD_REQUEST, "TH006", "이용 가능 인원은 1명 이상이어야 해요."),
|
||||||
|
DURATION_BELOW_MINIMUM(HttpStatus.BAD_REQUEST, "TH007", "소요 시간은 1분 이상이어야 해요."),
|
||||||
|
MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT(HttpStatus.BAD_REQUEST, "TH008", "최소 인원은 최대 인원보다 작아야 해요."),
|
||||||
|
MIN_EXPECTED_TIME_EXCEEDS_MAX_EXPECTED_TIME(HttpStatus.BAD_REQUEST, "TH009", "최소 예상 시간은 최대 예상 시간보다 짧아야 해요."),
|
||||||
|
EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME(HttpStatus.BAD_REQUEST, "TH010", "예상 시간은 이용 시간보다 짧아야 해요."),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
package roomescape.theme.infrastructure.persistence.v2
|
||||||
|
|
||||||
|
import jakarta.persistence.Column
|
||||||
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.EnumType
|
||||||
|
import jakarta.persistence.Enumerated
|
||||||
|
import jakarta.persistence.Table
|
||||||
|
import roomescape.common.entity.AuditingBaseEntity
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "theme")
|
||||||
|
class ThemeEntityV2(
|
||||||
|
id: Long,
|
||||||
|
|
||||||
|
var name: String,
|
||||||
|
var description: String,
|
||||||
|
var thumbnailUrl: String,
|
||||||
|
|
||||||
|
@Enumerated(value = EnumType.STRING)
|
||||||
|
var difficulty: Difficulty,
|
||||||
|
|
||||||
|
var price: Int,
|
||||||
|
var minParticipants: Short,
|
||||||
|
var maxParticipants: Short,
|
||||||
|
var availableMinutes: Short,
|
||||||
|
var expectedMinutesFrom: Short,
|
||||||
|
var expectedMinutesTo: Short,
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TINYINT", length = 1)
|
||||||
|
var isOpen: Boolean
|
||||||
|
) : AuditingBaseEntity(id) {
|
||||||
|
|
||||||
|
fun modifyIfNotNull(
|
||||||
|
name: String?,
|
||||||
|
description: String?,
|
||||||
|
thumbnailUrl: String?,
|
||||||
|
difficulty: Difficulty?,
|
||||||
|
price: Int?,
|
||||||
|
minParticipants: Short?,
|
||||||
|
maxParticipants: Short?,
|
||||||
|
availableMinutes: Short?,
|
||||||
|
expectedMinutesFrom: Short?,
|
||||||
|
expectedMinutesTo: Short?,
|
||||||
|
isOpen: Boolean?
|
||||||
|
) {
|
||||||
|
name?.let { this.name = it }
|
||||||
|
description?.let { this.description = it }
|
||||||
|
thumbnailUrl?.let { this.thumbnailUrl = it }
|
||||||
|
difficulty?.let { this.difficulty = it }
|
||||||
|
price?.let { this.price = it }
|
||||||
|
minParticipants?.let { this.minParticipants = it }
|
||||||
|
maxParticipants?.let { this.maxParticipants = it }
|
||||||
|
availableMinutes?.let { this.availableMinutes = it }
|
||||||
|
expectedMinutesFrom?.let { this.expectedMinutesFrom = it }
|
||||||
|
expectedMinutesTo?.let { this.expectedMinutesTo = it }
|
||||||
|
isOpen?.let { this.isOpen = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Difficulty {
|
||||||
|
VERY_EASY,
|
||||||
|
EASY,
|
||||||
|
NORMAL,
|
||||||
|
HARD,
|
||||||
|
VERY_HARD
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package roomescape.theme.infrastructure.persistence.v2
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
|
||||||
|
interface ThemeRepositoryV2: JpaRepository<ThemeEntityV2, Long> {
|
||||||
|
|
||||||
|
@Query("SELECT t FROM ThemeEntityV2 t WHERE t.isOpen = true")
|
||||||
|
fun findOpenedThemes(): List<ThemeEntityV2>
|
||||||
|
|
||||||
|
fun existsByName(name: String): Boolean
|
||||||
|
}
|
||||||
60
src/main/kotlin/roomescape/theme/web/ThemeControllerV2.kt
Normal file
60
src/main/kotlin/roomescape/theme/web/ThemeControllerV2.kt
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package roomescape.theme.web
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
import roomescape.common.dto.response.CommonApiResponse
|
||||||
|
import roomescape.theme.business.ThemeServiceV2
|
||||||
|
import roomescape.theme.docs.ThemeAPIV2
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
class ThemeControllerV2(
|
||||||
|
private val themeService: ThemeServiceV2,
|
||||||
|
) : ThemeAPIV2 {
|
||||||
|
|
||||||
|
@GetMapping("/v2/themes")
|
||||||
|
override fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>> {
|
||||||
|
val response = themeService.findThemesForReservation()
|
||||||
|
|
||||||
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/admin/themes")
|
||||||
|
override fun findAdminThemes(): ResponseEntity<CommonApiResponse<AdminThemeSummaryRetrieveListResponse>> {
|
||||||
|
val response = themeService.findAdminThemes()
|
||||||
|
|
||||||
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/admin/themes/{id}")
|
||||||
|
override fun findAdminThemeDetail(@PathVariable id: Long): ResponseEntity<CommonApiResponse<AdminThemeDetailRetrieveResponse>> {
|
||||||
|
val response = themeService.findAdminThemeDetail(id)
|
||||||
|
|
||||||
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/themes")
|
||||||
|
override fun createTheme(themeCreateRequestV2: ThemeCreateRequestV2): ResponseEntity<CommonApiResponse<ThemeCreateResponseV2>> {
|
||||||
|
val response = themeService.createTheme(themeCreateRequestV2)
|
||||||
|
|
||||||
|
return ResponseEntity.created(URI.create("/admin/themes/${response.id}"))
|
||||||
|
.body(CommonApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/admin/themes/{id}")
|
||||||
|
override fun deleteTheme(@PathVariable id: Long): ResponseEntity<CommonApiResponse<Unit>> {
|
||||||
|
themeService.deleteTheme(id)
|
||||||
|
|
||||||
|
return ResponseEntity.noContent().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/admin/themes/{id}")
|
||||||
|
override fun updateTheme(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
themeUpdateRequest: ThemeUpdateRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||||
|
themeService.updateTheme(id, themeUpdateRequest)
|
||||||
|
|
||||||
|
return ResponseEntity.ok().build()
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/main/kotlin/roomescape/theme/web/ThemeDtoV2.kt
Normal file
151
src/main/kotlin/roomescape/theme/web/ThemeDtoV2.kt
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
package roomescape.theme.web
|
||||||
|
|
||||||
|
import roomescape.theme.infrastructure.persistence.v2.Difficulty
|
||||||
|
import roomescape.theme.infrastructure.persistence.v2.ThemeEntityV2
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
data class ThemeCreateRequestV2(
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val thumbnailUrl: String,
|
||||||
|
val difficulty: Difficulty,
|
||||||
|
val price: Int,
|
||||||
|
val minParticipants: Short,
|
||||||
|
val maxParticipants: Short,
|
||||||
|
val availableMinutes: Short,
|
||||||
|
val expectedMinutesFrom: Short,
|
||||||
|
val expectedMinutesTo: Short,
|
||||||
|
val isOpen: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ThemeCreateResponseV2(
|
||||||
|
val id: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ThemeCreateRequestV2.toEntity(id: Long) = ThemeEntityV2(
|
||||||
|
id = id,
|
||||||
|
name = this.name,
|
||||||
|
description = this.description,
|
||||||
|
thumbnailUrl = this.thumbnailUrl,
|
||||||
|
difficulty = this.difficulty,
|
||||||
|
price = this.price,
|
||||||
|
minParticipants = this.minParticipants,
|
||||||
|
maxParticipants = this.maxParticipants,
|
||||||
|
availableMinutes = this.availableMinutes,
|
||||||
|
expectedMinutesFrom = this.expectedMinutesFrom,
|
||||||
|
expectedMinutesTo = this.expectedMinutesTo,
|
||||||
|
isOpen = this.isOpen
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ThemeUpdateRequest(
|
||||||
|
val name: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val thumbnailUrl: String? = null,
|
||||||
|
val difficulty: Difficulty? = null,
|
||||||
|
val price: Int? = null,
|
||||||
|
val minParticipants: Short? = null,
|
||||||
|
val maxParticipants: Short? = null,
|
||||||
|
val availableMinutes: Short? = null,
|
||||||
|
val expectedMinutesFrom: Short? = null,
|
||||||
|
val expectedMinutesTo: Short? = null,
|
||||||
|
val isOpen: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminThemeSummaryRetrieveResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val difficulty: Difficulty,
|
||||||
|
val price: Int,
|
||||||
|
val isOpen: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ThemeEntityV2.toAdminThemeSummaryResponse() = AdminThemeSummaryRetrieveResponse(
|
||||||
|
id = this.id,
|
||||||
|
name = this.name,
|
||||||
|
difficulty = this.difficulty,
|
||||||
|
price = this.price,
|
||||||
|
isOpen = this.isOpen
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminThemeSummaryRetrieveListResponse(
|
||||||
|
val themes: List<AdminThemeSummaryRetrieveResponse>
|
||||||
|
)
|
||||||
|
|
||||||
|
fun List<ThemeEntityV2>.toAdminThemeSummaryListResponse() = AdminThemeSummaryRetrieveListResponse(
|
||||||
|
themes = this.map { it.toAdminThemeSummaryResponse() }
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminThemeDetailRetrieveResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val thumbnailUrl: String,
|
||||||
|
val difficulty: Difficulty,
|
||||||
|
val price: Int,
|
||||||
|
val minParticipants: Short,
|
||||||
|
val maxParticipants: Short,
|
||||||
|
val availableMinutes: Short,
|
||||||
|
val expectedMinutesFrom: Short,
|
||||||
|
val expectedMinutesTo: Short,
|
||||||
|
val isOpen: Boolean,
|
||||||
|
val createdAt: LocalDateTime,
|
||||||
|
val createdBy: String,
|
||||||
|
val updatedAt: LocalDateTime,
|
||||||
|
val updatedBy: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ThemeEntityV2.toAdminThemeDetailResponse(createUserName: String, updateUserName: String) =
|
||||||
|
AdminThemeDetailRetrieveResponse(
|
||||||
|
id = this.id,
|
||||||
|
name = this.name,
|
||||||
|
description = this.description,
|
||||||
|
thumbnailUrl = this.thumbnailUrl,
|
||||||
|
difficulty = this.difficulty,
|
||||||
|
price = this.price,
|
||||||
|
minParticipants = this.minParticipants,
|
||||||
|
maxParticipants = this.maxParticipants,
|
||||||
|
availableMinutes = this.availableMinutes,
|
||||||
|
expectedMinutesFrom = this.expectedMinutesFrom,
|
||||||
|
expectedMinutesTo = this.expectedMinutesTo,
|
||||||
|
isOpen = this.isOpen,
|
||||||
|
createdAt = this.createdAt,
|
||||||
|
createdBy = createUserName,
|
||||||
|
updatedAt = this.updatedAt,
|
||||||
|
updatedBy = updateUserName
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ThemeRetrieveResponseV2(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val thumbnailUrl: String,
|
||||||
|
val description: String,
|
||||||
|
val difficulty: Difficulty,
|
||||||
|
val price: Int,
|
||||||
|
val minParticipants: Short,
|
||||||
|
val maxParticipants: Short,
|
||||||
|
val availableMinutes: Short,
|
||||||
|
val expectedMinutesFrom: Short,
|
||||||
|
val expectedMinutesTo: Short
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ThemeEntityV2.toRetrieveResponse() = ThemeRetrieveResponseV2(
|
||||||
|
id = this.id,
|
||||||
|
name = this.name,
|
||||||
|
thumbnailUrl = this.thumbnailUrl,
|
||||||
|
description = this.description,
|
||||||
|
difficulty = this.difficulty,
|
||||||
|
price = this.price,
|
||||||
|
minParticipants = this.minParticipants,
|
||||||
|
maxParticipants = this.maxParticipants,
|
||||||
|
availableMinutes = this.availableMinutes,
|
||||||
|
expectedMinutesFrom = this.expectedMinutesFrom,
|
||||||
|
expectedMinutesTo = this.expectedMinutesTo
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ThemeRetrieveListResponseV2(
|
||||||
|
val themes: List<ThemeRetrieveResponseV2>
|
||||||
|
)
|
||||||
|
|
||||||
|
fun List<ThemeEntityV2>.toRetrieveListResponse() = ThemeRetrieveListResponseV2(
|
||||||
|
themes = this.map { it.toRetrieveResponse() }
|
||||||
|
)
|
||||||
55
src/main/resources/login.http
Normal file
55
src/main/resources/login.http
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
POST http://localhost:8080/login?key=value&key1=value1
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "a@a.a",
|
||||||
|
"password": "a"
|
||||||
|
}
|
||||||
|
|
||||||
|
> {%
|
||||||
|
const accessToken = response.body.data.accessToken;
|
||||||
|
client.global.set("token", accessToken);
|
||||||
|
%}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
GET http://localhost:8080/reservations
|
||||||
|
Accept: application/json
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
DELETE http://localhost:8080/reservations/57
|
||||||
|
Accept: application/json
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
POST http://localhost:8080/reservations/admin
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"date": "2026-10-01",
|
||||||
|
"timeId": 1,
|
||||||
|
"themeId": 1,
|
||||||
|
"memberId": 3
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
POST http://localhost:8080/reservations/admin
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"date": "2023-10-01",
|
||||||
|
"timeId": 1,
|
||||||
|
"themeId": 1,
|
||||||
|
"memberId": 3
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
GET http://localhost:8080/reservations-mine
|
||||||
|
Accept: application/json
|
||||||
@ -27,6 +27,28 @@ create table if not exists themes (
|
|||||||
last_modified_at timestamp
|
last_modified_at timestamp
|
||||||
);
|
);
|
||||||
|
|
||||||
|
create table if not exists theme (
|
||||||
|
id bigint primary key ,
|
||||||
|
name varchar(30) not null,
|
||||||
|
difficulty varchar(20) not null,
|
||||||
|
description varchar(255) not null,
|
||||||
|
thumbnail_url varchar(255) not null,
|
||||||
|
price int not null,
|
||||||
|
min_participants smallint not null,
|
||||||
|
max_participants smallint not null,
|
||||||
|
available_minutes smallint not null,
|
||||||
|
expected_minutes_from smallint not null,
|
||||||
|
expected_minutes_to smallint not null,
|
||||||
|
is_open boolean not null,
|
||||||
|
created_at timestamp not null,
|
||||||
|
created_by bigint not null,
|
||||||
|
updated_at timestamp not null,
|
||||||
|
updated_by bigint not null,
|
||||||
|
|
||||||
|
constraint fk_theme__created_by foreign key (created_by) references members (member_id),
|
||||||
|
constraint fk_theme__updated_by foreign key (updated_by) references members (member_id)
|
||||||
|
);
|
||||||
|
|
||||||
create table if not exists times (
|
create table if not exists times (
|
||||||
time_id bigint primary key,
|
time_id bigint primary key,
|
||||||
start_at time not null,
|
start_at time not null,
|
||||||
|
|||||||
@ -29,6 +29,28 @@ create table if not exists themes
|
|||||||
last_modified_at datetime(6) null
|
last_modified_at datetime(6) null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
create table if not exists theme (
|
||||||
|
id bigint primary key ,
|
||||||
|
name varchar(30) not null,
|
||||||
|
difficulty varchar(20) not null,
|
||||||
|
description varchar(255) not null,
|
||||||
|
thumbnail_url varchar(255) not null,
|
||||||
|
price int not null,
|
||||||
|
min_participants smallint not null,
|
||||||
|
max_participants smallint not null,
|
||||||
|
available_minutes smallint not null,
|
||||||
|
expected_minutes_from smallint not null,
|
||||||
|
expected_minutes_to smallint not null,
|
||||||
|
is_open tinyint(1) not null,
|
||||||
|
created_at datetime(6) not null,
|
||||||
|
created_by bigint not null,
|
||||||
|
updated_at datetime(6) not null,
|
||||||
|
updated_by bigint not null,
|
||||||
|
|
||||||
|
constraint fk_theme__created_by foreign key (created_by) references members (member_id),
|
||||||
|
constraint fk_theme__updated_by foreign key (updated_by) references members (member_id)
|
||||||
|
);
|
||||||
|
|
||||||
create table if not exists times
|
create table if not exists times
|
||||||
(
|
(
|
||||||
time_id bigint primary key,
|
time_id bigint primary key,
|
||||||
|
|||||||
681
src/test/kotlin/roomescape/theme/ThemeApiTest.kt
Normal file
681
src/test/kotlin/roomescape/theme/ThemeApiTest.kt
Normal file
@ -0,0 +1,681 @@
|
|||||||
|
package roomescape.theme
|
||||||
|
|
||||||
|
import io.kotest.matchers.date.shouldBeAfter
|
||||||
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.shouldNotBe
|
||||||
|
import io.restassured.module.kotlin.extensions.Extract
|
||||||
|
import io.restassured.module.kotlin.extensions.Given
|
||||||
|
import io.restassured.module.kotlin.extensions.When
|
||||||
|
import io.restassured.response.ValidatableResponse
|
||||||
|
import org.hamcrest.CoreMatchers.equalTo
|
||||||
|
import org.hamcrest.CoreMatchers.notNullValue
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import roomescape.auth.exception.AuthErrorCode
|
||||||
|
import roomescape.member.infrastructure.persistence.Role
|
||||||
|
import roomescape.theme.business.MIN_DURATION
|
||||||
|
import roomescape.theme.business.MIN_PARTICIPANTS
|
||||||
|
import roomescape.theme.business.MIN_PRICE
|
||||||
|
import roomescape.theme.exception.ThemeErrorCode
|
||||||
|
import roomescape.theme.infrastructure.persistence.v2.Difficulty
|
||||||
|
import roomescape.theme.infrastructure.persistence.v2.ThemeEntityV2
|
||||||
|
import roomescape.theme.infrastructure.persistence.v2.ThemeRepositoryV2
|
||||||
|
import roomescape.theme.web.ThemeCreateRequestV2
|
||||||
|
import roomescape.theme.web.ThemeUpdateRequest
|
||||||
|
import roomescape.util.FunSpecSpringbootTest
|
||||||
|
import roomescape.util.assertProperties
|
||||||
|
import roomescape.util.runTest
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
class ThemeApiTest(
|
||||||
|
private val themeRepository: ThemeRepositoryV2
|
||||||
|
) : FunSpecSpringbootTest() {
|
||||||
|
|
||||||
|
private val request: ThemeCreateRequestV2 = ThemeCreateRequestV2(
|
||||||
|
name = "Matilda Green",
|
||||||
|
description = "constituto",
|
||||||
|
thumbnailUrl = "https://duckduckgo.com/?q=mediocrem",
|
||||||
|
difficulty = Difficulty.VERY_EASY,
|
||||||
|
price = 10000,
|
||||||
|
minParticipants = 3,
|
||||||
|
maxParticipants = 5,
|
||||||
|
availableMinutes = 80,
|
||||||
|
expectedMinutesFrom = 60,
|
||||||
|
expectedMinutesTo = 70,
|
||||||
|
isOpen = true
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
context("관리자가 아니면 접근할 수 없다.") {
|
||||||
|
lateinit var token: String
|
||||||
|
|
||||||
|
beforeTest {
|
||||||
|
token = loginUtil.loginAsUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
val commonAssertion: ValidatableResponse.() -> Unit = {
|
||||||
|
statusCode(HttpStatus.FORBIDDEN.value())
|
||||||
|
body("code", equalTo(AuthErrorCode.ACCESS_DENIED.errorCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("테마 생성: POST /admin/themes") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(request)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post("/admin/themes")
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("테마 조회: GET /admin/themes") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
on = {
|
||||||
|
get("/admin/themes")
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("테마 상세 조회: GET /admin/themes/{id}") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
on = {
|
||||||
|
get("/admin/themes/1")
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("테마 수정: PATCH /admin/themes/{id}") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(request)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
patch("/admin/themes/1")
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("테마 삭제: DELETE /admin/themes/{id}") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
on = {
|
||||||
|
delete("/admin/themes/1")
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("일반 회원도 접근할 수 있다.") {
|
||||||
|
test("테마 조회: GET /v2/themes") {
|
||||||
|
createDummyTheme(request.copy(name = "test123", isOpen = true))
|
||||||
|
|
||||||
|
runTest(
|
||||||
|
token = loginUtil.loginAsUser(),
|
||||||
|
on = {
|
||||||
|
get("/v2/themes")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.OK.value())
|
||||||
|
body("data.themes.size()", equalTo(1))
|
||||||
|
body("data.themes[0].name", equalTo("test123"))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("테마를 생성한다.") {
|
||||||
|
val apiPath = "/admin/themes"
|
||||||
|
|
||||||
|
lateinit var token: String
|
||||||
|
|
||||||
|
beforeTest {
|
||||||
|
token = loginUtil.loginAsAdmin()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("정상 생성 및 감사 정보 확인") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(request)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post(apiPath)
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.CREATED.value())
|
||||||
|
body("data.id", notNullValue())
|
||||||
|
}
|
||||||
|
).also {
|
||||||
|
val createdThemeId: String = it.extract().path("data.id")
|
||||||
|
val createdTheme: ThemeEntityV2 = themeRepository.findByIdOrNull(createdThemeId.toLong())
|
||||||
|
?: throw AssertionError("Unexpected Exception Occurred.")
|
||||||
|
|
||||||
|
createdTheme.name shouldBe request.name
|
||||||
|
createdTheme.createdAt shouldNotBeNull {}
|
||||||
|
createdTheme.createdBy shouldNotBeNull {}
|
||||||
|
createdTheme.updatedAt shouldNotBeNull {}
|
||||||
|
createdTheme.updatedBy shouldNotBeNull {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("이미 동일한 이름의 테마가 있으면 실패한다.") {
|
||||||
|
val commonName = "test123"
|
||||||
|
createDummyTheme(request.copy(name = commonName))
|
||||||
|
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(request.copy(name = commonName))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post(apiPath)
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.THEME_NAME_DUPLICATED.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("금액이 ${MIN_PRICE}원 미만이면 실패한다.") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(request.copy(price = (MIN_PRICE - 1)))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post(apiPath)
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.PRICE_BELOW_MINIMUM.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
context("입력된 시간이 ${MIN_DURATION}분 미만이면 실패한다.") {
|
||||||
|
val commonAssertion: ValidatableResponse.() -> Unit = {
|
||||||
|
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.DURATION_BELOW_MINIMUM.errorCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("field: availableMinutes") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(request.copy(availableMinutes = (MIN_DURATION - 1).toShort()))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post(apiPath)
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("field: expectedMinutesFrom") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(request.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort()))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post(apiPath)
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("field: expectedMinutesTo") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(request.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort()))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post(apiPath)
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("시간 범위가 잘못 지정되면 실패한다.") {
|
||||||
|
test("최소 예상 시간 > 최대 예상 시간") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(request.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post(apiPath)
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.MIN_EXPECTED_TIME_EXCEEDS_MAX_EXPECTED_TIME.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("최대 예상 시간 > 이용 가능 시간") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(
|
||||||
|
request.copy(
|
||||||
|
availableMinutes = 100,
|
||||||
|
expectedMinutesFrom = 101,
|
||||||
|
expectedMinutesTo = 101
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post(apiPath)
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("입력된 인원이 ${MIN_PARTICIPANTS}명 미만이면 실패한다.") {
|
||||||
|
val commonAssertion: ValidatableResponse.() -> Unit = {
|
||||||
|
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM.errorCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("field: minParticipants") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(request.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort()))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post(apiPath)
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("field: maxParticipants") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(request.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort()))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post(apiPath)
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("인원 범위가 잘못 지정되면 실패한다.") {
|
||||||
|
test("최소 인원 > 최대 인원") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(request.copy(minParticipants = 10, maxParticipants = 9))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post(apiPath)
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("모든 테마를 조회한다.") {
|
||||||
|
beforeTest {
|
||||||
|
createDummyTheme(request.copy(name = "open", isOpen = true))
|
||||||
|
createDummyTheme(request.copy(name = "close", isOpen = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("관리자 페이지에서는 비공개 테마까지 포함하여 간단한 정보만 조회된다.") {
|
||||||
|
runTest(
|
||||||
|
token = loginUtil.loginAsAdmin(),
|
||||||
|
on = {
|
||||||
|
get("/admin/themes")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
body("data.themes.size()", equalTo(2))
|
||||||
|
assertProperties(
|
||||||
|
props = setOf("id", "name", "difficulty", "price", "isOpen"),
|
||||||
|
propsNameIfList = "themes",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("예약 페이지에서는 공개된 테마의 전체 정보가 조회된다.") {
|
||||||
|
runTest(
|
||||||
|
token = loginUtil.loginAsUser(),
|
||||||
|
on = {
|
||||||
|
get("/v2/themes")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
body("data.themes.size()", equalTo(1))
|
||||||
|
body("data.themes[0].name", equalTo("open"))
|
||||||
|
assertProperties(
|
||||||
|
props = setOf(
|
||||||
|
"id", "name", "thumbnailUrl", "description", "difficulty", "price",
|
||||||
|
"minParticipants", "maxParticipants",
|
||||||
|
"availableMinutes", "expectedMinutesFrom", "expectedMinutesTo"
|
||||||
|
),
|
||||||
|
propsNameIfList = "themes",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("관리자 페이지에서 특정 테마의 상세 정보를 조회한다.") {
|
||||||
|
test("정상 응답") {
|
||||||
|
val createdTheme: ThemeEntityV2 = createDummyTheme(request)
|
||||||
|
|
||||||
|
runTest(
|
||||||
|
token = loginUtil.loginAsAdmin(),
|
||||||
|
on = {
|
||||||
|
get("/admin/themes/${createdTheme.id}")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.OK.value())
|
||||||
|
body("data.id", equalTo(createdTheme.id.toString()))
|
||||||
|
assertProperties(
|
||||||
|
props = setOf(
|
||||||
|
"id", "name", "description", "thumbnailUrl", "difficulty", "price", "isOpen",
|
||||||
|
"minParticipants", "maxParticipants",
|
||||||
|
"availableMinutes", "expectedMinutesFrom", "expectedMinutesTo",
|
||||||
|
"createdAt", "createdBy", "updatedAt", "updatedBy"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("테마가 없으면 실패한다.") {
|
||||||
|
runTest(
|
||||||
|
token = loginUtil.loginAsAdmin(),
|
||||||
|
on = {
|
||||||
|
get("/admin/themes/1")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.NOT_FOUND.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.THEME_NOT_FOUND.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("테마를 삭제한다.") {
|
||||||
|
test("정상 삭제") {
|
||||||
|
val createdTheme = createDummyTheme(request)
|
||||||
|
|
||||||
|
runTest(
|
||||||
|
token = loginUtil.loginAsAdmin(),
|
||||||
|
on = {
|
||||||
|
delete("/admin/themes/${createdTheme.id}")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.NO_CONTENT.value())
|
||||||
|
}
|
||||||
|
).also {
|
||||||
|
themeRepository.findByIdOrNull(createdTheme.id) shouldBe null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("테마가 없으면 실패한다.") {
|
||||||
|
runTest(
|
||||||
|
token = loginUtil.loginAsAdmin(),
|
||||||
|
on = {
|
||||||
|
delete("/admin/themes/1")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.NOT_FOUND.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.THEME_NOT_FOUND.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("테마를 수정한다.") {
|
||||||
|
lateinit var token: String
|
||||||
|
lateinit var createdTheme: ThemeEntityV2
|
||||||
|
lateinit var apiPath: String
|
||||||
|
|
||||||
|
val updateRequest = ThemeUpdateRequest(name = "modified")
|
||||||
|
|
||||||
|
beforeTest {
|
||||||
|
token = loginUtil.loginAsAdmin()
|
||||||
|
createdTheme = createDummyTheme(request.copy(name = "theme-${Random.nextInt()}"))
|
||||||
|
apiPath = "/admin/themes/${createdTheme.id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("정상 수정 및 감사 정보 변경 확인") {
|
||||||
|
val otherAdminToken = loginUtil.login("admin1@admin.com", "admin1", Role.ADMIN)
|
||||||
|
|
||||||
|
runTest(
|
||||||
|
token = otherAdminToken,
|
||||||
|
using = {
|
||||||
|
body(updateRequest)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
patch(apiPath)
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.OK.value())
|
||||||
|
}
|
||||||
|
).also {
|
||||||
|
val updatedTheme = themeRepository.findByIdOrNull(createdTheme.id)!!
|
||||||
|
|
||||||
|
updatedTheme.id shouldBe createdTheme.id
|
||||||
|
updatedTheme.name shouldBe updateRequest.name
|
||||||
|
updatedTheme.updatedBy shouldNotBe createdTheme.updatedBy
|
||||||
|
updatedTheme.updatedAt shouldBeAfter createdTheme.updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("테마가 없으면 실패한다.") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(updateRequest)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
patch("/admin/themes/1")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.NOT_FOUND.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.THEME_NOT_FOUND.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("금액이 ${MIN_PRICE}원 미만이면 실패한다.") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(updateRequest.copy(price = (MIN_PRICE - 1)))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
patch("/admin/themes/${createdTheme.id}")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.PRICE_BELOW_MINIMUM.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
context("입력된 시간이 ${MIN_DURATION}분 미만이면 실패한다.") {
|
||||||
|
val commonAssertion: ValidatableResponse.() -> Unit = {
|
||||||
|
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.DURATION_BELOW_MINIMUM.errorCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("field: availableMinutes") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(updateRequest.copy(availableMinutes = (MIN_DURATION - 1).toShort()))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
patch(apiPath)
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("field: expectedMinutesFrom") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(updateRequest.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort()))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
patch(apiPath)
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("field: expectedMinutesTo") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(updateRequest.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort()))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
patch(apiPath)
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("시간 범위가 잘못 지정되면 실패한다.") {
|
||||||
|
test("최소 예상 시간 > 최대 예상 시간") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(updateRequest.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
patch(apiPath)
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.MIN_EXPECTED_TIME_EXCEEDS_MAX_EXPECTED_TIME.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("최대 예상 시간 > 이용 가능 시간") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(
|
||||||
|
updateRequest.copy(
|
||||||
|
availableMinutes = 100,
|
||||||
|
expectedMinutesFrom = 101,
|
||||||
|
expectedMinutesTo = 101
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
patch(apiPath)
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("입력된 인원이 ${MIN_PARTICIPANTS}명 미만이면 실패한다.") {
|
||||||
|
val commonAssertion: ValidatableResponse.() -> Unit = {
|
||||||
|
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM.errorCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("field: minParticipants") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(updateRequest.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort()))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
patch(apiPath)
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("field: maxParticipants") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(updateRequest.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort()))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
patch(apiPath)
|
||||||
|
},
|
||||||
|
expect = commonAssertion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("인원 범위가 잘못 지정되면 실패한다.") {
|
||||||
|
test("최소 인원 > 최대 인원") {
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(updateRequest.copy(minParticipants = 10, maxParticipants = 9))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
patch(apiPath)
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||||
|
body("code", equalTo(ThemeErrorCode.MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createDummyTheme(request: ThemeCreateRequestV2): ThemeEntityV2 {
|
||||||
|
val createdThemeId: String = Given {
|
||||||
|
contentType(MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
header("Authorization", "Bearer ${loginUtil.loginAsAdmin()}")
|
||||||
|
body(request)
|
||||||
|
} When {
|
||||||
|
post("/admin/themes")
|
||||||
|
} Extract {
|
||||||
|
path("data.id")
|
||||||
|
}
|
||||||
|
|
||||||
|
return themeRepository.findByIdOrNull(createdThemeId.toLong())
|
||||||
|
?: throw RuntimeException("unreachable line")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +1,73 @@
|
|||||||
package roomescape.util
|
package roomescape.util
|
||||||
|
|
||||||
import io.kotest.core.config.AbstractProjectConfig
|
import io.kotest.core.config.AbstractProjectConfig
|
||||||
|
import io.kotest.core.spec.Spec
|
||||||
|
import io.kotest.core.spec.style.BehaviorSpec
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.core.spec.style.StringSpec
|
||||||
import io.kotest.extensions.spring.SpringExtension
|
import io.kotest.extensions.spring.SpringExtension
|
||||||
import io.kotest.extensions.spring.SpringTestExtension
|
import io.kotest.extensions.spring.SpringTestExtension
|
||||||
|
import io.restassured.RestAssured
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort
|
||||||
|
import roomescape.member.infrastructure.persistence.MemberRepository
|
||||||
|
import roomescape.util.CleanerMode.AFTER_EACH_TEST
|
||||||
|
|
||||||
object KotestConfig : AbstractProjectConfig() {
|
object KotestConfig : AbstractProjectConfig() {
|
||||||
override fun extensions(): List<SpringTestExtension> = listOf(SpringExtension)
|
override fun extensions(): List<SpringTestExtension> = listOf(SpringExtension)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
abstract class FunSpecSpringbootTest : FunSpec({
|
||||||
|
extension(DatabaseCleanerExtension(mode = AFTER_EACH_TEST))
|
||||||
|
}) {
|
||||||
|
@Autowired
|
||||||
|
private lateinit var memberRepository: MemberRepository
|
||||||
|
|
||||||
|
@LocalServerPort
|
||||||
|
var port: Int = 0
|
||||||
|
|
||||||
|
lateinit var loginUtil: LoginUtil
|
||||||
|
|
||||||
|
override suspend fun beforeSpec(spec: Spec) {
|
||||||
|
RestAssured.port = port
|
||||||
|
loginUtil = LoginUtil(memberRepository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
abstract class StringSpecSpringbootTest : StringSpec({
|
||||||
|
extension(DatabaseCleanerExtension(mode = AFTER_EACH_TEST))
|
||||||
|
}) {
|
||||||
|
@Autowired
|
||||||
|
private lateinit var memberRepository: MemberRepository
|
||||||
|
|
||||||
|
@LocalServerPort
|
||||||
|
var port: Int = 0
|
||||||
|
|
||||||
|
lateinit var loginUtil: LoginUtil
|
||||||
|
|
||||||
|
override suspend fun beforeSpec(spec: Spec) {
|
||||||
|
RestAssured.port = port
|
||||||
|
loginUtil = LoginUtil(memberRepository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
abstract class BehaviorSpecSpringbootTest : BehaviorSpec({
|
||||||
|
extension(DatabaseCleanerExtension(mode = AFTER_EACH_TEST))
|
||||||
|
}) {
|
||||||
|
@Autowired
|
||||||
|
private lateinit var memberRepository: MemberRepository
|
||||||
|
|
||||||
|
@LocalServerPort
|
||||||
|
var port: Int = 0
|
||||||
|
|
||||||
|
lateinit var loginUtil: LoginUtil
|
||||||
|
|
||||||
|
override suspend fun beforeSpec(spec: Spec) {
|
||||||
|
RestAssured.port = port
|
||||||
|
loginUtil = LoginUtil(memberRepository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
106
src/test/kotlin/roomescape/util/RestAssuredUtils.kt
Normal file
106
src/test/kotlin/roomescape/util/RestAssuredUtils.kt
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
package roomescape.util
|
||||||
|
|
||||||
|
import io.restassured.module.kotlin.extensions.Extract
|
||||||
|
import io.restassured.module.kotlin.extensions.Given
|
||||||
|
import io.restassured.module.kotlin.extensions.Then
|
||||||
|
import io.restassured.module.kotlin.extensions.When
|
||||||
|
import io.restassured.response.Response
|
||||||
|
import io.restassured.response.ValidatableResponse
|
||||||
|
import io.restassured.specification.RequestSpecification
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import roomescape.auth.web.LoginRequest
|
||||||
|
import roomescape.common.config.next
|
||||||
|
import roomescape.member.infrastructure.persistence.MemberEntity
|
||||||
|
import roomescape.member.infrastructure.persistence.MemberRepository
|
||||||
|
import roomescape.member.infrastructure.persistence.Role
|
||||||
|
|
||||||
|
class LoginUtil(
|
||||||
|
private val memberRepository: MemberRepository
|
||||||
|
) {
|
||||||
|
fun login(email: String, password: String, role: Role = Role.MEMBER): String {
|
||||||
|
if (!memberRepository.existsByEmail(email)) {
|
||||||
|
memberRepository.save(
|
||||||
|
MemberEntity(
|
||||||
|
_id = TsidFactory.next(),
|
||||||
|
email = email,
|
||||||
|
password = password,
|
||||||
|
name = email.split("@").first(),
|
||||||
|
role = role
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Given {
|
||||||
|
contentType(MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
body(LoginRequest(email, password))
|
||||||
|
} When {
|
||||||
|
post("/login")
|
||||||
|
} Then {
|
||||||
|
statusCode(200)
|
||||||
|
} Extract {
|
||||||
|
path("data.accessToken")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loginAsAdmin(): String {
|
||||||
|
return login(MemberFixture.admin().email, MemberFixture.admin().password, Role.ADMIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loginAsUser(): String {
|
||||||
|
return login(MemberFixture.user().email, MemberFixture.user().password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runTest(
|
||||||
|
token: String? = null,
|
||||||
|
using: RequestSpecification.() -> RequestSpecification = { this },
|
||||||
|
on: RequestSpecification.() -> Response,
|
||||||
|
expect: ValidatableResponse.() -> Unit
|
||||||
|
): ValidatableResponse {
|
||||||
|
return Given {
|
||||||
|
contentType(MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
token?.also { header("Authorization", "Bearer $token") }
|
||||||
|
using()
|
||||||
|
} When {
|
||||||
|
on()
|
||||||
|
} Then {
|
||||||
|
expect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param props: RestAssured 응답 Body 에서 존재 & Null 여부를 확인할 프로퍼티 이름
|
||||||
|
*/
|
||||||
|
fun ValidatableResponse.assertProperties(props: Set<String>, propsNameIfList: String? = null) {
|
||||||
|
val jsonDefaultPath = propsNameIfList?.let { "data.$propsNameIfList" } ?: "data"
|
||||||
|
val json = extract().jsonPath().get<Any>(jsonDefaultPath)
|
||||||
|
|
||||||
|
fun checkMap(map: Map<*, *>) {
|
||||||
|
val responseKeys = map.keys.map { it.toString() }.toSet()
|
||||||
|
val expectedKeys = props
|
||||||
|
|
||||||
|
val missing = expectedKeys - responseKeys
|
||||||
|
val extra = responseKeys - expectedKeys
|
||||||
|
|
||||||
|
require(missing.isEmpty() && extra.isEmpty()) {
|
||||||
|
buildString {
|
||||||
|
if (missing.isNotEmpty()) append("Missing keys: $missing. ")
|
||||||
|
if (extra.isNotEmpty()) append("Unexpected keys: $extra.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedKeys.forEach { key ->
|
||||||
|
require(map[key] != null) { "Property '$key' is null" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (json) {
|
||||||
|
is List<*> -> json.forEach { item ->
|
||||||
|
val map = item as? Map<*, *> ?: error("Expected Map but got ${item?.javaClass}")
|
||||||
|
checkMap(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Map<*, *> -> checkMap(json)
|
||||||
|
else -> error("Unexpected data type: ${json::class}")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user