Compare commits

...

13 Commits

50 changed files with 4342 additions and 301 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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,42 +15,34 @@ 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>
); );
}; };
export default Navbar; export default Navbar;

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

@ -1,11 +1,12 @@
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>
); );
}; };
export default AdminPage; export default AdminPage;

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

View File

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

View File

@ -1,12 +1,12 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { 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,89 +23,60 @@ 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>
); );
}; };
export default AdminThemePage; export default AdminThemePage;

View File

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

View File

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

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

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

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

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

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

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

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

View File

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

View File

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

View 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())
}
}
}

View 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
}

View File

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

View 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,
)
}
}

View 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())
}

View 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>>
}

View File

@ -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", "예상 시간은 이용 시간보다 짧아야 해요."),
} }

View File

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

View File

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

View 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()
}
}

View 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() }
)

View 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

View File

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

View File

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

View 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")
}
}

View File

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

View 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}")
}
}