generated from pricelees/issue-pr-template
<!-- 제목 양식 --> <!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) --> ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #34 ## ✨ 작업 내용 <!-- 어떤 작업을 했는지 알려주세요! --> - 회원 테이블과 관리자 테이블 분리 및 관리자 계정의 예약 기능 제거 - API 인증을 모두(Public) / 회원 전용(UserOnly) / 관리자 전용(AdminOnly) / 회원 + 관리자(Authenticated) 로 세분화해서 구분 - 관리자의 경우 API 접근 권한 세분화 등 인증 로직 개선 - 전체 리팩터링이 완료되어 레거시 코드 제거 ## 🧪 테스트 <!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! --> <img width="750" alt="스크린샷 2025-09-13 19.11.44.png" src="attachments/11e1a79c-9723-4843-839d-be6158d94130"> - 추가 & 변경된 모든 API에 대한 통합 테스트 진행 ## 📚 참고 자료 및 기타 <!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! --> Reviewed-on: #43 Co-authored-by: pricelees <priceelees@gmail.com> Co-committed-by: pricelees <priceelees@gmail.com>
303 lines
14 KiB
TypeScript
303 lines
14 KiB
TypeScript
import {isLoginRequiredError} from '@_api/apiClient';
|
||
import {findAvailableThemesByDate, findSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
|
||
import {type ScheduleRetrieveResponse, ScheduleStatus} from '@_api/schedule/scheduleTypes';
|
||
import {findThemesByIds} from '@_api/theme/themeAPI';
|
||
import {mapThemeResponse, type ThemeInfoResponse} 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';
|
||
|
||
|
||
const ReservationStep1Page: React.FC = () => {
|
||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||
const [viewDate, setViewDate] = useState<Date>(new Date()); // For carousel
|
||
const [themes, setThemes] = useState<ThemeInfoResponse[]>([]);
|
||
const [selectedTheme, setSelectedTheme] = useState<ThemeInfoResponse | 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();
|
||
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(() => {
|
||
if (selectedDate) {
|
||
const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd
|
||
findAvailableThemesByDate(dateStr)
|
||
.then(res => {
|
||
console.log('Available themes response:', res);
|
||
const themeIds: string[] = res.themeIds;
|
||
console.log('Available theme IDs:', themeIds);
|
||
if (themeIds.length > 0) {
|
||
return findThemesByIds({ themeIds });
|
||
} else {
|
||
return Promise.resolve({ themes: [] });
|
||
}
|
||
})
|
||
.then(themeResponse => {
|
||
setThemes(themeResponse.themes.map(mapThemeResponse));
|
||
})
|
||
.catch((err) => {
|
||
if (isLoginRequiredError(err)) {
|
||
setThemes([]);
|
||
} else {
|
||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||
alert(message);
|
||
console.error(err);
|
||
}
|
||
})
|
||
.finally(() => {
|
||
setSelectedTheme(null);
|
||
setSchedules([]);
|
||
setSelectedSchedule(null);
|
||
});
|
||
}
|
||
}, [selectedDate]);
|
||
|
||
useEffect(() => {
|
||
if (selectedDate && selectedTheme) {
|
||
const dateStr = selectedDate.toLocaleDateString('en-CA');
|
||
findSchedules(dateStr, selectedTheme.id)
|
||
.then(res => {
|
||
setSchedules(res.schedules);
|
||
setSelectedSchedule(null);
|
||
})
|
||
.catch((err) => {
|
||
if (isLoginRequiredError(err)) {
|
||
setSchedules([]);
|
||
} else {
|
||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||
alert(message);
|
||
console.error(err);
|
||
}
|
||
setSelectedSchedule(null);
|
||
});
|
||
}
|
||
}, [selectedDate, selectedTheme]);
|
||
|
||
const handleNextStep = () => {
|
||
if (!selectedDate || !selectedTheme || !selectedSchedule) {
|
||
alert('날짜, 테마, 시간을 모두 선택해주세요.');
|
||
return;
|
||
}
|
||
if (selectedSchedule.status !== ScheduleStatus.AVAILABLE) {
|
||
alert('예약할 수 없는 시간입니다.');
|
||
return;
|
||
}
|
||
setIsConfirmModalOpen(true);
|
||
};
|
||
|
||
const handleConfirmReservation = () => {
|
||
if (!selectedSchedule) return;
|
||
|
||
holdSchedule(selectedSchedule.id)
|
||
.then(() => {
|
||
navigate('/reservation/form', {
|
||
state: {
|
||
scheduleId: selectedSchedule.id,
|
||
theme: selectedTheme,
|
||
date: selectedDate.toLocaleDateString('en-CA'),
|
||
time: selectedSchedule.time,
|
||
}
|
||
});
|
||
})
|
||
.catch(handleError)
|
||
.finally(() => setIsConfirmModalOpen(false));
|
||
};
|
||
|
||
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(viewDate);
|
||
date.setDate(viewDate.getDate() + i);
|
||
dates.push(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 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: ThemeInfoResponse) => {
|
||
setSelectedTheme(theme);
|
||
setIsThemeModalOpen(true);
|
||
};
|
||
|
||
const getStatusText = (status: ScheduleStatus) => {
|
||
switch (status) {
|
||
case ScheduleStatus.AVAILABLE:
|
||
return '예약가능';
|
||
case ScheduleStatus.HOLD:
|
||
return '예약 진행중';
|
||
default:
|
||
return '예약불가';
|
||
}
|
||
};
|
||
|
||
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== ScheduleStatus.AVAILABLE;
|
||
|
||
return (
|
||
<div className="reservation-v21-container">
|
||
<h2 className="page-title">예약하기</h2>
|
||
|
||
<div className="step-section">
|
||
<h3>1. 날짜 선택</h3>
|
||
{renderDateCarousel()}
|
||
</div>
|
||
|
||
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
|
||
<h3>2. 테마 선택</h3>
|
||
<div className="theme-list">
|
||
{themes.map(theme => (
|
||
<div
|
||
key={theme.id}
|
||
className={`theme-card ${selectedTheme?.id === theme.id ? 'active' : ''}`}
|
||
onClick={() => setSelectedTheme(theme)}
|
||
>
|
||
<div className="theme-info">
|
||
<h4>{theme.name}</h4>
|
||
<div className="theme-meta">
|
||
<p><strong>1인당 요금:</strong> {theme.price.toLocaleString()}원</p>
|
||
<p><strong>난이도:</strong> {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>
|
||
<button className="theme-detail-button" onClick={(e) => { e.stopPropagation(); openThemeModal(theme); }}>상세보기</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={`step-section ${!selectedTheme ? 'disabled' : ''}`}>
|
||
<h3>3. 시간 선택</h3>
|
||
<div className="time-slots">
|
||
{schedules.length > 0 ? schedules.map(schedule => (
|
||
<div
|
||
key={schedule.id}
|
||
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||
>
|
||
{schedule.time}
|
||
<span className="time-availability">{getStatusText(schedule.status)}</span>
|
||
</div>
|
||
)) : <div className="no-times">선택 가능한 시간이 없습니다.</div>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="next-step-button-container">
|
||
<button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}>
|
||
예약하기
|
||
</button>
|
||
</div>
|
||
|
||
{isThemeModalOpen && selectedTheme && (
|
||
<div className="modal-overlay" onClick={() => setIsThemeModalOpen(false)}>
|
||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||
<button className="modal-close-button" onClick={() => setIsThemeModalOpen(false)}>×</button>
|
||
<img src={selectedTheme.thumbnailUrl} alt={selectedTheme.name} className="modal-theme-thumbnail" />
|
||
<h2>{selectedTheme.name}</h2>
|
||
<div className="modal-section">
|
||
<h3>테마 정보</h3>
|
||
<p><strong>난이도:</strong> {selectedTheme.difficulty}</p>
|
||
<p><strong>참여 인원:</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</p>
|
||
<p><strong>소요 시간:</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</p>
|
||
<p><strong>1인당 요금:</strong> {selectedTheme.price.toLocaleString()}원</p>
|
||
</div>
|
||
<div className="modal-section">
|
||
<h3>소개</h3>
|
||
<p>{selectedTheme.description}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isConfirmModalOpen && (
|
||
<div className="modal-overlay" onClick={() => setIsConfirmModalOpen(false)}>
|
||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||
<button className="modal-close-button" onClick={() => setIsConfirmModalOpen(false)}>×</button>
|
||
<h2>예약 정보를 확인해주세요</h2>
|
||
<div className="modal-section">
|
||
<p><strong>날짜:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
|
||
<p><strong>테마:</strong> {selectedTheme!!.name}</p>
|
||
<p><strong>시간:</strong> {formatTime(selectedSchedule!!.time)}</p>
|
||
</div>
|
||
<div className="modal-actions">
|
||
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
||
<button className="confirm-button" onClick={handleConfirmReservation}>예약하기</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ReservationStep1Page; |