feat: 관리자 페이지 및 예약 페이지에 일정(Schedule) 기능 도입

This commit is contained in:
이상진 2025-09-04 11:30:27 +09:00
parent bdc99c7883
commit fa9b858950
16 changed files with 967 additions and 132 deletions

View File

@ -11,6 +11,7 @@
"axios": "^1.7.2", "axios": "^1.7.2",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"json-bigint": "^1.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-flatpickr": "^3.10.13", "react-flatpickr": "^3.10.13",
@ -18,6 +19,7 @@
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"devDependencies": { "devDependencies": {
"@types/json-bigint": "^1.0.4",
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@types/react-flatpickr": "^3.8.11", "@types/react-flatpickr": "^3.8.11",
@ -1323,6 +1325,13 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -1690,6 +1699,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/bootstrap": {
"version": "5.3.7", "version": "5.3.7",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz",
@ -2833,6 +2851,15 @@
"node": ">=6" "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": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",

View File

@ -13,6 +13,7 @@
"axios": "^1.7.2", "axios": "^1.7.2",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"json-bigint": "^1.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-flatpickr": "^3.10.13", "react-flatpickr": "^3.10.13",
@ -20,6 +21,7 @@
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"devDependencies": { "devDependencies": {
"@types/json-bigint": "^1.0.4",
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@types/react-flatpickr": "^3.8.11", "@types/react-flatpickr": "^3.8.11",

View File

@ -24,6 +24,7 @@ import HomePageV2 from './pages/v2/HomePageV2';
import LoginPageV2 from './pages/v2/LoginPageV2'; import LoginPageV2 from './pages/v2/LoginPageV2';
import SignupPageV2 from './pages/v2/SignupPageV2'; import SignupPageV2 from './pages/v2/SignupPageV2';
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage'; import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
const AdminRoutes = () => ( const AdminRoutes = () => (
<AdminLayout> <AdminLayout>
@ -34,6 +35,7 @@ const AdminRoutes = () => (
<Route path="/theme" element={<AdminThemePage />} /> <Route path="/theme" element={<AdminThemePage />} />
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} /> <Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
<Route path="/waiting" element={<AdminWaitingPage />} /> <Route path="/waiting" element={<AdminWaitingPage />} />
<Route path="/schedule" element={<AdminSchedulePage />} />
</Routes> </Routes>
</AdminLayout> </AdminLayout>
); );

View File

@ -1,8 +1,28 @@
import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios'; 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({ const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000, 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 => { export const isLoginRequiredError = (error: any): boolean => {

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

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

View File

@ -4,7 +4,8 @@ import type {
AdminThemeSummaryRetrieveListResponse, AdminThemeSummaryRetrieveListResponse,
ThemeCreateRequest, ThemeCreateRequest,
ThemeCreateRequestV2, ThemeCreateResponse, ThemeCreateRequestV2, ThemeCreateResponse,
ThemeCreateResponseV2, ThemeRetrieveListResponse, ThemeCreateResponseV2, ThemeListRetrieveRequest, ThemeRetrieveListResponse,
ThemeRetrieveListResponseV2,
ThemeUpdateRequest, ThemeUpdateRequest,
UserThemeRetrieveListResponse UserThemeRetrieveListResponse
} from './themeTypes'; } from './themeTypes';
@ -48,3 +49,7 @@ export const deleteTheme = async (id: string): Promise<void> => {
export const fetchUserThemes = async (): Promise<UserThemeRetrieveListResponse> => { export const fetchUserThemes = async (): Promise<UserThemeRetrieveListResponse> => {
return await apiClient.get<UserThemeRetrieveListResponse>('/v2/themes'); return await apiClient.get<UserThemeRetrieveListResponse>('/v2/themes');
}; };
export const findThemesByIds = async (request: ThemeListRetrieveRequest): Promise<ThemeRetrieveListResponseV2> => {
return await apiClient.post<ThemeRetrieveListResponseV2>('/themes/retrieve', request);
};

View File

@ -123,6 +123,28 @@ export interface UserThemeRetrieveListResponse {
themes: UserThemeRetrieveResponse[]; 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 // @ts-ignore
export enum Difficulty { export enum Difficulty {
VERY_EASY = 'VERY_EASY', VERY_EASY = 'VERY_EASY',

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

View File

@ -40,45 +40,116 @@
color: #191F28; color: #191F28;
} }
/* Date Selector */ /* Date Carousel */
.date-selector { .date-carousel {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; 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 { .date-option {
cursor: pointer; cursor: pointer;
padding: 12px 16px; padding: 8px;
border-radius: 8px; border-radius: 8px;
text-align: center; display: flex;
border: 2px solid transparent; flex-direction: column;
background-color: #F2F4F6; align-items: center;
transition: all 0.2s ease-in-out; justify-content: center;
flex-grow: 1; border: 1px solid transparent;
transition: all 0.3s ease;
width: 60px;
flex-shrink: 0;
} }
.date-option:hover { .date-option:hover {
background-color: #E5E8EB; background-color: #f0f0f0;
} }
.date-option.active { .date-option.active {
background-color: #3182F6; border: 1px solid #007bff;
color: #ffffff; background-color: #e7f3ff;
border-color: #3182F6;
font-weight: 600;
} }
.date-option .day-of-week { .date-option .day-of-week {
font-size: 14px; font-size: 12px;
margin-bottom: 4px; color: #888;
} }
.date-option .day { .date-option.active .day-of-week {
font-size: 18px; color: #007bff;
font-weight: 700;
} }
.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 */
.theme-list { .theme-list {
display: grid; display: grid;

View File

@ -60,3 +60,20 @@
.cursor-pointer { .cursor-pointer {
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;
}

View File

@ -81,11 +81,15 @@ a {
margin-top: 72px; margin-top: 72px;
} }
.form-select {
margin: 10px 0px !important;
}
.button-group { .button-group {
margin-top: 32px; margin-top: 32px;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
justify-content: center; justify-content: center !important;
gap: 16px; gap: 16px;
} }

View File

@ -35,7 +35,7 @@ const LoginPage: React.FC = () => {
<div className="form-group"> <div className="form-group">
<input type="password" className="form-control" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} /> <input type="password" className="form-control" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} />
</div> </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-outline-custom" onClick={() => navigate('/signup')}>Sign Up</button>
<button className="btn btn-custom" onClick={handleLogin}>Login</button> <button className="btn btn-custom" onClick={handleLogin}>Login</button>
</div> </div>

View File

@ -25,6 +25,7 @@ const AdminNavbar: React.FC = () => {
<Link className="nav-link" to="/admin/waiting"></Link> <Link className="nav-link" to="/admin/waiting"></Link>
<Link className="nav-link" to="/admin/theme"></Link> <Link className="nav-link" to="/admin/theme"></Link>
<Link className="nav-link" to="/admin/time"></Link> <Link className="nav-link" to="/admin/time"></Link>
<Link className="nav-link" to="/admin/schedule"></Link>
</div> </div>
<div className="nav-actions"> <div className="nav-actions">
{!loggedIn ? ( {!loggedIn ? (

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

View File

@ -1,18 +1,18 @@
import { isLoginRequiredError } from '@_api/apiClient'; import { isLoginRequiredError } from '@_api/apiClient';
import { createPendingReservation } from '@_api/reservation/reservationAPI'; import { createPendingReservation } from '@_api/reservation/reservationAPI';
import { fetchUserThemes } from '@_api/theme/themeAPI'; import { findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI';
import { fetchTimesWithAvailability } from '@_api/time/timeAPI'; import type { ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
import type { TimeWithAvailabilityResponse } from '@_api/time/timeTypes'; import { findThemesByIds } from '@_api/theme/themeAPI';
import { Difficulty } from '@_api/theme/themeTypes';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate, formatTime } from 'src/util/DateTimeFormatter'; import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
// New theme type based on the provided schema
interface ThemeV21 { interface ThemeV21 {
id: string; // Changed to number to match API id: string;
name: string; name: string;
difficulty: string; difficulty: Difficulty;
description: string; description: string;
thumbnailUrl: string; thumbnailUrl: string;
price: number; price: number;
@ -23,13 +23,30 @@ interface ThemeV21 {
availableMinutes: number; 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 ReservationStep1PageV21: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [viewDate, setViewDate] = useState<Date>(new Date()); // For carousel
const [themes, setThemes] = useState<ThemeV21[]>([]); const [themes, setThemes] = useState<ThemeV21[]>([]);
const [selectedTheme, setSelectedTheme] = useState<ThemeV21 | null>(null); const [selectedTheme, setSelectedTheme] = useState<ThemeV21 | null>(null);
const [times, setTimes] = useState<TimeWithAvailabilityResponse[]>([]); const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
const [selectedTime, setSelectedTime] = useState<TimeWithAvailabilityResponse | null>(null); const [selectedSchedule, setSelectedSchedule] = useState<ScheduleRetrieveResponse | null>(null);
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false); const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
@ -47,49 +64,47 @@ const ReservationStep1PageV21: React.FC = () => {
}; };
useEffect(() => { useEffect(() => {
const fetchData = async () => { if (selectedDate) {
try { const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd
const response = await fetchUserThemes(); findAvailableThemesByDate(dateStr)
// Map UserThemeRetrieveResponse to ThemeV21 .then(res => {
const mappedThemes: ThemeV21[] = response.themes.map(theme => ({ const themeIds: string[] = res.themeIds;
id: theme.id, if (themeIds.length > 0) {
name: theme.name, return findThemesByIds({ themeIds });
difficulty: theme.difficulty, } else {
description: theme.description, return Promise.resolve({ themes: [] });
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(); .then(themeResponse => {
}, []); setThemes(themeResponse.themes as ThemeV21[]);
})
.catch(handleError)
.finally(() => {
setSelectedTheme(null);
setSchedules([]);
setSelectedSchedule(null);
});
}
}, [selectedDate]);
useEffect(() => { useEffect(() => {
if (selectedDate && selectedTheme) { if (selectedDate && selectedTheme) {
const dateStr = selectedDate.toLocaleDateString('en-CA'); const dateStr = selectedDate.toLocaleDateString('en-CA');
fetchTimesWithAvailability(dateStr, selectedTheme.id) findSchedules(dateStr, selectedTheme.id)
.then(res => { .then(res => {
setTimes(res.times); setSchedules(res.schedules);
setSelectedTime(null); setSelectedSchedule(null);
}) })
.catch(handleError); .catch(handleError);
} }
}, [selectedDate, selectedTheme]); }, [selectedDate, selectedTheme]);
const handleNextStep = () => { const handleNextStep = () => {
if (!selectedDate || !selectedTheme || !selectedTime) { if (!selectedDate || !selectedTheme || !selectedSchedule) {
alert('날짜, 테마, 시간을 모두 선택해주세요.'); alert('날짜, 테마, 시간을 모두 선택해주세요.');
return; return;
} }
if (!selectedTime.isAvailable) { if (selectedSchedule.status !== 'AVAILABLE') {
alert('예약할 수 없는 시간입니다.'); alert('예약할 수 없는 시간입니다.');
return; return;
} }
@ -97,45 +112,85 @@ const ReservationStep1PageV21: React.FC = () => {
}; };
const handleConfirmPayment = () => { const handleConfirmPayment = () => {
if (!selectedDate || !selectedTheme || !selectedTime) return; if (!selectedDate || !selectedTheme || !selectedSchedule) return;
const reservationData = { const reservationData = {
date: selectedDate.toLocaleDateString('en-CA'), scheduleId: selectedSchedule.id,
themeId: selectedTheme.id,
timeId: selectedTime.id,
}; };
createPendingReservation(reservationData) createPendingReservation(reservationData)
.then((res) => { .then((res) => {
navigate('/v2/reservation/payment', { state: { reservation: res } }); navigate('/v2-1/reservation/payment', { state: { reservation: res } });
}) })
.catch(handleError) .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 dates = [];
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0);
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const date = new Date(today); const date = new Date(viewDate);
date.setDate(today.getDate() + i); date.setDate(viewDate.getDate() + i);
dates.push(date); dates.push(date);
} }
return dates.map(date => { const handlePrev = () => {
const newViewDate = new Date(viewDate);
newViewDate.setDate(viewDate.getDate() - 1);
if (newViewDate < today) {
alert("지난 날짜는 조회할 수 없습니다.");
return;
}
setViewDate(newViewDate);
}
const handleNext = () => {
const newViewDate = new Date(viewDate);
newViewDate.setDate(viewDate.getDate() + 1);
setViewDate(newViewDate);
}
const goToToday = () => {
setViewDate(new Date());
setSelectedDate(new Date());
}
return (
<div className="date-carousel">
<button onClick={handlePrev} className="carousel-arrow"></button>
<div className="date-options-container">
{dates.map(date => {
const isSelected = selectedDate.toDateString() === date.toDateString(); const isSelected = selectedDate.toDateString() === date.toDateString();
const isPast = date < today;
return ( return (
<div <div
key={date.toISOString()} key={date.toISOString()}
className={`date-option ${isSelected ? 'active' : ''}`} className={`date-option ${isSelected ? 'active' : ''} ${isPast ? 'disabled' : ''}`}
onClick={() => setSelectedDate(date)} onClick={() => handleDateSelect(date)}
> >
<div className="day-of-week">{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}</div> <div className="day-of-week">{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}</div>
<div className="day">{date.getDate()}</div> <div className="day-circle">{date.getDate()}</div>
</div>
);
})}
</div>
<button onClick={handleNext} className="carousel-arrow"></button>
<button onClick={goToToday} className="today-button"></button>
</div> </div>
); );
});
}; };
const openThemeModal = (theme: ThemeV21) => { const openThemeModal = (theme: ThemeV21) => {
@ -143,7 +198,7 @@ const ReservationStep1PageV21: React.FC = () => {
setIsThemeModalOpen(true); setIsThemeModalOpen(true);
}; };
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || !selectedTime.isAvailable; const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== 'AVAILABLE';
return ( return (
<div className="reservation-v21-container"> <div className="reservation-v21-container">
@ -151,7 +206,7 @@ const ReservationStep1PageV21: React.FC = () => {
<div className="step-section"> <div className="step-section">
<h3>1. </h3> <h3>1. </h3>
<div className="date-selector">{renderDateOptions()}</div> {renderDateCarousel()}
</div> </div>
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}> <div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
@ -166,9 +221,9 @@ const ReservationStep1PageV21: React.FC = () => {
<div className="theme-info"> <div className="theme-info">
<h4>{theme.name}</h4> <h4>{theme.name}</h4>
<div className="theme-meta"> <div className="theme-meta">
<p><strong>:</strong> {theme.difficulty}</p>
<p><strong> :</strong> {theme.minParticipants} ~ {theme.maxParticipants}</p>
<p><strong>:</strong> {theme.price.toLocaleString()}</p> <p><strong>:</strong> {theme.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.expectedMinutesFrom} ~ {theme.expectedMinutesTo}</p>
<p><strong> :</strong> {theme.availableMinutes}</p> <p><strong> :</strong> {theme.availableMinutes}</p>
</div> </div>
@ -182,14 +237,14 @@ const ReservationStep1PageV21: React.FC = () => {
<div className={`step-section ${!selectedTheme ? 'disabled' : ''}`}> <div className={`step-section ${!selectedTheme ? 'disabled' : ''}`}>
<h3>3. </h3> <h3>3. </h3>
<div className="time-slots"> <div className="time-slots">
{times.length > 0 ? times.map(time => ( {schedules.length > 0 ? schedules.map(schedule => (
<div <div
key={time.id} key={schedule.id}
className={`time-slot ${selectedTime?.id === time.id ? 'active' : ''} ${!time.isAvailable ? 'disabled' : ''}`} className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== 'AVAILABLE' ? 'disabled' : ''}`}
onClick={() => time.isAvailable && setSelectedTime(time)} onClick={() => schedule.status === 'AVAILABLE' && setSelectedSchedule(schedule)}
> >
{time.startAt} {schedule.time}
<span className="time-availability">{time.isAvailable ? '예약가능' : '예약불가'}</span> <span className="time-availability">{schedule.status === 'AVAILABLE' ? '예약가능' : '예약불가'}</span>
</div> </div>
)) : <div className="no-times"> .</div>} )) : <div className="no-times"> .</div>}
</div> </div>
@ -209,7 +264,7 @@ const ReservationStep1PageV21: React.FC = () => {
<h2>{selectedTheme.name}</h2> <h2>{selectedTheme.name}</h2>
<div className="modal-section"> <div className="modal-section">
<h3> </h3> <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.minParticipants} ~ {selectedTheme.maxParticipants}</p>
<p><strong> :</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</p> <p><strong> :</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</p>
<p><strong>:</strong> {selectedTheme.price.toLocaleString()}</p> <p><strong>:</strong> {selectedTheme.price.toLocaleString()}</p>
@ -230,7 +285,7 @@ const ReservationStep1PageV21: React.FC = () => {
<div className="modal-section"> <div className="modal-section">
<p><strong>:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p> <p><strong>:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
<p><strong>:</strong> {selectedTheme!!.name}</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> <p><strong>:</strong> {selectedTheme!!.price.toLocaleString()}</p>
</div> </div>
<div className="modal-actions"> <div className="modal-actions">