roomescape-refactored/frontend/src/pages/ReservationStep1Page.tsx
pricelees 32b8019576 [#46] 더미 데이터 생성 및 1개의 슬로우쿼리 개선 (#47)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #46

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 전체 더미 데이터 추가(관리자 약 2,400건 / 회원 100만건 / 예약, 일정 약 197만건 / 결제 및 결제 상세 196만건(대략 충전식 간편결제 29.3만건, 카드 147만건, 계좌이체 19.6만건) / 테마 500건 / 매장 263건
- 로컬 애플리케이션 실행 후, 가장 병목이 되는 메인 인기 테마 쿼리만 성능 개선(5회 측정시 API 응답 시간 평균 3300 -> 90ms)

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
변경된 기능은 모두 테스트 반영

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->
취소 데이터 등이 들어가있지 않아, 일부 컬럼에서의 Cardinality가 훨씬 낮게 나오는 상황이긴 함. 예약을 예로 들면, 현재는 확정 예약인 데이터만 추가하여 확정 예약이 100%지만, 실제 도메인의 특성상 예약 데이터는 8~90%는 확정 예약일 것으로 생각하여 큰 차이가 없다고 판단하였음.

Reviewed-on: #47
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-09-27 06:38:44 +00:00

331 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {isLoginRequiredError} from '@_api/apiClient';
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
import {type SidoResponse, type SigunguResponse} from '@_api/region/regionTypes';
import {fetchSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
import {ScheduleStatus, type ScheduleWithThemeResponse} from '@_api/schedule/scheduleTypes';
import {getStores} from '@_api/store/storeAPI';
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
import {fetchThemeById} from '@_api/theme/themeAPI';
import {DifficultyKoreanMap, 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 {type ReservationData} from '@_api/reservation/reservationTypes';
import {formatDate} from 'src/util/DateTimeFormatter';
const ReservationStep1Page: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [viewDate, setViewDate] = useState<Date>(new Date());
const [sidoList, setSidoList] = useState<SidoResponse[]>([]);
const [sigunguList, setSigunguList] = useState<SigunguResponse[]>([]);
const [storeList, setStoreList] = useState<SimpleStoreResponse[]>([]);
const [selectedSido, setSelectedSido] = useState('');
const [selectedSigungu, setSelectedSigungu] = useState('');
const [selectedStore, setSelectedStore] = useState<SimpleStoreResponse | null>(null);
const [schedulesByTheme, setSchedulesByTheme] = useState<Record<string, ScheduleWithThemeResponse[]>>({});
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleWithThemeResponse | null>(null);
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
const [modalThemeDetails, setModalThemeDetails] = useState<ThemeInfoResponse | null>(null);
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(() => {
fetchSidoList().then(res => setSidoList(res.sidoList)).catch(handleError);
}, []);
useEffect(() => {
if (selectedSido) {
fetchSigunguList(selectedSido).then(res => setSigunguList(res.sigunguList)).catch(handleError);
} else {
setSigunguList([]);
}
setSelectedSigungu('');
}, [selectedSido]);
useEffect(() => {
if (selectedSido) {
getStores(selectedSido, selectedSigungu)
.then(res => setStoreList(res.stores))
.catch(handleError);
} else {
setStoreList([]);
}
setSelectedStore(null);
}, [selectedSido, selectedSigungu]);
useEffect(() => {
if (selectedDate && selectedStore) {
const dateStr = selectedDate.toLocaleDateString('en-CA');
fetchSchedules(selectedStore.id, dateStr)
.then(res => {
const grouped = res.schedules.reduce((acc, schedule) => {
const key = schedule.themeName;
if (!acc[key]) acc[key] = [];
acc[key].push(schedule);
return acc;
}, {} as Record<string, ScheduleWithThemeResponse[]>);
setSchedulesByTheme(grouped);
})
.catch(handleError);
} else {
setSchedulesByTheme({});
}
setSelectedSchedule(null);
}, [selectedDate, selectedStore]);
const handleDateSelect = (date: Date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (date < today) {
alert("지난 날짜는 선택할 수 없습니다.");
return;
}
setSelectedDate(date);
};
const handleNextStep = () => {
if (!selectedSchedule) {
alert('예약할 시간을 선택해주세요.');
return;
}
setIsConfirmModalOpen(true);
};
const handleConfirmReservation = () => {
if (!selectedSchedule) return;
holdSchedule(selectedSchedule.id)
.then(() => {
fetchThemeById(selectedSchedule.themeId).then(res => {
const reservationData: ReservationData = {
scheduleId: selectedSchedule.id,
store: {
id: selectedStore!.id,
name: selectedStore!.name,
},
theme: {
id: res.id,
name: res.name,
price: res.price,
minParticipants: res.minParticipants,
maxParticipants: res.maxParticipants,
},
date: selectedDate.toLocaleDateString('en-CA'),
startFrom: selectedSchedule.startFrom,
endAt: selectedSchedule.endAt,
};
navigate('/reservation/form', {state: reservationData});
}).catch(handleError);
})
.catch(handleError);
};
const openThemeModal = (themeId: string) => {
fetchThemeById(themeId)
.then(themeDetails => {
setModalThemeDetails(themeDetails);
setIsThemeModalOpen(true);
})
.catch(handleError);
};
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 getStatusText = (status: ScheduleStatus) => {
switch (status) {
case ScheduleStatus.AVAILABLE:
return '예약가능';
case ScheduleStatus.HOLD:
return '예약 진행중';
default:
return '예약불가';
}
};
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="region-store-selectors">
<select value={selectedSido} onChange={e => setSelectedSido(e.target.value)}>
<option value="">/</option>
{sidoList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
</select>
<select value={selectedSigungu} onChange={e => setSelectedSigungu(e.target.value)}
disabled={!selectedSido}>
<option value="">// ()</option>
{sigunguList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
</select>
<select value={selectedStore?.id || ''}
onChange={e => setSelectedStore(storeList.find(s => s.id === e.target.value) || null)}
disabled={storeList.length === 0}>
<option value=""> </option>
{storeList.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
</div>
<div className={`step-section ${!selectedStore ? 'disabled' : ''}`}>
<h3>3. </h3>
<div className="schedule-list">
{Object.keys(schedulesByTheme).length > 0 ? (
Object.entries(schedulesByTheme).map(([themeName, schedules]) => (
<div key={themeName} className="theme-schedule-group">
<div className="theme-header">
<h4>{themeName}</h4>
<button onClick={() => openThemeModal(schedules[0].themeId)}
className="theme-detail-button">
</button>
</div>
<div className="time-slots">
{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.startFrom} ~ ${schedule.endAt}`}
<span className="time-availability">{getStatusText(schedule.status)}</span>
</div>
))}
</div>
</div>
))
) : (
<div className="no-times"> .</div>
)}
</div>
</div>
<div className="next-step-button-container">
<button className="next-step-button" disabled={!selectedSchedule} onClick={handleNextStep}>
</button>
</div>
{isThemeModalOpen && modalThemeDetails && (
<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={modalThemeDetails.thumbnailUrl} alt={modalThemeDetails.name}
className="modal-theme-thumbnail"/>
<h2>{modalThemeDetails.name}</h2>
<div className="modal-section modal-info-grid">
<h3> </h3>
<p><strong>:</strong><span>{DifficultyKoreanMap[modalThemeDetails.difficulty]}</span></p>
<p><strong> :</strong><span>{modalThemeDetails.minParticipants} ~ {modalThemeDetails.maxParticipants}</span></p>
<p><strong>1 :</strong><span>{modalThemeDetails.price.toLocaleString()}</span></p>
<p><strong> :</strong><span>{modalThemeDetails.expectedMinutesFrom} ~ {modalThemeDetails.expectedMinutesTo}</span></p>
<p><strong> :</strong><span>{modalThemeDetails.availableMinutes}</span></p>
</div>
<div className="modal-section">
<h3></h3>
<p>{modalThemeDetails.description}</p>
</div>
</div>
</div>
)}
{isConfirmModalOpen && selectedSchedule && (
<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 modal-info-grid">
<p><strong>:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
<p><strong>:</strong><span>{selectedStore?.name}</span></p>
<p><strong>:</strong><span>{selectedSchedule.themeName}</span></p>
<p><strong>:</strong><span>{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}</span></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;