generated from pricelees/issue-pr-template
[#39] '시간' -> '일정' 스키마 변경으로 테마별 시간 지정 #40
27
frontend/package-lock.json
generated
27
frontend/package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"axios": "^1.7.2",
|
||||
"bootstrap": "^5.3.3",
|
||||
"flatpickr": "^4.6.13",
|
||||
"json-bigint": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-flatpickr": "^3.10.13",
|
||||
@ -18,6 +19,7 @@
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/react-flatpickr": "^3.8.11",
|
||||
@ -1323,6 +1325,13 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-bigint": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.4.tgz",
|
||||
"integrity": "sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@ -1690,6 +1699,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz",
|
||||
@ -2833,6 +2851,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-bigint": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"axios": "^1.7.2",
|
||||
"bootstrap": "^5.3.3",
|
||||
"flatpickr": "^4.6.13",
|
||||
"json-bigint": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-flatpickr": "^3.10.13",
|
||||
@ -20,6 +21,7 @@
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/react-flatpickr": "^3.8.11",
|
||||
|
||||
@ -24,6 +24,7 @@ import HomePageV2 from './pages/v2/HomePageV2';
|
||||
import LoginPageV2 from './pages/v2/LoginPageV2';
|
||||
import SignupPageV2 from './pages/v2/SignupPageV2';
|
||||
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
|
||||
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
|
||||
|
||||
const AdminRoutes = () => (
|
||||
<AdminLayout>
|
||||
@ -34,51 +35,52 @@ const AdminRoutes = () => (
|
||||
<Route path="/theme" element={<AdminThemePage />} />
|
||||
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
|
||||
<Route path="/waiting" element={<AdminWaitingPage />} />
|
||||
<Route path="/schedule" element={<AdminSchedulePage />} />
|
||||
</Routes>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/admin/*" element={
|
||||
<AdminRoute>
|
||||
<AdminRoutes />
|
||||
</AdminRoute>
|
||||
} />
|
||||
<Route path="/*" element={
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/reservation" element={<ReservationPage />} />
|
||||
<Route path="/my-reservation" element={<MyReservationPage />} />
|
||||
<Route path="/my-reservation/v2" element={<MyReservationPageV2 />} />
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/admin/*" element={
|
||||
<AdminRoute>
|
||||
<AdminRoutes />
|
||||
</AdminRoute>
|
||||
} />
|
||||
<Route path="/*" element={
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/reservation" element={<ReservationPage />} />
|
||||
<Route path="/my-reservation" element={<MyReservationPage />} />
|
||||
<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 Pages */}
|
||||
<Route path="/v2/home" element={<HomePageV2 />} />
|
||||
<Route path="/v2/login" element={<LoginPageV2 />} />
|
||||
<Route path="/v2/signup" element={<SignupPageV2 />} />
|
||||
|
||||
{/* V2 Reservation Flow */}
|
||||
<Route path="/v2/reservation" element={<ReservationStep1Page />} />
|
||||
<Route path="/v2/reservation/payment" element={<ReservationStep2Page />} />
|
||||
<Route path="/v2/reservation/success" element={<ReservationSuccessPage />} />
|
||||
{/* V2 Reservation Flow */}
|
||||
<Route path="/v2/reservation" element={<ReservationStep1Page />} />
|
||||
<Route path="/v2/reservation/payment" element={<ReservationStep2Page />} />
|
||||
<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>
|
||||
</Layout>
|
||||
} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
{/* 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>
|
||||
</Layout>
|
||||
} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@ -1,8 +1,28 @@
|
||||
import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios';
|
||||
import JSONbig from 'json-bigint';
|
||||
|
||||
// Create a JSONbig instance that stores big integers as strings
|
||||
const JSONbigString = JSONbig({ storeAsString: true });
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 10000,
|
||||
// transformResponse is used to parse JSON with big integers (Long type from backend) as strings.
|
||||
// This prevents precision loss in JavaScript.
|
||||
transformResponse: [(data) => {
|
||||
// Do not transform if data is not a string or is empty
|
||||
if (!data || typeof data !== 'string') {
|
||||
return data;
|
||||
}
|
||||
try {
|
||||
// Use the configured JSONbig instance to parse the data
|
||||
return JSONbigString.parse(data);
|
||||
} catch (e) {
|
||||
// If parsing fails, it might not be JSON, so return original data
|
||||
// This is the default behavior of axios if parsing fails
|
||||
return data;
|
||||
}
|
||||
}],
|
||||
});
|
||||
|
||||
export const isLoginRequiredError = (error: any): boolean => {
|
||||
|
||||
32
frontend/src/api/schedule/scheduleAPI.ts
Normal file
32
frontend/src/api/schedule/scheduleAPI.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import apiClient from '../apiClient';
|
||||
import type {
|
||||
AvailableThemeIdListResponse,
|
||||
ScheduleCreateRequest,
|
||||
ScheduleCreateResponse, ScheduleDetailRetrieveResponse,
|
||||
ScheduleRetrieveListResponse,
|
||||
ScheduleUpdateRequest
|
||||
} from './scheduleTypes';
|
||||
|
||||
export const findAvailableThemesByDate = async (date: string): Promise<AvailableThemeIdListResponse> => {
|
||||
return await apiClient.get<AvailableThemeIdListResponse>(`/schedules/themes?date=${date}`);
|
||||
};
|
||||
|
||||
export const findSchedules = async (date: string, themeId: string): Promise<ScheduleRetrieveListResponse> => {
|
||||
return await apiClient.get<ScheduleRetrieveListResponse>(`/schedules?date=${date}&themeId=${themeId}`);
|
||||
};
|
||||
|
||||
export const findScheduleById = async (id: string): Promise<ScheduleDetailRetrieveResponse> => {
|
||||
return await apiClient.get<ScheduleDetailRetrieveResponse>(`/schedules/${id}`);
|
||||
}
|
||||
|
||||
export const createSchedule = async (request: ScheduleCreateRequest): Promise<ScheduleCreateResponse> => {
|
||||
return await apiClient.post<ScheduleCreateResponse>('/schedules', request);
|
||||
};
|
||||
|
||||
export const updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise<void> => {
|
||||
await apiClient.patch(`/schedules/${id}`, request);
|
||||
};
|
||||
|
||||
export const deleteSchedule = async (id: string): Promise<void> => {
|
||||
await apiClient.del(`/schedules/${id}`);
|
||||
};
|
||||
48
frontend/src/api/schedule/scheduleTypes.ts
Normal file
48
frontend/src/api/schedule/scheduleTypes.ts
Normal file
@ -0,0 +1,48 @@
|
||||
export enum ScheduleStatus {
|
||||
AVAILABLE = 'AVAILABLE',
|
||||
PENDING = 'PENDING',
|
||||
RESERVED = 'RESERVED',
|
||||
BLOCKED = 'BLOCKED',
|
||||
}
|
||||
|
||||
export interface AvailableThemeIdListResponse {
|
||||
themeIds: string[];
|
||||
}
|
||||
|
||||
export interface ScheduleRetrieveResponse {
|
||||
id: string;
|
||||
time: string; // "HH:mm"
|
||||
status: ScheduleStatus;
|
||||
}
|
||||
|
||||
export interface ScheduleRetrieveListResponse {
|
||||
schedules: ScheduleRetrieveResponse[];
|
||||
}
|
||||
|
||||
export interface ScheduleCreateRequest {
|
||||
date: string; // "yyyy-MM-dd"
|
||||
time: string; // "HH:mm"
|
||||
themeId: string;
|
||||
}
|
||||
|
||||
export interface ScheduleCreateResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ScheduleUpdateRequest {
|
||||
date?: string; // "yyyy-MM-dd"
|
||||
time?: string; // "HH:mm"
|
||||
themeId?: string;
|
||||
status?: ScheduleStatus;
|
||||
}
|
||||
|
||||
export interface ScheduleDetailRetrieveResponse {
|
||||
id: string;
|
||||
date: string; // "yyyy-MM-dd"
|
||||
time: string; // "HH:mm"
|
||||
status: ScheduleStatus;
|
||||
createdAt: string; // or Date
|
||||
createdBy: string;
|
||||
updatedAt: string; // or Date
|
||||
updatedBy: string;
|
||||
}
|
||||
@ -4,7 +4,8 @@ import type {
|
||||
AdminThemeSummaryRetrieveListResponse,
|
||||
ThemeCreateRequest,
|
||||
ThemeCreateRequestV2, ThemeCreateResponse,
|
||||
ThemeCreateResponseV2, ThemeRetrieveListResponse,
|
||||
ThemeCreateResponseV2, ThemeListRetrieveRequest, ThemeRetrieveListResponse,
|
||||
ThemeRetrieveListResponseV2,
|
||||
ThemeUpdateRequest,
|
||||
UserThemeRetrieveListResponse
|
||||
} from './themeTypes';
|
||||
@ -48,3 +49,7 @@ export const deleteTheme = async (id: string): Promise<void> => {
|
||||
export const fetchUserThemes = async (): Promise<UserThemeRetrieveListResponse> => {
|
||||
return await apiClient.get<UserThemeRetrieveListResponse>('/v2/themes');
|
||||
};
|
||||
|
||||
export const findThemesByIds = async (request: ThemeListRetrieveRequest): Promise<ThemeRetrieveListResponseV2> => {
|
||||
return await apiClient.post<ThemeRetrieveListResponseV2>('/themes/retrieve', request);
|
||||
};
|
||||
|
||||
@ -123,6 +123,28 @@ export interface UserThemeRetrieveListResponse {
|
||||
themes: UserThemeRetrieveResponse[];
|
||||
}
|
||||
|
||||
export interface ThemeListRetrieveRequest {
|
||||
themeIds: string[];
|
||||
}
|
||||
|
||||
export interface ThemeRetrieveResponseV2 {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnailUrl: string;
|
||||
description: string;
|
||||
difficulty: Difficulty;
|
||||
price: number;
|
||||
minParticipants: number;
|
||||
maxParticipants: number;
|
||||
availableMinutes: number;
|
||||
expectedMinutesFrom: number;
|
||||
expectedMinutesTo: number;
|
||||
}
|
||||
|
||||
export interface ThemeRetrieveListResponseV2 {
|
||||
themes: ThemeRetrieveResponseV2[];
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
export enum Difficulty {
|
||||
VERY_EASY = 'VERY_EASY',
|
||||
|
||||
214
frontend/src/css/admin-schedule-page.css
Normal file
214
frontend/src/css/admin-schedule-page.css
Normal file
@ -0,0 +1,214 @@
|
||||
.admin-schedule-container {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schedule-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.schedule-controls .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.schedule-controls .form-label {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.schedule-controls .form-input,
|
||||
.schedule-controls .form-select {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.editing-row td {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.editing-row .form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Styles for schedule details row */
|
||||
.schedule-details-row td {
|
||||
padding: 0;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.details-form-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.details-form-container .form-card {
|
||||
background-color: #fff;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.details-form-container .form-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.details-form-container .form-row {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.details-form-container .form-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.details-form-container .form-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.details-form-container .form-input,
|
||||
.details-form-container .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
box-sizing: border-box; /* Ensures padding/border are included in height */
|
||||
}
|
||||
|
||||
.details-form-container .button-group {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.audit-info {
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
margin-bottom: 1.5rem; /* Add margin to separate from buttons */
|
||||
}
|
||||
|
||||
.audit-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #343a40;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.audit-body p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.audit-body p strong {
|
||||
color: #212529;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
@ -40,45 +40,116 @@
|
||||
color: #191F28;
|
||||
}
|
||||
|
||||
/* Date Selector */
|
||||
.date-selector {
|
||||
/* Date Carousel */
|
||||
.date-carousel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.date-options-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: hidden;
|
||||
flex-grow: 1;
|
||||
justify-content: space-between;
|
||||
margin: 0px 15px;
|
||||
}
|
||||
|
||||
.carousel-arrow, .today-button {
|
||||
background-color: #F2F4F6;
|
||||
border: 1px solid #E5E8EB;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #4E5968;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.today-button {
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
width: auto;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.carousel-arrow:hover, .today-button:hover {
|
||||
background-color: #E5E8EB;
|
||||
}
|
||||
|
||||
.date-option {
|
||||
cursor: pointer;
|
||||
padding: 12px 16px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
border: 2px solid transparent;
|
||||
background-color: #F2F4F6;
|
||||
transition: all 0.2s ease-in-out;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.date-option:hover {
|
||||
background-color: #E5E8EB;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.date-option.active {
|
||||
background-color: #3182F6;
|
||||
color: #ffffff;
|
||||
border-color: #3182F6;
|
||||
font-weight: 600;
|
||||
border: 1px solid #007bff;
|
||||
background-color: #e7f3ff;
|
||||
}
|
||||
|
||||
.date-option .day-of-week {
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.date-option .day {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
.date-option.active .day-of-week {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.date-option .day-circle {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.date-option.active .day-circle {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.date-option.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.date-option.disabled .day-circle {
|
||||
background-color: #E5E8EB;
|
||||
color: #B0B8C1;
|
||||
}
|
||||
|
||||
|
||||
/* Theme List */
|
||||
.theme-list {
|
||||
display: grid;
|
||||
|
||||
@ -60,3 +60,20 @@
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* =================================== */
|
||||
/* Button Group */
|
||||
/* =================================== */
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: flex-end; /* Aligns buttons to the right by default */
|
||||
gap: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
.button-group.full-width {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.button-group.full-width .btn {
|
||||
flex-grow: 1;
|
||||
}
|
||||
@ -81,11 +81,15 @@ a {
|
||||
margin-top: 72px;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
margin: 10px 0px !important;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
justify-content: center !important;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@ const LoginPage: React.FC = () => {
|
||||
<div className="form-group">
|
||||
<input type="password" className="form-control" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="d-flex justify-content-between">
|
||||
<div className="button-group full-width">
|
||||
<button className="btn btn-outline-custom" onClick={() => navigate('/signup')}>Sign Up</button>
|
||||
<button className="btn btn-custom" onClick={handleLogin}>Login</button>
|
||||
</div>
|
||||
|
||||
@ -25,6 +25,7 @@ const AdminNavbar: React.FC = () => {
|
||||
<Link className="nav-link" to="/admin/waiting">대기</Link>
|
||||
<Link className="nav-link" to="/admin/theme">테마</Link>
|
||||
<Link className="nav-link" to="/admin/time">시간</Link>
|
||||
<Link className="nav-link" to="/admin/schedule">일정</Link>
|
||||
</div>
|
||||
<div className="nav-actions">
|
||||
{!loggedIn ? (
|
||||
|
||||
315
frontend/src/pages/admin/AdminSchedulePage.tsx
Normal file
315
frontend/src/pages/admin/AdminSchedulePage.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { createSchedule, deleteSchedule, findScheduleById, findSchedules, updateSchedule } from '@_api/schedule/scheduleAPI';
|
||||
import { ScheduleStatus, type ScheduleDetailRetrieveResponse, type ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
|
||||
import { fetchAdminThemes } from '@_api/theme/themeAPI';
|
||||
import type { AdminThemeSummaryRetrieveResponse } from '@_api/theme/themeTypes';
|
||||
import '@_css/admin-schedule-page.css';
|
||||
import React, { Fragment, useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
const getScheduleStatusText = (status: ScheduleStatus): string => {
|
||||
switch (status) {
|
||||
case ScheduleStatus.AVAILABLE:
|
||||
return '예약 가능';
|
||||
case ScheduleStatus.PENDING:
|
||||
return '예약 진행 중';
|
||||
case ScheduleStatus.RESERVED:
|
||||
return '예약 완료';
|
||||
case ScheduleStatus.BLOCKED:
|
||||
return '예약 불가';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const AdminSchedulePage: React.FC = () => {
|
||||
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
|
||||
const [themes, setThemes] = useState<AdminThemeSummaryRetrieveResponse[]>([]);
|
||||
const [selectedThemeId, setSelectedThemeId] = useState<string>('');
|
||||
const [selectedDate, setSelectedDate] = useState<string>(new Date().toLocaleDateString('en-CA'));
|
||||
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newScheduleTime, setNewScheduleTime] = useState('');
|
||||
|
||||
const [expandedScheduleId, setExpandedScheduleId] = useState<string | null>(null);
|
||||
const [detailedSchedules, setDetailedSchedules] = useState<{ [key: string]: ScheduleDetailRetrieveResponse }>({});
|
||||
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editingSchedule, setEditingSchedule] = useState<ScheduleDetailRetrieveResponse | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleError = (err: any) => {
|
||||
if (isLoginRequiredError(err)) {
|
||||
alert('로그인이 필요해요.');
|
||||
navigate('/login', { state: { from: location } });
|
||||
} else {
|
||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||
alert(message);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdminThemes()
|
||||
.then(res => {
|
||||
setThemes(res.themes);
|
||||
if (res.themes.length > 0) {
|
||||
setSelectedThemeId(String(res.themes[0].id));
|
||||
}
|
||||
})
|
||||
.catch(handleError);
|
||||
}, []);
|
||||
|
||||
const fetchSchedules = () => {
|
||||
if (selectedDate && selectedThemeId) {
|
||||
findSchedules(selectedDate, selectedThemeId)
|
||||
.then(res => setSchedules(res.schedules))
|
||||
.catch(err => {
|
||||
setSchedules([]);
|
||||
if (err.response?.status !== 404) {
|
||||
handleError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSchedules();
|
||||
}, [selectedDate, selectedThemeId]);
|
||||
|
||||
const handleAddSchedule = async () => {
|
||||
if (!newScheduleTime) {
|
||||
alert('시간을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!/\d{2}:\d{2}/.test(newScheduleTime)) {
|
||||
alert('시간 형식이 올바르지 않습니다. HH:MM 형식으로 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createSchedule({
|
||||
date: selectedDate,
|
||||
themeId: selectedThemeId,
|
||||
time: newScheduleTime,
|
||||
});
|
||||
fetchSchedules();
|
||||
setIsAdding(false);
|
||||
setNewScheduleTime('');
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSchedule = async (scheduleId: string) => {
|
||||
if (window.confirm('정말 이 일정을 삭제하시겠습니까?')) {
|
||||
try {
|
||||
await deleteSchedule(scheduleId);
|
||||
setSchedules(schedules.filter(s => s.id !== scheduleId));
|
||||
setExpandedScheduleId(null); // Close the details view after deletion
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleDetails = async (scheduleId: string) => {
|
||||
const isAlreadyExpanded = expandedScheduleId === scheduleId;
|
||||
setIsEditing(false); // Reset editing state whenever toggling
|
||||
if (isAlreadyExpanded) {
|
||||
setExpandedScheduleId(null);
|
||||
} else {
|
||||
setExpandedScheduleId(scheduleId);
|
||||
if (!detailedSchedules[scheduleId]) {
|
||||
setIsLoadingDetails(true);
|
||||
try {
|
||||
const details = await findScheduleById(scheduleId);
|
||||
setDetailedSchedules(prev => ({ ...prev, [scheduleId]: details }));
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
} finally {
|
||||
setIsLoadingDetails(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
if (expandedScheduleId && detailedSchedules[expandedScheduleId]) {
|
||||
setEditingSchedule({ ...detailedSchedules[expandedScheduleId] });
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
setEditingSchedule(null);
|
||||
};
|
||||
|
||||
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
if (editingSchedule) {
|
||||
setEditingSchedule({ ...editingSchedule, [name]: value });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingSchedule) return;
|
||||
|
||||
try {
|
||||
await updateSchedule(editingSchedule.id, {
|
||||
time: editingSchedule.time,
|
||||
status: editingSchedule.status,
|
||||
});
|
||||
// Refresh data
|
||||
const details = await findScheduleById(editingSchedule.id);
|
||||
setDetailedSchedules(prev => ({ ...prev, [editingSchedule.id]: details }));
|
||||
setSchedules(schedules.map(s => s.id === editingSchedule.id ? { ...s, time: details.time, status: details.status } : s));
|
||||
|
||||
alert('일정이 성공적으로 업데이트되었습니다.');
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-schedule-container">
|
||||
<h2 className="page-title">일정 관리</h2>
|
||||
|
||||
<div className="schedule-controls">
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="date-filter">날짜</label>
|
||||
<input
|
||||
id="date-filter"
|
||||
type="date"
|
||||
className="form-input"
|
||||
value={selectedDate}
|
||||
onChange={e => setSelectedDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="theme-filter">테마</label>
|
||||
<select
|
||||
id="theme-filter"
|
||||
className="form-select"
|
||||
value={selectedThemeId}
|
||||
onChange={e => setSelectedThemeId(e.target.value)}
|
||||
>
|
||||
{themes.map(theme => (
|
||||
<option key={theme.id} value={theme.id}>{theme.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-card">
|
||||
<div className="table-header">
|
||||
<button className="btn btn-primary" onClick={() => setIsAdding(true)}>일정 추가</button>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>상태</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{schedules.map(schedule => (
|
||||
<Fragment key={schedule.id}>
|
||||
<tr>
|
||||
<td>{schedule.time}</td>
|
||||
<td>{getScheduleStatusText(schedule.status)}</td>
|
||||
<td className="action-buttons">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handleToggleDetails(schedule.id)}
|
||||
>
|
||||
{expandedScheduleId === schedule.id ? '닫기' : '상세'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedScheduleId === schedule.id && (
|
||||
<tr className="schedule-details-row">
|
||||
<td colSpan={3}>
|
||||
{isLoadingDetails ? (
|
||||
<p>로딩 중...</p>
|
||||
) : detailedSchedules[schedule.id] ? (
|
||||
<div className="details-form-container">
|
||||
<div className="audit-info">
|
||||
<h4 className="audit-title">감사 정보</h4>
|
||||
<div className="audit-body">
|
||||
<p><strong>생성일:</strong> {new Date(detailedSchedules[schedule.id].createdAt).toLocaleString()}</p>
|
||||
<p><strong>수정일:</strong> {new Date(detailedSchedules[schedule.id].updatedAt).toLocaleString()}</p>
|
||||
<p><strong>생성자:</strong> {detailedSchedules[schedule.id].createdBy}</p>
|
||||
<p><strong>수정자:</strong> {detailedSchedules[schedule.id].updatedBy}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing && editingSchedule ? (
|
||||
// --- EDIT MODE ---
|
||||
<div className="form-card">
|
||||
<div className="form-section">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">시간</label>
|
||||
<input type="time" name="time" className="form-input" value={editingSchedule.time} onChange={handleEditChange} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">상태</label>
|
||||
<select name="status" className="form-select" value={editingSchedule.status} onChange={handleEditChange}>
|
||||
{Object.values(ScheduleStatus).map(s => <option key={s} value={s}>{getScheduleStatusText(s)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="button-group">
|
||||
<button type="button" className="btn btn-secondary" onClick={handleCancelEdit}>취소</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleSave}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// --- VIEW MODE ---
|
||||
<div className="button-group view-mode-buttons">
|
||||
<button type="button" className="btn btn-danger" onClick={() => handleDeleteSchedule(schedule.id)}>삭제</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleEditClick}>수정</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p>상세 정보를 불러올 수 없습니다.</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{isAdding && (
|
||||
<tr className="editing-row">
|
||||
<td>
|
||||
<input
|
||||
type="time"
|
||||
className="form-input"
|
||||
value={newScheduleTime}
|
||||
onChange={e => setNewScheduleTime(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td></td>
|
||||
<td className="action-buttons">
|
||||
<button className="btn btn-primary" onClick={handleAddSchedule}>저장</button>
|
||||
<button className="btn btn-secondary" onClick={() => setIsAdding(false)}>취소</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSchedulePage;
|
||||
@ -1,18 +1,18 @@
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { createPendingReservation } from '@_api/reservation/reservationAPI';
|
||||
import { fetchUserThemes } from '@_api/theme/themeAPI';
|
||||
import { fetchTimesWithAvailability } from '@_api/time/timeAPI';
|
||||
import type { TimeWithAvailabilityResponse } from '@_api/time/timeTypes';
|
||||
import { findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI';
|
||||
import type { ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
|
||||
import { findThemesByIds } from '@_api/theme/themeAPI';
|
||||
import { Difficulty } from '@_api/theme/themeTypes';
|
||||
import '@_css/reservation-v2-1.css';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
|
||||
|
||||
// New theme type based on the provided schema
|
||||
interface ThemeV21 {
|
||||
id: string; // Changed to number to match API
|
||||
id: string;
|
||||
name: string;
|
||||
difficulty: string;
|
||||
difficulty: Difficulty;
|
||||
description: string;
|
||||
thumbnailUrl: string;
|
||||
price: number;
|
||||
@ -23,13 +23,30 @@ interface ThemeV21 {
|
||||
availableMinutes: number;
|
||||
}
|
||||
|
||||
const getDifficultyText = (difficulty: Difficulty): string => {
|
||||
switch (difficulty) {
|
||||
case Difficulty.VERY_EASY:
|
||||
return '매우 쉬움';
|
||||
case Difficulty.EASY:
|
||||
return '쉬움';
|
||||
case Difficulty.NORMAL:
|
||||
return '보통';
|
||||
case Difficulty.HARD:
|
||||
return '어려움';
|
||||
case Difficulty.VERY_HARD:
|
||||
return '매우 어려움';
|
||||
default:
|
||||
return difficulty;
|
||||
}
|
||||
};
|
||||
|
||||
const ReservationStep1PageV21: React.FC = () => {
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
const [viewDate, setViewDate] = useState<Date>(new Date()); // For carousel
|
||||
const [themes, setThemes] = useState<ThemeV21[]>([]);
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeV21 | null>(null);
|
||||
const [times, setTimes] = useState<TimeWithAvailabilityResponse[]>([]);
|
||||
const [selectedTime, setSelectedTime] = useState<TimeWithAvailabilityResponse | null>(null);
|
||||
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
|
||||
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleRetrieveResponse | null>(null);
|
||||
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
||||
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
@ -47,49 +64,47 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await fetchUserThemes();
|
||||
// Map UserThemeRetrieveResponse to ThemeV21
|
||||
const mappedThemes: ThemeV21[] = response.themes.map(theme => ({
|
||||
id: theme.id,
|
||||
name: theme.name,
|
||||
difficulty: theme.difficulty,
|
||||
description: theme.description,
|
||||
thumbnailUrl: theme.thumbnailUrl,
|
||||
price: theme.price,
|
||||
minParticipants: theme.minParticipants,
|
||||
maxParticipants: theme.maxParticipants,
|
||||
expectedMinutesFrom: theme.expectedMinutesFrom,
|
||||
expectedMinutesTo: theme.expectedMinutesTo,
|
||||
availableMinutes: theme.availableMinutes,
|
||||
}));
|
||||
setThemes(mappedThemes);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
if (selectedDate) {
|
||||
const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd
|
||||
findAvailableThemesByDate(dateStr)
|
||||
.then(res => {
|
||||
const themeIds: string[] = res.themeIds;
|
||||
if (themeIds.length > 0) {
|
||||
return findThemesByIds({ themeIds });
|
||||
} else {
|
||||
return Promise.resolve({ themes: [] });
|
||||
}
|
||||
})
|
||||
.then(themeResponse => {
|
||||
setThemes(themeResponse.themes as ThemeV21[]);
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
setSelectedTheme(null);
|
||||
setSchedules([]);
|
||||
setSelectedSchedule(null);
|
||||
});
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && selectedTheme) {
|
||||
const dateStr = selectedDate.toLocaleDateString('en-CA');
|
||||
fetchTimesWithAvailability(dateStr, selectedTheme.id)
|
||||
findSchedules(dateStr, selectedTheme.id)
|
||||
.then(res => {
|
||||
setTimes(res.times);
|
||||
setSelectedTime(null);
|
||||
setSchedules(res.schedules);
|
||||
setSelectedSchedule(null);
|
||||
})
|
||||
.catch(handleError);
|
||||
}
|
||||
}, [selectedDate, selectedTheme]);
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (!selectedDate || !selectedTheme || !selectedTime) {
|
||||
if (!selectedDate || !selectedTheme || !selectedSchedule) {
|
||||
alert('날짜, 테마, 시간을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!selectedTime.isAvailable) {
|
||||
if (selectedSchedule.status !== 'AVAILABLE') {
|
||||
alert('예약할 수 없는 시간입니다.');
|
||||
return;
|
||||
}
|
||||
@ -97,45 +112,85 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleConfirmPayment = () => {
|
||||
if (!selectedDate || !selectedTheme || !selectedTime) return;
|
||||
if (!selectedDate || !selectedTheme || !selectedSchedule) return;
|
||||
|
||||
const reservationData = {
|
||||
date: selectedDate.toLocaleDateString('en-CA'),
|
||||
themeId: selectedTheme.id,
|
||||
timeId: selectedTime.id,
|
||||
scheduleId: selectedSchedule.id,
|
||||
};
|
||||
|
||||
createPendingReservation(reservationData)
|
||||
.then((res) => {
|
||||
navigate('/v2/reservation/payment', { state: { reservation: res } });
|
||||
navigate('/v2-1/reservation/payment', { state: { reservation: res } });
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => setIsModalOpen(false));
|
||||
|
||||
.finally(() => setIsConfirmModalOpen(false));
|
||||
};
|
||||
|
||||
const renderDateOptions = () => {
|
||||
const handleDateSelect = (date: Date) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (date < today) {
|
||||
alert("지난 날짜는 선택할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
setSelectedDate(date);
|
||||
}
|
||||
|
||||
const renderDateCarousel = () => {
|
||||
const dates = [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() + i);
|
||||
const date = new Date(viewDate);
|
||||
date.setDate(viewDate.getDate() + i);
|
||||
dates.push(date);
|
||||
}
|
||||
|
||||
return dates.map(date => {
|
||||
const isSelected = selectedDate.toDateString() === date.toDateString();
|
||||
return (
|
||||
<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>
|
||||
const handlePrev = () => {
|
||||
const newViewDate = new Date(viewDate);
|
||||
newViewDate.setDate(viewDate.getDate() - 1);
|
||||
if (newViewDate < today) {
|
||||
alert("지난 날짜는 조회할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
setViewDate(newViewDate);
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
const newViewDate = new Date(viewDate);
|
||||
newViewDate.setDate(viewDate.getDate() + 1);
|
||||
setViewDate(newViewDate);
|
||||
}
|
||||
|
||||
const goToToday = () => {
|
||||
setViewDate(new Date());
|
||||
setSelectedDate(new Date());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="date-carousel">
|
||||
<button onClick={handlePrev} className="carousel-arrow">‹</button>
|
||||
<div className="date-options-container">
|
||||
{dates.map(date => {
|
||||
const isSelected = selectedDate.toDateString() === date.toDateString();
|
||||
const isPast = date < today;
|
||||
return (
|
||||
<div
|
||||
key={date.toISOString()}
|
||||
className={`date-option ${isSelected ? 'active' : ''} ${isPast ? 'disabled' : ''}`}
|
||||
onClick={() => handleDateSelect(date)}
|
||||
>
|
||||
<div className="day-of-week">{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}</div>
|
||||
<div className="day-circle">{date.getDate()}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
<button onClick={handleNext} className="carousel-arrow">›</button>
|
||||
<button onClick={goToToday} className="today-button">오늘</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const openThemeModal = (theme: ThemeV21) => {
|
||||
@ -143,7 +198,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
setIsThemeModalOpen(true);
|
||||
};
|
||||
|
||||
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || !selectedTime.isAvailable;
|
||||
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== 'AVAILABLE';
|
||||
|
||||
return (
|
||||
<div className="reservation-v21-container">
|
||||
@ -151,7 +206,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
|
||||
<div className="step-section">
|
||||
<h3>1. 날짜 선택</h3>
|
||||
<div className="date-selector">{renderDateOptions()}</div>
|
||||
{renderDateCarousel()}
|
||||
</div>
|
||||
|
||||
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
|
||||
@ -166,9 +221,9 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
<div className="theme-info">
|
||||
<h4>{theme.name}</h4>
|
||||
<div className="theme-meta">
|
||||
<p><strong>난이도:</strong> {theme.difficulty}</p>
|
||||
<p><strong>참여 인원:</strong> {theme.minParticipants} ~ {theme.maxParticipants}명</p>
|
||||
<p><strong>가격:</strong> {theme.price.toLocaleString()}원</p>
|
||||
<p><strong>난이도:</strong> {getDifficultyText(theme.difficulty)}</p>
|
||||
<p><strong>참여 가능 인원:</strong> {theme.minParticipants} ~ {theme.maxParticipants}명</p>
|
||||
<p><strong>예상 소요 시간:</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}분</p>
|
||||
<p><strong>이용 가능 시간:</strong> {theme.availableMinutes}분</p>
|
||||
</div>
|
||||
@ -182,14 +237,14 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
<div className={`step-section ${!selectedTheme ? 'disabled' : ''}`}>
|
||||
<h3>3. 시간 선택</h3>
|
||||
<div className="time-slots">
|
||||
{times.length > 0 ? times.map(time => (
|
||||
{schedules.length > 0 ? schedules.map(schedule => (
|
||||
<div
|
||||
key={time.id}
|
||||
className={`time-slot ${selectedTime?.id === time.id ? 'active' : ''} ${!time.isAvailable ? 'disabled' : ''}`}
|
||||
onClick={() => time.isAvailable && setSelectedTime(time)}
|
||||
key={schedule.id}
|
||||
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== 'AVAILABLE' ? 'disabled' : ''}`}
|
||||
onClick={() => schedule.status === 'AVAILABLE' && setSelectedSchedule(schedule)}
|
||||
>
|
||||
{time.startAt}
|
||||
<span className="time-availability">{time.isAvailable ? '예약가능' : '예약불가'}</span>
|
||||
{schedule.time}
|
||||
<span className="time-availability">{schedule.status === 'AVAILABLE' ? '예약가능' : '예약불가'}</span>
|
||||
</div>
|
||||
)) : <div className="no-times">선택 가능한 시간이 없습니다.</div>}
|
||||
</div>
|
||||
@ -209,7 +264,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
<h2>{selectedTheme.name}</h2>
|
||||
<div className="modal-section">
|
||||
<h3>테마 정보</h3>
|
||||
<p><strong>난이도:</strong> {selectedTheme.difficulty}</p>
|
||||
<p><strong>난이도:</strong> {getDifficultyText(selectedTheme.difficulty)}</p>
|
||||
<p><strong>참여 인원:</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</p>
|
||||
<p><strong>소요 시간:</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</p>
|
||||
<p><strong>가격:</strong> {selectedTheme.price.toLocaleString()}원</p>
|
||||
@ -230,7 +285,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
||||
<div className="modal-section">
|
||||
<p><strong>날짜:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
|
||||
<p><strong>테마:</strong> {selectedTheme!!.name}</p>
|
||||
<p><strong>시간:</strong> {formatTime(selectedTime!!.startAt)}</p>
|
||||
<p><strong>시간:</strong> {formatTime(selectedSchedule!!.time)}</p>
|
||||
<p><strong>결제금액:</strong> {selectedTheme!!.price.toLocaleString()}원</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
|
||||
@ -30,7 +30,6 @@ class JacksonConfig {
|
||||
.registerModule(javaTimeModule())
|
||||
.registerModule(dateTimeModule())
|
||||
.registerModule(kotlinModule())
|
||||
.registerModule(longIdModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
|
||||
private fun javaTimeModule(): JavaTimeModule = JavaTimeModule()
|
||||
@ -51,13 +50,6 @@ class JacksonConfig {
|
||||
LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm"))
|
||||
) as JavaTimeModule
|
||||
|
||||
private fun longIdModule(): SimpleModule {
|
||||
val simpleModule = SimpleModule()
|
||||
simpleModule.addSerializer(Long::class.java, LongToStringSerializer())
|
||||
simpleModule.addDeserializer(Long::class.java, StringToLongDeserializer())
|
||||
return simpleModule
|
||||
}
|
||||
|
||||
private fun dateTimeModule(): SimpleModule {
|
||||
val simpleModule = SimpleModule()
|
||||
simpleModule.addSerializer(LocalDateTime::class.java, LocalDateTimeSerializer())
|
||||
|
||||
@ -24,7 +24,7 @@ class ControllerLoggingAspect(
|
||||
private val messageConverter: ApiLogMessageConverter,
|
||||
) {
|
||||
|
||||
@Pointcut("execution(* roomescape..web..*Controller.*(..))")
|
||||
@Pointcut("execution(* roomescape..web..*Controller*.*(..))")
|
||||
fun allController() {
|
||||
}
|
||||
|
||||
|
||||
130
src/main/kotlin/roomescape/schedule/business/ScheduleService.kt
Normal file
130
src/main/kotlin/roomescape/schedule/business/ScheduleService.kt
Normal file
@ -0,0 +1,130 @@
|
||||
package roomescape.schedule.business
|
||||
|
||||
import ScheduleException
|
||||
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.schedule.exception.ScheduleErrorCode
|
||||
import roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||
import roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
||||
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||
import roomescape.schedule.web.*
|
||||
import java.time.LocalDate
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Service
|
||||
class ScheduleService(
|
||||
private val scheduleRepository: ScheduleRepository,
|
||||
private val scheduleValidator: ScheduleValidator,
|
||||
private val tsidFactory: TsidFactory,
|
||||
private val memberService: MemberService
|
||||
) {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findThemesByDate(date: LocalDate): AvailableThemeIdListResponse {
|
||||
log.info { "[ScheduleService.findThemesByDate] 동일한 날짜의 모든 테마 조회: date=$date" }
|
||||
|
||||
return scheduleRepository.findAllByDate(date)
|
||||
.toThemeIdListResponse()
|
||||
.also {
|
||||
log.info { "[ScheduleService.findThemesByDate] date=${date} 인 ${it.themeIds.size}개 테마 조회 완료" }
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findSchedules(date: LocalDate, themeId: Long): ScheduleRetrieveListResponse {
|
||||
log.info { "[ScheduleService.findSchedules] 동일한 날짜와 테마인 모든 일정 조회: date=${date}, themeId=${themeId}" }
|
||||
|
||||
return scheduleRepository.findAllByDateAndThemeId(date, themeId)
|
||||
.toRetrieveListResponse()
|
||||
.also {
|
||||
log.info { "[ScheduleService.findSchedules] date=${date}, themeId=${themeId} 인 ${it.schedules.size}개 일정 조회 완료" }
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findDetail(id: Long): ScheduleDetailRetrieveResponse {
|
||||
log.info { "[ScheduleService.findDetail] 일정 상세 정보조회 시작: id=$id" }
|
||||
|
||||
val schedule: ScheduleEntity = findOrThrow(id)
|
||||
|
||||
val createdBy = memberService.findById(schedule.createdBy).name
|
||||
val updatedBy = memberService.findById(schedule.updatedBy).name
|
||||
|
||||
return schedule.toDetailRetrieveResponse(createdBy, updatedBy)
|
||||
.also {
|
||||
log.info { "[ScheduleService.findDetail] 일정 상세 조회 완료: id=$id" }
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createSchedule(request: ScheduleCreateRequest): ScheduleCreateResponse {
|
||||
log.info { "[ScheduleService.createSchedule] 일정 생성 시작: date=${request.date}, time=${request.time}, themeId=${request.themeId}" }
|
||||
|
||||
scheduleValidator.validateCanCreate(request)
|
||||
|
||||
val schedule = ScheduleEntity(
|
||||
id = tsidFactory.next(),
|
||||
date = request.date,
|
||||
time = request.time,
|
||||
themeId = request.themeId,
|
||||
status = ScheduleStatus.AVAILABLE
|
||||
)
|
||||
|
||||
return ScheduleCreateResponse(scheduleRepository.save(schedule).id)
|
||||
.also {
|
||||
log.info { "[ScheduleService.createSchedule] 일정 생성 완료: id=${it.id}" }
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
|
||||
log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
|
||||
|
||||
if (request.isAllParamsNull()) {
|
||||
log.info { "[ScheduleService.updateSchedule] 일정 변경 사항 없음: id=$id" }
|
||||
return
|
||||
}
|
||||
|
||||
val schedule: ScheduleEntity = findOrThrow(id)
|
||||
|
||||
scheduleValidator.validateCanUpdate(schedule, request)
|
||||
|
||||
schedule.modifyIfNotNull(
|
||||
request.time,
|
||||
request.status
|
||||
).also {
|
||||
log.info { "[ScheduleService.updateSchedule] 일정 수정 완료: id=$id, request=${request}" }
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteSchedule(id: Long) {
|
||||
log.info { "[ScheduleService.deleteSchedule] 일정 삭제 시작: id=$id" }
|
||||
|
||||
val schedule: ScheduleEntity = findOrThrow(id)
|
||||
|
||||
scheduleValidator.validateCanDelete(schedule)
|
||||
|
||||
scheduleRepository.delete(schedule).also {
|
||||
log.info { "[ScheduleService.deleteSchedule] 일정 삭제 완료: id=$id" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun findOrThrow(id: Long): ScheduleEntity {
|
||||
log.info { "[ScheduleService.findOrThrow] 일정 조회 시작: id=$id" }
|
||||
|
||||
return scheduleRepository.findByIdOrNull(id)
|
||||
?.also { log.info { "[ScheduleService.findOrThrow] 일정 조회 완료: id=$id" } }
|
||||
?: run {
|
||||
log.warn { "[ScheduleService.updateSchedule] 일정 조회 실패. id=$id" }
|
||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
package roomescape.schedule.business
|
||||
|
||||
import ScheduleException
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Component
|
||||
import roomescape.schedule.exception.ScheduleErrorCode
|
||||
import roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||
import roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
||||
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||
import roomescape.schedule.web.ScheduleCreateRequest
|
||||
import roomescape.schedule.web.ScheduleUpdateRequest
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class ScheduleValidator(
|
||||
private val scheduleRepository: ScheduleRepository
|
||||
) {
|
||||
fun validateCanDelete(schedule: ScheduleEntity) {
|
||||
val status: ScheduleStatus = schedule.status
|
||||
|
||||
if (status !in listOf(ScheduleStatus.AVAILABLE,ScheduleStatus.BLOCKED)) {
|
||||
log.info { "[ScheduleValidator.validateCanDelete] 삭제 실패: id=${schedule.id} / status=${status}" }
|
||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE)
|
||||
}
|
||||
}
|
||||
|
||||
fun validateCanUpdate(schedule: ScheduleEntity, request: ScheduleUpdateRequest) {
|
||||
val date: LocalDate = schedule.date
|
||||
val time: LocalTime = request.time ?: schedule.time
|
||||
|
||||
validateDateTime(date, time)
|
||||
}
|
||||
|
||||
fun validateCanCreate(request: ScheduleCreateRequest) {
|
||||
val date: LocalDate = request.date
|
||||
val time: LocalTime = request.time
|
||||
val themeId: Long = request.themeId
|
||||
|
||||
validateAlreadyExists(date, themeId, time)
|
||||
validateDateTime(date, time)
|
||||
}
|
||||
|
||||
private fun validateAlreadyExists(date: LocalDate, themeId: Long, time: LocalTime) {
|
||||
if (scheduleRepository.existsByDateAndThemeIdAndTime(date, themeId, time)) {
|
||||
log.info {
|
||||
"[ScheduleValidator.validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}"
|
||||
}
|
||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateDateTime(date: LocalDate, time: LocalTime) {
|
||||
val dateTime = LocalDateTime.of(date, time)
|
||||
|
||||
if (dateTime.isBefore(LocalDateTime.now())) {
|
||||
log.info {
|
||||
"[ScheduleValidator.validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}"
|
||||
}
|
||||
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
|
||||
}
|
||||
}
|
||||
}
|
||||
69
src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt
Normal file
69
src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt
Normal file
@ -0,0 +1,69 @@
|
||||
package roomescape.schedule.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 jakarta.validation.Valid
|
||||
import org.springframework.format.annotation.DateTimeFormat
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import roomescape.auth.web.support.Admin
|
||||
import roomescape.auth.web.support.LoginRequired
|
||||
import roomescape.common.dto.response.CommonApiResponse
|
||||
import roomescape.schedule.web.*
|
||||
import java.time.LocalDate
|
||||
|
||||
interface ScheduleAPI {
|
||||
|
||||
@LoginRequired
|
||||
@Operation(summary = "입력된 날짜에 가능한 테마 목록 조회", tags = ["로그인이 필요한 API"])
|
||||
@ApiResponses(ApiResponse(responseCode = "200", description = "입력된 날짜에 가능한 테마 목록 조회", useReturnTypeSchema = true))
|
||||
fun findAvailableThemes(
|
||||
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate
|
||||
): ResponseEntity<CommonApiResponse<AvailableThemeIdListResponse>>
|
||||
|
||||
@LoginRequired
|
||||
@Operation(summary = "입력된 날짜, 테마에 대한 모든 시간 조회", tags = ["로그인이 필요한 API"])
|
||||
@ApiResponses(
|
||||
ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "입력된 날짜, 테마에 대한 모든 시간 조회",
|
||||
useReturnTypeSchema = true
|
||||
)
|
||||
)
|
||||
fun findAllTime(
|
||||
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate,
|
||||
@RequestParam("themeId") themeId: Long
|
||||
): ResponseEntity<CommonApiResponse<ScheduleRetrieveListResponse>>
|
||||
|
||||
@Admin
|
||||
@Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"])
|
||||
@ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true))
|
||||
fun findScheduleDetail(
|
||||
@PathVariable("id") id: Long
|
||||
): ResponseEntity<CommonApiResponse<ScheduleDetailRetrieveResponse>>
|
||||
|
||||
@Admin
|
||||
@Operation(summary = "일정 생성", tags = ["관리자 로그인이 필요한 API"])
|
||||
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||
fun createSchedule(
|
||||
@Valid @RequestBody request: ScheduleCreateRequest
|
||||
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>>
|
||||
|
||||
@Admin
|
||||
@Operation(summary = "일정 수정", tags = ["관리자 로그인이 필요한 API"])
|
||||
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||
fun updateSchedule(
|
||||
@PathVariable("id") id: Long,
|
||||
@Valid @RequestBody request: ScheduleUpdateRequest
|
||||
): ResponseEntity<CommonApiResponse<Unit>>
|
||||
|
||||
@Admin
|
||||
@Operation(summary = "일정 삭제", tags = ["관리자 로그인이 필요한 API"])
|
||||
@ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true))
|
||||
fun deleteSchedule(
|
||||
@PathVariable("id") id: Long
|
||||
): ResponseEntity<CommonApiResponse<Unit>>
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package roomescape.schedule.exception
|
||||
|
||||
import org.springframework.http.HttpStatus
|
||||
import roomescape.common.exception.ErrorCode
|
||||
|
||||
enum class ScheduleErrorCode(
|
||||
override val httpStatus: HttpStatus,
|
||||
override val errorCode: String,
|
||||
override val message: String
|
||||
) : ErrorCode {
|
||||
SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "S001", "일정을 찾을 수 없어요."),
|
||||
SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "S002", "이미 동일한 일정이 있어요."),
|
||||
PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."),
|
||||
SCHEDULE_IN_USE(HttpStatus.CONFLICT, "S004", "예약이 진행중이거나 완료된 일정은 삭제할 수 없어요."),
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import roomescape.common.exception.ErrorCode
|
||||
import roomescape.common.exception.RoomescapeException
|
||||
|
||||
class ScheduleException(
|
||||
override val errorCode: ErrorCode,
|
||||
override val message: String = errorCode.message
|
||||
) : RoomescapeException(errorCode, message)
|
||||
@ -0,0 +1,36 @@
|
||||
package roomescape.schedule.infrastructure.persistence
|
||||
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.EnumType
|
||||
import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.Table
|
||||
import jakarta.persistence.UniqueConstraint
|
||||
import roomescape.common.entity.AuditingBaseEntity
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
|
||||
@Entity
|
||||
@Table(name = "schedule", uniqueConstraints = [UniqueConstraint(columnNames = ["date", "time", "theme_id"])])
|
||||
class ScheduleEntity(
|
||||
id: Long,
|
||||
|
||||
var date: LocalDate,
|
||||
var time: LocalTime,
|
||||
var themeId: Long,
|
||||
|
||||
@Enumerated(value = EnumType.STRING)
|
||||
var status: ScheduleStatus
|
||||
) : AuditingBaseEntity(id) {
|
||||
|
||||
fun modifyIfNotNull(
|
||||
time: LocalTime?,
|
||||
status: ScheduleStatus?
|
||||
) {
|
||||
time?.let { this.time = it }
|
||||
status?.let { this.status = it }
|
||||
}
|
||||
}
|
||||
|
||||
enum class ScheduleStatus {
|
||||
AVAILABLE, PENDING, RESERVED, BLOCKED
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package roomescape.schedule.infrastructure.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
|
||||
interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
||||
|
||||
fun findAllByDate(date: LocalDate): List<ScheduleEntity>
|
||||
|
||||
fun findAllByDateAndThemeId(date: LocalDate, themeId: Long): List<ScheduleEntity>
|
||||
|
||||
fun existsByDateAndThemeIdAndTime(date: LocalDate, themeId: Long, time: LocalTime): Boolean
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package roomescape.schedule.web
|
||||
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.format.annotation.DateTimeFormat
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import roomescape.common.dto.response.CommonApiResponse
|
||||
import roomescape.schedule.business.ScheduleService
|
||||
import roomescape.schedule.docs.ScheduleAPI
|
||||
import java.time.LocalDate
|
||||
|
||||
@RestController
|
||||
class ScheduleController(
|
||||
private val scheduleService: ScheduleService
|
||||
) : ScheduleAPI {
|
||||
@GetMapping("/schedules/themes")
|
||||
override fun findAvailableThemes(
|
||||
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate
|
||||
): ResponseEntity<CommonApiResponse<AvailableThemeIdListResponse>> {
|
||||
val response = scheduleService.findThemesByDate(date)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
|
||||
@GetMapping("/schedules")
|
||||
override fun findAllTime(
|
||||
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate,
|
||||
@RequestParam("themeId") themeId: Long
|
||||
): ResponseEntity<CommonApiResponse<ScheduleRetrieveListResponse>> {
|
||||
val response = scheduleService.findSchedules(date, themeId)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
|
||||
@GetMapping("/schedules/{id}")
|
||||
override fun findScheduleDetail(
|
||||
@PathVariable("id") id: Long
|
||||
): ResponseEntity<CommonApiResponse<ScheduleDetailRetrieveResponse>> {
|
||||
val response = scheduleService.findDetail(id)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
|
||||
@PostMapping("/schedules")
|
||||
override fun createSchedule(
|
||||
@Valid @RequestBody request: ScheduleCreateRequest
|
||||
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>> {
|
||||
val response = scheduleService.createSchedule(request)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
|
||||
@PatchMapping("/schedules/{id}")
|
||||
override fun updateSchedule(
|
||||
@PathVariable("id") id: Long,
|
||||
@Valid @RequestBody request: ScheduleUpdateRequest
|
||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||
scheduleService.updateSchedule(id, request)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(Unit))
|
||||
}
|
||||
|
||||
@DeleteMapping("/schedules/{id}")
|
||||
override fun deleteSchedule(
|
||||
@PathVariable("id") id: Long
|
||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||
scheduleService.deleteSchedule(id)
|
||||
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
}
|
||||
68
src/main/kotlin/roomescape/schedule/web/ScheduleDto.kt
Normal file
68
src/main/kotlin/roomescape/schedule/web/ScheduleDto.kt
Normal file
@ -0,0 +1,68 @@
|
||||
package roomescape.schedule.web
|
||||
|
||||
import roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
|
||||
data class AvailableThemeIdListResponse(
|
||||
val themeIds: List<Long>
|
||||
)
|
||||
|
||||
fun List<ScheduleEntity>.toThemeIdListResponse() = AvailableThemeIdListResponse(this.map { it.themeId })
|
||||
|
||||
data class ScheduleRetrieveResponse(
|
||||
val id: Long,
|
||||
val time: LocalTime,
|
||||
val status: ScheduleStatus
|
||||
)
|
||||
|
||||
data class ScheduleRetrieveListResponse(
|
||||
val schedules: List<ScheduleRetrieveResponse>
|
||||
)
|
||||
|
||||
fun List<ScheduleEntity>.toRetrieveListResponse() = ScheduleRetrieveListResponse(
|
||||
this.map { ScheduleRetrieveResponse(it.id, it.time, it.status) }
|
||||
)
|
||||
|
||||
data class ScheduleCreateRequest(
|
||||
val date: LocalDate,
|
||||
val time: LocalTime,
|
||||
val themeId: Long
|
||||
)
|
||||
|
||||
data class ScheduleCreateResponse(
|
||||
val id: Long
|
||||
)
|
||||
|
||||
data class ScheduleUpdateRequest(
|
||||
val time: LocalTime? = null,
|
||||
val status: ScheduleStatus? = null
|
||||
) {
|
||||
fun isAllParamsNull(): Boolean {
|
||||
return time == null && status == null
|
||||
}
|
||||
}
|
||||
|
||||
data class ScheduleDetailRetrieveResponse(
|
||||
val id: Long,
|
||||
val date: LocalDate,
|
||||
val time: LocalTime,
|
||||
val status: ScheduleStatus,
|
||||
val createdAt: LocalDateTime,
|
||||
val createdBy: String,
|
||||
val updatedAt: LocalDateTime,
|
||||
val updatedBy: String,
|
||||
)
|
||||
|
||||
fun ScheduleEntity.toDetailRetrieveResponse(createdBy: String, updatedBy: String) = ScheduleDetailRetrieveResponse(
|
||||
id = this.id,
|
||||
date = this.date,
|
||||
time = this.time,
|
||||
status = this.status,
|
||||
createdAt = this.createdAt,
|
||||
createdBy = createdBy,
|
||||
updatedAt = this.updatedAt,
|
||||
updatedBy = updatedBy
|
||||
)
|
||||
@ -23,6 +23,16 @@ class ThemeServiceV2(
|
||||
private val memberService: MemberService,
|
||||
private val themeValidator: ThemeValidatorV2
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun findThemesByIds(request: ThemeListRetrieveRequest): ThemeRetrieveListResponseV2 {
|
||||
log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" }
|
||||
|
||||
return request.themeIds
|
||||
.map { findOrThrow(it) }
|
||||
.toRetrieveListResponse()
|
||||
.also { log.info { "[ThemeService.findThemesByIds] ${it.themes.size}개 테마 조회 완료" } }
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findThemesForReservation(): ThemeRetrieveListResponseV2 {
|
||||
log.info { "[ThemeService.findThemesForReservation] 예약 페이지에서의 테마 목록 조회 시작" }
|
||||
@ -43,19 +53,15 @@ class ThemeServiceV2(
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findAdminThemeDetail(id: Long): AdminThemeDetailRetrieveResponse {
|
||||
log.info { "[ThemeService.findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작" }
|
||||
log.info { "[ThemeService.findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
|
||||
|
||||
val theme = themeRepository.findByIdOrNull(id)
|
||||
?: run {
|
||||
log.warn { "[ThemeService.findAdminThemeDetail] 테마 조회 실패. id=$id" }
|
||||
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
|
||||
}
|
||||
val theme: ThemeEntityV2 = findOrThrow(id)
|
||||
|
||||
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}" } }
|
||||
.also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@ -69,36 +75,33 @@ class ThemeServiceV2(
|
||||
)
|
||||
|
||||
return ThemeCreateResponseV2(theme.id).also {
|
||||
log.info { "[ThemeService.createTheme] 테마 생성 완료. id=${theme.id}, name=${theme.name}" }
|
||||
log.info { "[ThemeService.createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" }
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteTheme(id: Long) {
|
||||
log.info { "[ThemeService.deleteTheme] 테마 삭제 시작" }
|
||||
log.info { "[ThemeService.deleteTheme] 테마 삭제 시작: id=${id}" }
|
||||
|
||||
val theme = themeRepository.findByIdOrNull(id)
|
||||
?: run {
|
||||
log.warn { "[ThemeService.deleteTheme] 테마 조회 실패. id=$id" }
|
||||
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
|
||||
}
|
||||
val theme: ThemeEntityV2 = findOrThrow(id)
|
||||
|
||||
themeRepository.delete(theme).also {
|
||||
log.info { "[ThemeService.deleteTheme] 테마 삭제 완료. id=$id, name=${theme.name}" }
|
||||
log.info { "[ThemeService.deleteTheme] 테마 삭제 완료: id=$id, name=${theme.name}" }
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateTheme(id: Long, request: ThemeUpdateRequest) {
|
||||
log.info { "[ThemeService.updateTheme] 테마 수정 시작" }
|
||||
log.info { "[ThemeService.updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
|
||||
|
||||
if (request.isAllParamsNull()) {
|
||||
log.info { "[ThemeService.updateTheme] 테마 변경 사항 없음: id=${id}" }
|
||||
return
|
||||
}
|
||||
|
||||
themeValidator.validateCanUpdate(request)
|
||||
|
||||
val theme: ThemeEntityV2 = themeRepository.findByIdOrNull(id)
|
||||
?: run {
|
||||
log.warn { "[ThemeService.updateTheme] 테마 조회 실패. id=$id" }
|
||||
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
|
||||
}
|
||||
val theme: ThemeEntityV2 = findOrThrow(id)
|
||||
|
||||
theme.modifyIfNotNull(
|
||||
request.name,
|
||||
@ -112,6 +115,19 @@ class ThemeServiceV2(
|
||||
request.expectedMinutesFrom,
|
||||
request.expectedMinutesTo,
|
||||
request.isOpen,
|
||||
)
|
||||
).also {
|
||||
log.info { "[ThemeService.updateTheme] 테마 수정 완료: id=$id, request=${request}" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun findOrThrow(id: Long): ThemeEntityV2 {
|
||||
log.info { "[ThemeService.findOrThrow] 테마 조회 시작: id=$id" }
|
||||
|
||||
return themeRepository.findByIdOrNull(id)
|
||||
?.also { log.info { "[ThemeService.findOrThrow] 테마 조회 완료: id=$id" } }
|
||||
?: run {
|
||||
log.warn { "[ThemeService.updateTheme] 테마 조회 실패: id=$id" }
|
||||
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ class ThemeValidatorV2(
|
||||
|
||||
fun validateCanCreate(request: ThemeCreateRequestV2) {
|
||||
if (themeRepository.existsByName(request.name)) {
|
||||
log.info { "[ThemeValidator.validateCanCreate] 이름 중복: name=${request.name}" }
|
||||
log.info { "[ThemeValidator.validateCanCreate] 이름 중복으로 인한 실패: name=${request.name}" }
|
||||
throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED)
|
||||
}
|
||||
|
||||
@ -55,7 +55,7 @@ class ThemeValidatorV2(
|
||||
maxParticipants: Short?,
|
||||
) {
|
||||
if (isNotNullAndBelowThan(price, MIN_PRICE)) {
|
||||
log.info { "[ThemeValidator.validateCanCreate] 최소 가격 미달: price=${price}" }
|
||||
log.info { "[ThemeValidator.validateCanCreate] 최소 가격 미달로 인한 실패로 인한 실패: price=${price}" }
|
||||
throw ThemeException(ThemeErrorCode.PRICE_BELOW_MINIMUM)
|
||||
}
|
||||
validateTimes(availableMinutes, expectedMinutesFrom, expectedMinutesTo)
|
||||
@ -72,18 +72,18 @@ class ThemeValidatorV2(
|
||||
|| isNotNullAndBelowThan(expectedMinutesTo, MIN_DURATION)
|
||||
) {
|
||||
log.info {
|
||||
"[ThemeValidator.validateTimes] 최소 시간 미달: availableMinutes=$availableMinutes" +
|
||||
"[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" }
|
||||
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" }
|
||||
log.info { "[ThemeValidator.validateTimes] 예상 시간의 이용 가능 시간 초과로 인한 실패: availableMinutes=$expectedMinutesFrom, expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" }
|
||||
throw ThemeException(ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME)
|
||||
}
|
||||
}
|
||||
@ -95,11 +95,11 @@ class ThemeValidatorV2(
|
||||
if (isNotNullAndBelowThan(minParticipants, MIN_PARTICIPANTS)
|
||||
|| isNotNullAndBelowThan(maxParticipants, MIN_PARTICIPANTS)
|
||||
) {
|
||||
log.info { "[ThemeValidator.validateParticipants] 최소 인원 미달: minParticipants=$minParticipants, maxParticipants=$maxParticipants" }
|
||||
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" }
|
||||
log.info { "[ThemeValidator.validateParticipants] 최소 인원의 최대 인원 초과로 인한 실패: minParticipants=$minParticipants, maxParticipants=$maxParticipants" }
|
||||
throw ThemeException(ThemeErrorCode.MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT)
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import roomescape.theme.web.AdminThemeDetailRetrieveResponse
|
||||
import roomescape.theme.web.AdminThemeSummaryRetrieveListResponse
|
||||
import roomescape.theme.web.ThemeCreateRequestV2
|
||||
import roomescape.theme.web.ThemeCreateResponseV2
|
||||
import roomescape.theme.web.ThemeListRetrieveRequest
|
||||
import roomescape.theme.web.ThemeUpdateRequest
|
||||
import roomescape.theme.web.ThemeRetrieveListResponseV2
|
||||
|
||||
@ -33,7 +34,7 @@ interface ThemeAPIV2 {
|
||||
|
||||
@Admin
|
||||
@Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"])
|
||||
@ApiResponses(ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true))
|
||||
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||
fun createTheme(@Valid @RequestBody themeCreateRequestV2: ThemeCreateRequestV2): ResponseEntity<CommonApiResponse<ThemeCreateResponseV2>>
|
||||
|
||||
@Admin
|
||||
@ -53,4 +54,9 @@ interface ThemeAPIV2 {
|
||||
@Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
|
||||
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||
fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>>
|
||||
|
||||
@LoginRequired
|
||||
@Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
|
||||
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||
fun findThemesByIds(request: ThemeListRetrieveRequest): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>>
|
||||
}
|
||||
|
||||
@ -12,6 +12,15 @@ class ThemeControllerV2(
|
||||
private val themeService: ThemeServiceV2,
|
||||
) : ThemeAPIV2 {
|
||||
|
||||
@PostMapping("/themes/retrieve")
|
||||
override fun findThemesByIds(
|
||||
@RequestBody request: ThemeListRetrieveRequest
|
||||
): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>> {
|
||||
val response = themeService.findThemesByIds(request)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
|
||||
@GetMapping("/v2/themes")
|
||||
override fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>> {
|
||||
val response = themeService.findThemesForReservation()
|
||||
|
||||
@ -49,7 +49,21 @@ data class ThemeUpdateRequest(
|
||||
val expectedMinutesFrom: Short? = null,
|
||||
val expectedMinutesTo: Short? = null,
|
||||
val isOpen: Boolean? = null,
|
||||
)
|
||||
) {
|
||||
fun isAllParamsNull(): Boolean {
|
||||
return name == null &&
|
||||
description == null &&
|
||||
thumbnailUrl == null &&
|
||||
difficulty == null &&
|
||||
price == null &&
|
||||
minParticipants == null &&
|
||||
maxParticipants == null &&
|
||||
availableMinutes == null &&
|
||||
expectedMinutesFrom == null &&
|
||||
expectedMinutesTo == null &&
|
||||
isOpen == null
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminThemeSummaryRetrieveResponse(
|
||||
val id: Long,
|
||||
@ -114,6 +128,10 @@ fun ThemeEntityV2.toAdminThemeDetailResponse(createUserName: String, updateUserN
|
||||
updatedBy = updateUserName
|
||||
)
|
||||
|
||||
data class ThemeListRetrieveRequest(
|
||||
val themeIds: List<Long>
|
||||
)
|
||||
|
||||
data class ThemeRetrieveResponseV2(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
|
||||
@ -16,11 +16,11 @@
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
|
||||
<logger name="roomescape" level="debug" additivity="false">
|
||||
<logger name="roomescape" level="info" additivity="false">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</logger>
|
||||
|
||||
<logger name="all-query-logger" level="debug" additivity="false">
|
||||
<logger name="all-query-logger" level="info" additivity="false">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</logger>
|
||||
</included>
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_region_sido_sigungu_dong ON region(sido_code, sigungu_code, dong_code);
|
||||
|
||||
INSERT INTO region (code, sido_code, sigungu_code, dong_code, sido_name, sigungu_name, dong_name)
|
||||
VALUES ('1111010100', '11', '110', '10100', '서울특별시', '종로구', '청운동'),
|
||||
('1111010200', '11', '110', '10200', '서울특별시', '종로구', '신교동'),
|
||||
|
||||
@ -49,6 +49,23 @@ create table if not exists theme (
|
||||
constraint fk_theme__updated_by foreign key (updated_by) references members (member_id)
|
||||
);
|
||||
|
||||
create table if not exists schedule (
|
||||
id bigint primary key,
|
||||
date date not null,
|
||||
time time not null,
|
||||
theme_id bigint not null,
|
||||
status varchar(30) not null,
|
||||
created_at timestamp not null,
|
||||
created_by bigint not null,
|
||||
updated_at timestamp not null,
|
||||
updated_by bigint not null,
|
||||
|
||||
constraint uk_schedule__date_time_theme_id unique (date, time, theme_id),
|
||||
constraint fk_schedule__created_by foreign key (created_by) references members (member_id),
|
||||
constraint fk_schedule__updated_by foreign key (updated_by) references members (member_id),
|
||||
constraint fk_schedule__theme_id foreign key (theme_id) references theme (id)
|
||||
);
|
||||
|
||||
create table if not exists times (
|
||||
time_id bigint primary key,
|
||||
start_at time not null,
|
||||
|
||||
@ -51,6 +51,23 @@ create table if not exists theme (
|
||||
constraint fk_theme__updated_by foreign key (updated_by) references members (member_id)
|
||||
);
|
||||
|
||||
create table if not exists schedule (
|
||||
id bigint primary key,
|
||||
date date not null,
|
||||
time time not null,
|
||||
theme_id bigint not null,
|
||||
status varchar(30) 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 uk_schedule__date_time_theme_id unique (date, time, theme_id),
|
||||
constraint fk_schedule__created_by foreign key (created_by) references members (member_id),
|
||||
constraint fk_schedule__updated_by foreign key (updated_by) references members (member_id),
|
||||
constraint fk_schedule__theme_id foreign key (theme_id) references theme (id)
|
||||
);
|
||||
|
||||
create table if not exists times
|
||||
(
|
||||
time_id bigint primary key,
|
||||
|
||||
569
src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt
Normal file
569
src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt
Normal file
@ -0,0 +1,569 @@
|
||||
package roomescape.schedule
|
||||
|
||||
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.schedule.exception.ScheduleErrorCode
|
||||
import roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||
import roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
||||
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||
import roomescape.schedule.web.ScheduleCreateRequest
|
||||
import roomescape.schedule.web.ScheduleUpdateRequest
|
||||
import roomescape.util.*
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
|
||||
class ScheduleApiTest(
|
||||
private val scheduleRepository: ScheduleRepository
|
||||
) : FunSpecSpringbootTest() {
|
||||
|
||||
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("일정 상세: GET /schedules/{id}") {
|
||||
runTest(
|
||||
token = token,
|
||||
on = {
|
||||
get("/schedules/1")
|
||||
},
|
||||
expect = commonAssertion
|
||||
)
|
||||
}
|
||||
|
||||
test("일정 생성: POST /schedules") {
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(ScheduleFixture.createRequest)
|
||||
},
|
||||
on = {
|
||||
get("/schedules/1")
|
||||
},
|
||||
expect = commonAssertion
|
||||
)
|
||||
}
|
||||
|
||||
test("일정 수정: PATCH /schedules/{id}") {
|
||||
val createdSchedule: ScheduleEntity =
|
||||
createDummySchedule(
|
||||
ScheduleFixture.createRequest
|
||||
)
|
||||
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(ScheduleFixture.createRequest)
|
||||
},
|
||||
on = {
|
||||
patch("/schedules/${createdSchedule.id}")
|
||||
},
|
||||
expect = commonAssertion
|
||||
)
|
||||
}
|
||||
|
||||
test("일정 삭제: DELETE /schedules/{id}") {
|
||||
val createdSchedule: ScheduleEntity =
|
||||
createDummySchedule(
|
||||
ScheduleFixture.createRequest
|
||||
)
|
||||
|
||||
runTest(
|
||||
token = token,
|
||||
on = {
|
||||
delete("/schedules/${createdSchedule.id}")
|
||||
},
|
||||
expect = commonAssertion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
context("일반 회원도 접근할 수 있다.") {
|
||||
lateinit var token: String
|
||||
|
||||
beforeTest {
|
||||
token = loginUtil.loginAsUser()
|
||||
}
|
||||
|
||||
test("예약 가능 테마 조회: GET /schedules/themes?date={date}") {
|
||||
val date = LocalDate.now().plusDays(1)
|
||||
val time = LocalTime.now()
|
||||
val createdSchedule: ScheduleEntity =
|
||||
createDummySchedule(
|
||||
ScheduleFixture.createRequest.copy(
|
||||
date = date,
|
||||
time = time
|
||||
)
|
||||
)
|
||||
|
||||
runTest(
|
||||
token = token,
|
||||
on = {
|
||||
get("/schedules/themes?date=$date")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.OK.value())
|
||||
body("data.themeIds.size()", equalTo(1))
|
||||
body("data.themeIds[0]", equalTo(createdSchedule.themeId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
test("동일한 날짜, 테마에 대한 시간 조회: GET /schedules?date={date}&themeId={themeId}") {
|
||||
val date = LocalDate.now().plusDays(1)
|
||||
val time = LocalTime.now()
|
||||
|
||||
val createdSchedule: ScheduleEntity =
|
||||
createDummySchedule(
|
||||
ScheduleFixture.createRequest.copy(
|
||||
date = date,
|
||||
time = time
|
||||
)
|
||||
)
|
||||
createDummySchedule(
|
||||
ScheduleFixture.createRequest.copy(
|
||||
date = date.plusDays(1L),
|
||||
time = time,
|
||||
themeId = createdSchedule.themeId
|
||||
)
|
||||
)
|
||||
|
||||
runTest(
|
||||
token = token,
|
||||
on = {
|
||||
get("/schedules?date=$date&themeId=${createdSchedule.themeId}")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.OK.value())
|
||||
body("data.schedules.size()", equalTo(1))
|
||||
body("data.schedules[0].id", equalTo(createdSchedule.id))
|
||||
assertProperties(
|
||||
props = setOf("id", "time", "status"),
|
||||
propsNameIfList = "schedules"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
context("특정 날짜에 예약 가능한 테마 목록을 조회한다.") {
|
||||
test("정상 응답") {
|
||||
val date = LocalDate.now().plusDays(1)
|
||||
for (i in 1..10) {
|
||||
createDummySchedule(
|
||||
ScheduleFixture.createRequest.copy(
|
||||
date = date,
|
||||
time = LocalTime.now().plusMinutes(i.toLong())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
runTest(
|
||||
token = loginUtil.loginAsUser(),
|
||||
on = {
|
||||
get("/schedules/themes?date=$date")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.OK.value())
|
||||
body("data.themeIds.size()", equalTo(10))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
context("동일한 날짜, 테마에 대한 모든 시간을 조회한다.") {
|
||||
test("정상 응답") {
|
||||
val date = LocalDate.now().plusDays(1)
|
||||
val createdSchedule = createDummySchedule(
|
||||
ScheduleFixture.createRequest.copy(
|
||||
date = date,
|
||||
time = LocalTime.now()
|
||||
)
|
||||
)
|
||||
for (i in 1..10) {
|
||||
createDummySchedule(
|
||||
ScheduleFixture.createRequest.copy(
|
||||
date = date,
|
||||
time = LocalTime.now().plusMinutes(i.toLong()),
|
||||
themeId = createdSchedule.themeId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
runTest(
|
||||
token = loginUtil.loginAsUser(),
|
||||
on = {
|
||||
get("/schedules?date=$date&themeId=${createdSchedule.themeId}")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.OK.value())
|
||||
body("data.schedules.size()", equalTo(11))
|
||||
assertProperties(
|
||||
props = setOf("id", "time", "status"),
|
||||
propsNameIfList = "schedules"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
context("관리자 페이지에서 특정 일정의 감사 정보를 조회한다.") {
|
||||
test("정상 응답") {
|
||||
val createdSchedule = createDummySchedule(ScheduleFixture.createRequest)
|
||||
|
||||
runTest(
|
||||
token = loginUtil.loginAsAdmin(),
|
||||
on = {
|
||||
get("/schedules/${createdSchedule.id}")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.OK.value())
|
||||
body("data.id", equalTo(createdSchedule.id))
|
||||
assertProperties(
|
||||
props = setOf(
|
||||
"id", "date", "time", "status",
|
||||
"createdAt", "createdBy", "updatedAt", "updatedBy",
|
||||
)
|
||||
)
|
||||
}
|
||||
).also {
|
||||
it.extract().path<String>("data.createdAt") shouldNotBeNull {}
|
||||
it.extract().path<String>("data.createdBy") shouldNotBeNull {}
|
||||
it.extract().path<String>("data.createdAt") shouldNotBeNull {}
|
||||
it.extract().path<String>("data.createdAt") shouldNotBeNull {}
|
||||
}
|
||||
}
|
||||
|
||||
test("일정이 없으면 실패한다.") {
|
||||
runTest(
|
||||
token = loginUtil.loginAsAdmin(),
|
||||
on = {
|
||||
get("/schedules/1")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.NOT_FOUND.value())
|
||||
body(
|
||||
"code",
|
||||
equalTo(ScheduleErrorCode.SCHEDULE_NOT_FOUND.errorCode)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
context("일정을 생성한다.") {
|
||||
lateinit var token: String
|
||||
|
||||
beforeTest {
|
||||
token = loginUtil.loginAsAdmin()
|
||||
}
|
||||
|
||||
test("정상 생성 및 감사 정보 확인") {
|
||||
/**
|
||||
* FK 제약조건 해소를 위한 테마 생성 API 호출 및 ID 획득
|
||||
*/
|
||||
val themeId: Long = Given {
|
||||
contentType(MediaType.APPLICATION_JSON_VALUE)
|
||||
header("Authorization", "Bearer $token")
|
||||
body(ThemeFixtureV2.createRequest.copy(name = "theme-${System.currentTimeMillis()}"))
|
||||
} When {
|
||||
post("/admin/themes")
|
||||
} Extract {
|
||||
path("data.id")
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 테스트
|
||||
*/
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(ScheduleFixture.createRequest.copy(themeId = themeId))
|
||||
},
|
||||
on = {
|
||||
post("/schedules")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.OK.value())
|
||||
body("data.id", notNullValue())
|
||||
}
|
||||
).also {
|
||||
val createdScheduleId: Long = it.extract().path("data.id")
|
||||
val createdSchedule: ScheduleEntity = scheduleRepository.findByIdOrNull(createdScheduleId)
|
||||
?: throw AssertionError("Unexpected Exception Occurred.")
|
||||
|
||||
createdSchedule.date shouldBe ScheduleFixture.createRequest.date
|
||||
createdSchedule.time.hour shouldBe ScheduleFixture.createRequest.time.hour
|
||||
createdSchedule.time.minute shouldBe ScheduleFixture.createRequest.time.minute
|
||||
createdSchedule.createdAt shouldNotBeNull {}
|
||||
createdSchedule.createdBy shouldNotBeNull {}
|
||||
createdSchedule.updatedAt shouldNotBeNull {}
|
||||
createdSchedule.updatedBy shouldNotBeNull {}
|
||||
}
|
||||
}
|
||||
|
||||
test("이미 동일한 날짜, 시간, 테마인 일정이 있으면 실패한다.") {
|
||||
val date = LocalDate.now().plusDays(1)
|
||||
val time = LocalTime.of(10, 0)
|
||||
|
||||
val alreadyCreated: ScheduleEntity = createDummySchedule(
|
||||
ScheduleFixture.createRequest.copy(
|
||||
date = date, time = time
|
||||
)
|
||||
)
|
||||
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(
|
||||
ScheduleFixture.createRequest.copy(
|
||||
date = date, time = time, themeId = alreadyCreated.themeId
|
||||
)
|
||||
)
|
||||
},
|
||||
on = {
|
||||
post("/schedules")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.CONFLICT.value())
|
||||
body("code", equalTo(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS.errorCode))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") {
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(
|
||||
ScheduleFixture.createRequest.copy(
|
||||
date = LocalDate.now(),
|
||||
time = LocalTime.now().minusMinutes(1)
|
||||
)
|
||||
)
|
||||
},
|
||||
on = {
|
||||
post("/schedules")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||
body("code", equalTo(ScheduleErrorCode.PAST_DATE_TIME.errorCode))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
context("일정을 수정한다.") {
|
||||
val updateRequest = ScheduleUpdateRequest(
|
||||
time = LocalTime.now().plusHours(1),
|
||||
status = ScheduleStatus.BLOCKED
|
||||
)
|
||||
|
||||
test("정상 수정 및 감사 정보 변경 확인") {
|
||||
val createdSchedule: ScheduleEntity = createDummySchedule(
|
||||
ScheduleFixture.createRequest.copy(
|
||||
date = LocalDate.now().plusDays(1),
|
||||
time = LocalTime.now().plusMinutes(1),
|
||||
)
|
||||
)
|
||||
val otherAdminToken = loginUtil.login("admin1@admin.com", "admin1", Role.ADMIN)
|
||||
|
||||
runTest(
|
||||
token = otherAdminToken,
|
||||
using = {
|
||||
body(updateRequest)
|
||||
},
|
||||
on = {
|
||||
patch("/schedules/${createdSchedule.id}")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.OK.value())
|
||||
}
|
||||
).also {
|
||||
val updatedSchedule = scheduleRepository.findByIdOrNull(createdSchedule.id)!!
|
||||
|
||||
updatedSchedule.id shouldBe createdSchedule.id
|
||||
updatedSchedule.time.hour shouldBe updateRequest.time!!.hour
|
||||
updatedSchedule.time.minute shouldBe updateRequest.time.minute
|
||||
updatedSchedule.status shouldBe updateRequest.status
|
||||
updatedSchedule.updatedBy shouldNotBe createdSchedule.updatedBy
|
||||
updatedSchedule.updatedAt shouldBeAfter createdSchedule.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
test("입력값이 없으면 수정하지 않는다.") {
|
||||
val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest)
|
||||
|
||||
runTest(
|
||||
token = loginUtil.loginAsAdmin(),
|
||||
using = {
|
||||
body(ScheduleUpdateRequest())
|
||||
},
|
||||
on = {
|
||||
patch("/schedules/${createdSchedule.id}")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.OK.value())
|
||||
}
|
||||
).also {
|
||||
val updatedSchedule = scheduleRepository.findByIdOrNull(createdSchedule.id)!!
|
||||
|
||||
updatedSchedule.id shouldBe createdSchedule.id
|
||||
updatedSchedule.updatedAt shouldBe createdSchedule.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
test("일정이 없으면 실패한다.") {
|
||||
runTest(
|
||||
token = loginUtil.loginAsAdmin(),
|
||||
using = {
|
||||
body(updateRequest)
|
||||
},
|
||||
on = {
|
||||
patch("/schedules/1")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.NOT_FOUND.value())
|
||||
body("code", equalTo(ScheduleErrorCode.SCHEDULE_NOT_FOUND.errorCode))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") {
|
||||
val createdSchedule: ScheduleEntity = createDummySchedule(
|
||||
ScheduleFixture.createRequest.copy(date = LocalDate.now(), time = LocalTime.now().plusMinutes(1))
|
||||
)
|
||||
|
||||
runTest(
|
||||
token = loginUtil.loginAsAdmin(),
|
||||
using = {
|
||||
body(
|
||||
updateRequest.copy(
|
||||
time = LocalTime.now().minusMinutes(1)
|
||||
)
|
||||
)
|
||||
},
|
||||
on = {
|
||||
patch("/schedules/${createdSchedule.id}")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||
body("code", equalTo(ScheduleErrorCode.PAST_DATE_TIME.errorCode))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
context("일정을 삭제한다.") {
|
||||
test("정상 삭제") {
|
||||
val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest)
|
||||
|
||||
runTest(
|
||||
token = loginUtil.loginAsAdmin(),
|
||||
on = {
|
||||
delete("/schedules/${createdSchedule.id}")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.NO_CONTENT.value())
|
||||
}
|
||||
).also {
|
||||
scheduleRepository.findByIdOrNull(createdSchedule.id) shouldBe null
|
||||
}
|
||||
}
|
||||
|
||||
test("예약 중이거나 예약이 완료된 일정이면 실패한다.") {
|
||||
val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest)
|
||||
|
||||
/*
|
||||
* 테스트를 위해 수정 API를 호출하여 상태를 예약 중 상태로 변경
|
||||
* 생성 API에서는 일정 생성 시 AVAILABLE을 기본 상태로 지정하기 때문.
|
||||
*/
|
||||
runTest(
|
||||
token = loginUtil.loginAsAdmin(),
|
||||
using = {
|
||||
body(
|
||||
ScheduleUpdateRequest(
|
||||
status = ScheduleStatus.RESERVED
|
||||
)
|
||||
)
|
||||
},
|
||||
on = {
|
||||
patch("/schedules/${createdSchedule.id}")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.OK.value())
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 삭제 테스트
|
||||
*/
|
||||
runTest(
|
||||
token = loginUtil.loginAsAdmin(),
|
||||
on = {
|
||||
delete("/schedules/${createdSchedule.id}")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.CONFLICT.value())
|
||||
body("code", equalTo(ScheduleErrorCode.SCHEDULE_IN_USE.errorCode))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createDummySchedule(request: ScheduleCreateRequest): ScheduleEntity {
|
||||
val token = loginUtil.loginAsAdmin()
|
||||
|
||||
val themeId: Long = if (request.themeId > 1L) {
|
||||
request.themeId
|
||||
} else {
|
||||
Given {
|
||||
contentType(MediaType.APPLICATION_JSON_VALUE)
|
||||
header("Authorization", "Bearer $token")
|
||||
body(ThemeFixtureV2.createRequest.copy(name = "theme-${System.currentTimeMillis()}"))
|
||||
} When {
|
||||
post("/admin/themes")
|
||||
} Extract {
|
||||
path("data.id")
|
||||
}
|
||||
}
|
||||
|
||||
val createdScheduleId: Long = Given {
|
||||
contentType(MediaType.APPLICATION_JSON_VALUE)
|
||||
header("Authorization", "Bearer $token")
|
||||
body(request.copy(themeId = themeId))
|
||||
} When {
|
||||
post("/schedules")
|
||||
} Extract {
|
||||
path("data.id")
|
||||
}
|
||||
|
||||
return scheduleRepository.findByIdOrNull(createdScheduleId)
|
||||
?: throw RuntimeException("unreachable line")
|
||||
}
|
||||
}
|
||||
@ -19,12 +19,12 @@ 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.ThemeFixtureV2.createRequest
|
||||
import roomescape.util.assertProperties
|
||||
import roomescape.util.runTest
|
||||
import kotlin.random.Random
|
||||
@ -33,19 +33,6 @@ 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("관리자가 아니면 접근할 수 없다.") {
|
||||
@ -64,7 +51,7 @@ class ThemeApiTest(
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(request)
|
||||
body(createRequest)
|
||||
},
|
||||
on = {
|
||||
post("/admin/themes")
|
||||
@ -97,7 +84,7 @@ class ThemeApiTest(
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(request)
|
||||
body(createRequest)
|
||||
},
|
||||
on = {
|
||||
patch("/admin/themes/1")
|
||||
@ -119,7 +106,7 @@ class ThemeApiTest(
|
||||
|
||||
context("일반 회원도 접근할 수 있다.") {
|
||||
test("테마 조회: GET /v2/themes") {
|
||||
createDummyTheme(request.copy(name = "test123", isOpen = true))
|
||||
createDummyTheme(createRequest.copy(name = "test123", isOpen = true))
|
||||
|
||||
runTest(
|
||||
token = loginUtil.loginAsUser(),
|
||||
@ -148,7 +135,7 @@ class ThemeApiTest(
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(request)
|
||||
body(createRequest)
|
||||
},
|
||||
on = {
|
||||
post(apiPath)
|
||||
@ -158,11 +145,11 @@ class ThemeApiTest(
|
||||
body("data.id", notNullValue())
|
||||
}
|
||||
).also {
|
||||
val createdThemeId: String = it.extract().path("data.id")
|
||||
val createdTheme: ThemeEntityV2 = themeRepository.findByIdOrNull(createdThemeId.toLong())
|
||||
val createdThemeId: Long = it.extract().path("data.id")
|
||||
val createdTheme: ThemeEntityV2 = themeRepository.findByIdOrNull(createdThemeId)
|
||||
?: throw AssertionError("Unexpected Exception Occurred.")
|
||||
|
||||
createdTheme.name shouldBe request.name
|
||||
createdTheme.name shouldBe createRequest.name
|
||||
createdTheme.createdAt shouldNotBeNull {}
|
||||
createdTheme.createdBy shouldNotBeNull {}
|
||||
createdTheme.updatedAt shouldNotBeNull {}
|
||||
@ -172,12 +159,12 @@ class ThemeApiTest(
|
||||
|
||||
test("이미 동일한 이름의 테마가 있으면 실패한다.") {
|
||||
val commonName = "test123"
|
||||
createDummyTheme(request.copy(name = commonName))
|
||||
createDummyTheme(createRequest.copy(name = commonName))
|
||||
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(request.copy(name = commonName))
|
||||
body(createRequest.copy(name = commonName))
|
||||
},
|
||||
on = {
|
||||
post(apiPath)
|
||||
@ -193,7 +180,7 @@ class ThemeApiTest(
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(request.copy(price = (MIN_PRICE - 1)))
|
||||
body(createRequest.copy(price = (MIN_PRICE - 1)))
|
||||
},
|
||||
on = {
|
||||
post(apiPath)
|
||||
@ -215,7 +202,7 @@ class ThemeApiTest(
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(request.copy(availableMinutes = (MIN_DURATION - 1).toShort()))
|
||||
body(createRequest.copy(availableMinutes = (MIN_DURATION - 1).toShort()))
|
||||
},
|
||||
on = {
|
||||
post(apiPath)
|
||||
@ -228,7 +215,7 @@ class ThemeApiTest(
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(request.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort()))
|
||||
body(createRequest.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort()))
|
||||
},
|
||||
on = {
|
||||
post(apiPath)
|
||||
@ -241,7 +228,7 @@ class ThemeApiTest(
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(request.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort()))
|
||||
body(createRequest.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort()))
|
||||
},
|
||||
on = {
|
||||
post(apiPath)
|
||||
@ -256,7 +243,7 @@ class ThemeApiTest(
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(request.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99))
|
||||
body(createRequest.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99))
|
||||
},
|
||||
on = {
|
||||
post(apiPath)
|
||||
@ -273,7 +260,7 @@ class ThemeApiTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(
|
||||
request.copy(
|
||||
createRequest.copy(
|
||||
availableMinutes = 100,
|
||||
expectedMinutesFrom = 101,
|
||||
expectedMinutesTo = 101
|
||||
@ -301,7 +288,7 @@ class ThemeApiTest(
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(request.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort()))
|
||||
body(createRequest.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort()))
|
||||
},
|
||||
on = {
|
||||
post(apiPath)
|
||||
@ -314,7 +301,7 @@ class ThemeApiTest(
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(request.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort()))
|
||||
body(createRequest.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort()))
|
||||
},
|
||||
on = {
|
||||
post(apiPath)
|
||||
@ -329,7 +316,7 @@ class ThemeApiTest(
|
||||
runTest(
|
||||
token = token,
|
||||
using = {
|
||||
body(request.copy(minParticipants = 10, maxParticipants = 9))
|
||||
body(createRequest.copy(minParticipants = 10, maxParticipants = 9))
|
||||
},
|
||||
on = {
|
||||
post(apiPath)
|
||||
@ -345,8 +332,8 @@ class ThemeApiTest(
|
||||
|
||||
context("모든 테마를 조회한다.") {
|
||||
beforeTest {
|
||||
createDummyTheme(request.copy(name = "open", isOpen = true))
|
||||
createDummyTheme(request.copy(name = "close", isOpen = false))
|
||||
createDummyTheme(createRequest.copy(name = "open", isOpen = true))
|
||||
createDummyTheme(createRequest.copy(name = "close", isOpen = false))
|
||||
}
|
||||
|
||||
test("관리자 페이지에서는 비공개 테마까지 포함하여 간단한 정보만 조회된다.") {
|
||||
@ -389,7 +376,7 @@ class ThemeApiTest(
|
||||
|
||||
context("관리자 페이지에서 특정 테마의 상세 정보를 조회한다.") {
|
||||
test("정상 응답") {
|
||||
val createdTheme: ThemeEntityV2 = createDummyTheme(request)
|
||||
val createdTheme: ThemeEntityV2 = createDummyTheme(createRequest)
|
||||
|
||||
runTest(
|
||||
token = loginUtil.loginAsAdmin(),
|
||||
@ -398,7 +385,7 @@ class ThemeApiTest(
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.OK.value())
|
||||
body("data.id", equalTo(createdTheme.id.toString()))
|
||||
body("data.id", equalTo(createdTheme.id))
|
||||
assertProperties(
|
||||
props = setOf(
|
||||
"id", "name", "description", "thumbnailUrl", "difficulty", "price", "isOpen",
|
||||
@ -427,7 +414,7 @@ class ThemeApiTest(
|
||||
|
||||
context("테마를 삭제한다.") {
|
||||
test("정상 삭제") {
|
||||
val createdTheme = createDummyTheme(request)
|
||||
val createdTheme = createDummyTheme(createRequest)
|
||||
|
||||
runTest(
|
||||
token = loginUtil.loginAsAdmin(),
|
||||
@ -465,7 +452,7 @@ class ThemeApiTest(
|
||||
|
||||
beforeTest {
|
||||
token = loginUtil.loginAsAdmin()
|
||||
createdTheme = createDummyTheme(request.copy(name = "theme-${Random.nextInt()}"))
|
||||
createdTheme = createDummyTheme(createRequest.copy(name = "theme-${Random.nextInt()}"))
|
||||
apiPath = "/admin/themes/${createdTheme.id}"
|
||||
}
|
||||
|
||||
@ -493,6 +480,26 @@ class ThemeApiTest(
|
||||
}
|
||||
}
|
||||
|
||||
test("입력값이 없으면 수정하지 않는다.") {
|
||||
runTest(
|
||||
token = loginUtil.loginAsAdmin(),
|
||||
using = {
|
||||
body(ThemeUpdateRequest())
|
||||
},
|
||||
on = {
|
||||
patch(apiPath)
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.OK.value())
|
||||
}
|
||||
).also {
|
||||
val updatedTheme = themeRepository.findByIdOrNull(createdTheme.id)!!
|
||||
|
||||
updatedTheme.id shouldBe createdTheme.id
|
||||
updatedTheme.updatedAt shouldBe createdTheme.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
test("테마가 없으면 실패한다.") {
|
||||
runTest(
|
||||
token = token,
|
||||
@ -665,7 +672,7 @@ class ThemeApiTest(
|
||||
}
|
||||
|
||||
fun createDummyTheme(request: ThemeCreateRequestV2): ThemeEntityV2 {
|
||||
val createdThemeId: String = Given {
|
||||
val createdThemeId: Long = Given {
|
||||
contentType(MediaType.APPLICATION_JSON_VALUE)
|
||||
header("Authorization", "Bearer ${loginUtil.loginAsAdmin()}")
|
||||
body(request)
|
||||
@ -675,7 +682,7 @@ class ThemeApiTest(
|
||||
path("data.id")
|
||||
}
|
||||
|
||||
return themeRepository.findByIdOrNull(createdThemeId.toLong())
|
||||
return themeRepository.findByIdOrNull(createdThemeId)
|
||||
?: throw RuntimeException("unreachable line")
|
||||
}
|
||||
}
|
||||
|
||||
51
src/test/kotlin/roomescape/util/FixturesV2.kt
Normal file
51
src/test/kotlin/roomescape/util/FixturesV2.kt
Normal file
@ -0,0 +1,51 @@
|
||||
package roomescape.util
|
||||
|
||||
import roomescape.member.infrastructure.persistence.MemberEntity
|
||||
import roomescape.member.infrastructure.persistence.Role
|
||||
import roomescape.schedule.web.ScheduleCreateRequest
|
||||
import roomescape.theme.infrastructure.persistence.v2.Difficulty
|
||||
import roomescape.theme.web.ThemeCreateRequestV2
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
|
||||
object MemberFixtureV2 {
|
||||
val admin: MemberEntity = MemberEntity(
|
||||
_id = 9304,
|
||||
name = "ADMIN",
|
||||
email = "admin@example.com",
|
||||
password = "adminPassword",
|
||||
role = Role.ADMIN
|
||||
)
|
||||
|
||||
val user: MemberEntity = MemberEntity(
|
||||
_id = 9305,
|
||||
name = "USER",
|
||||
email = "user@example.com",
|
||||
password = "userPassword",
|
||||
role = Role.MEMBER
|
||||
)
|
||||
}
|
||||
|
||||
object ThemeFixtureV2 {
|
||||
val createRequest: 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
|
||||
)
|
||||
}
|
||||
|
||||
object ScheduleFixture {
|
||||
val createRequest: ScheduleCreateRequest = ScheduleCreateRequest(
|
||||
date = LocalDate.now().plusDays(1),
|
||||
time = LocalTime.now(),
|
||||
themeId = 1L
|
||||
)
|
||||
}
|
||||
@ -43,11 +43,11 @@ class LoginUtil(
|
||||
}
|
||||
|
||||
fun loginAsAdmin(): String {
|
||||
return login(MemberFixture.admin().email, MemberFixture.admin().password, Role.ADMIN)
|
||||
return login(MemberFixtureV2.admin.email, MemberFixtureV2.admin.password, Role.ADMIN)
|
||||
}
|
||||
|
||||
fun loginAsUser(): String {
|
||||
return login(MemberFixture.user().email, MemberFixture.user().password)
|
||||
return login(MemberFixtureV2.user.email, MemberFixtureV2.user.password)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user