roomescape-refactored/frontend/src/pages/v2/MyReservationPageV2.tsx
pricelees 675a5b8854 [#41] 예약 스키마 재정의 (#42)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

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

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 예약 스키마 & API 재정의
- 새로운 기능에 맞춘 프론트엔드 페이지 추가
- Controller 이후 응답(성공, 실패) 로그에 Endpoint 추가
- 전환으로 인해 미사용되는 코드 및 테스트 전체 제거

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
<img width="528" alt="테스트 커버리지" src="attachments/a4899c0a-2919-4993-bd3b-a71bc601137d">

- 예약 & 결제 통합 테스트 작성 완료
- 결제 테스트는 통합 테스트에서는 Client를 mocking하는 방식 + 별도의 Client 슬라이스 테스트로 진행

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #42
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-09-09 00:43:39 +00:00

343 lines
13 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 { cancelPayment } from '@_api/payment/paymentAPI';
import type { PaymentRetrieveResponse } from '@_api/payment/PaymentTypes';
import { cancelReservation, fetchDetailById, fetchSummaryByMember } from '@_api/reservation/reservationAPIV2';
import type { ReservationDetail, ReservationSummaryRetrieveResponse } from '@_api/reservation/reservationTypesV2';
import React, { useEffect, useState } from 'react';
import '../../css/my-reservation-v2.css';
const formatDisplayDateTime = (dateTime: any): string => {
let date: Date;
if (typeof dateTime === 'string') {
// ISO 문자열 형식 처리 (LocalDateTime, OffsetDateTime 모두 포함)
date = new Date(dateTime);
} else if (typeof dateTime === 'number') {
// Unix 타임스탬프(초) 형식 처리
date = new Date(dateTime * 1000);
} else if (Array.isArray(dateTime) && dateTime.length >= 6) {
// 배열 형식 처리: [year, month, day, hour, minute, second, nanosecond?]
const year = dateTime[0];
const month = dateTime[1] - 1; // JS Date의 월은 0부터 시작
const day = dateTime[2];
const hour = dateTime[3];
const minute = dateTime[4];
const second = dateTime[5];
const millisecond = dateTime.length > 6 ? Math.floor(dateTime[6] / 1000000) : 0;
date = new Date(year, month, day, hour, minute, second, millisecond);
} else {
return '유효하지 않은 날짜 형식';
}
if (isNaN(date.getTime())) {
return '유효하지 않은 날짜';
}
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
second: 'numeric'
};
return new Intl.DateTimeFormat('ko-KR', options).format(date);
};
const formatCardDateTime = (dateStr: string, timeStr: string): string => {
const date = new Date(`${dateStr}T${timeStr}`);
const currentYear = new Date().getFullYear();
const reservationYear = date.getFullYear();
const days = ['일', '월', '화', '수', '목', '금', '토'];
const dayOfWeek = days[date.getDay()];
const month = date.getMonth() + 1;
const day = date.getDate();
let hours = date.getHours();
const minutes = date.getMinutes();
const ampm = hours >= 12 ? '오후' : '오전';
hours = hours % 12;
hours = hours ? hours : 12;
let datePart = '';
if (currentYear === reservationYear) {
datePart = `${month}${day}일(${dayOfWeek})`;
} else {
datePart = `${reservationYear}${month}${day}일(${dayOfWeek})`;
}
let timePart = `${ampm} ${hours}`;
if (minutes !== 0) {
timePart += ` ${minutes}`;
}
return `${datePart} ${timePart}`;
};
// --- Cancellation View Component ---
const CancellationView: React.FC<{
reservation: ReservationDetail;
onCancelSubmit: (reason: string) => void;
onBack: () => void;
isCancelling: boolean;
}> = ({ reservation, onCancelSubmit, onBack, isCancelling }) => {
const [reason, setReason] = useState('');
const handleSubmit = () => {
if (!reason.trim()) {
alert('취소 사유를 입력해주세요.');
return;
}
onCancelSubmit(reason);
};
return (
<div className="cancellation-view-v2">
<h3> </h3>
<div className="cancellation-summary-v2">
<p><strong>:</strong> {reservation.themeName}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
<p><strong> :</strong> {reservation.payment.totalAmount.toLocaleString()}</p>
</div>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="취소 사유를 입력해주세요."
className="cancellation-reason-textarea-v2"
rows={4}
/>
<div className="modal-actions-v2">
<button onClick={onBack} className="back-button-v2" disabled={isCancelling}></button>
<button onClick={handleSubmit} className="cancel-submit-button-v2" disabled={isCancelling}>
{isCancelling ? '취소 중...' : '취소 요청'}
</button>
</div>
</div>
);
};
const ReservationDetailView: React.FC<{
reservation: ReservationDetail;
onGoToCancel: () => void;
}> = ({ reservation, onGoToCancel }) => {
const renderPaymentDetails = (payment: PaymentRetrieveResponse) => {
const { detail } = payment;
switch (detail.type) {
case 'CARD':
return (
<>
<p><strong> ID:</strong> {payment.orderId}</p>
{payment.totalAmount === detail.amount ? (
<p><strong> :</strong> {payment.totalAmount.toLocaleString()}</p>
) : (
<>
<p><strong> :</strong> {payment.totalAmount.toLocaleString()}</p>
<p><strong> :</strong> {detail.amount.toLocaleString()}</p>
{detail.easypayDiscountAmount && (
<p><strong> :</strong> {detail.easypayDiscountAmount.toLocaleString()}</p>
)}
</>
)}
<p><strong> :</strong> {detail.easypayProviderName ? `간편결제 / ${detail.easypayProviderName}` : '카드'}</p>
<p><strong> / :</strong> {detail.issuerCode}({detail.ownerType}) / {detail.cardType}</p>
<p><strong> :</strong> {detail.cardNumber}</p>
<p><strong> :</strong> {detail.installmentPlanMonths === 0 ? '일시불' : `${detail.installmentPlanMonths}개월`}</p>
<p><strong> :</strong> {detail.approvalNumber}</p>
</>
);
case 'BANK_TRANSFER':
return (
<>
<p><strong> :</strong> </p>
<p><strong>:</strong> {detail.bankName}</p>
</>
);
case 'EASYPAY_PREPAID':
return (
<>
<p><strong> :</strong> / {detail.providerName}</p>
<p><strong> :</strong> {payment.totalAmount.toLocaleString()}</p>
<p><strong> :</strong> {detail.amount.toLocaleString()}</p>
{detail.discountAmount > 0 && <p><strong>:</strong> {detail.discountAmount.toLocaleString()}</p>}
</>
);
default:
return <p><strong> :</strong> {payment.method}</p>;
}
};
return (
<>
<div className="modal-section-v2">
<h3> </h3>
<p><strong> :</strong> {reservation.themeName}</p>
<p><strong> :</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p>
<p><strong> :</strong> {reservation.member.name}</p>
<p><strong> :</strong> {reservation.member.email}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
</div>
<div className="modal-section-v2">
<h3> </h3>
{/* <p><strong>결제금액:</strong> {reservation.payment.totalAmount.toLocaleString()}원</p> */}
{renderPaymentDetails(reservation.payment)}
<p><strong> :</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p>
</div>
{reservation.payment.cancellation && (
<div className="modal-section-v2 cancellation-section-v2">
<h3> </h3>
<p><strong> :</strong> {formatDisplayDateTime(reservation.payment.cancellation.cancellationRequestedAt)}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.payment.cancellation.cancellationApprovedAt)}</p>
<p><strong> :</strong> {reservation.payment.cancellation.cancelReason}</p>
<p><strong> :</strong> {reservation.payment.cancellation.canceledBy == reservation.member.id ? '회원 본인' : '관리자'}</p>
</div>
)}
{reservation.payment.status !== 'CANCELED' && (
<div className="modal-actions-v2">
<button onClick={onGoToCancel} className="cancel-button-v2"> </button>
</div>
)}
</>
);
};
// --- Main Page Component ---
const MyReservationPageV2: React.FC = () => {
const [reservations, setReservations] = useState<ReservationSummaryRetrieveResponse[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedReservation, setSelectedReservation] = useState<ReservationDetail | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const [modalView, setModalView] = useState<'detail' | 'cancel'>('detail');
const [isCancelling, setIsCancelling] = useState(false);
const loadReservations = async () => {
try {
setIsLoading(true);
const data = await fetchSummaryByMember();
setReservations(data.reservations);
setError(null);
} catch (err) {
setError('예약 목록을 불러오는 데 실패했습니다.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadReservations();
}, []);
const handleShowDetail = async (id: string, themeName: string, date: string, time: string) => {
try {
setIsDetailLoading(true);
setDetailError(null);
setModalView('detail');
const detailData = await fetchDetailById(id);
setSelectedReservation({
id: detailData.id,
themeName: themeName,
date: date,
startAt: time,
member: detailData.member,
applicationDateTime: detailData.applicationDateTime,
payment: detailData.payment
});
setIsModalOpen(true);
} catch (err) {
setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.');
} finally {
setIsDetailLoading(false);
}
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedReservation(null);
};
const handleCancelSubmit = async (reason: string) => {
if (!selectedReservation) return;
if (!window.confirm('정말 취소하시겠어요?')) {
return;
}
try {
setIsCancelling(true);
setDetailError(null);
await cancelReservation(selectedReservation.id, reason);
cancelPayment({ reservationId: selectedReservation.id, cancelReason: reason });
alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.');
handleCloseModal();
await loadReservations(); // Refresh the list
} catch (err) {
setDetailError(err instanceof Error ? err.message : '예약 취소에 실패했습니다.');
} finally {
setIsCancelling(true);
}
};
console.log("reservations=", reservations);
return (
<div className="my-reservation-container-v2">
<h1> V2</h1>
{isLoading && <p> ...</p>}
{error && <p className="error-message-v2">{error}</p>}
{!isLoading && !error && (
<div className="reservation-list-v2">
{reservations.map((res) => (
<div key={res.id} className={`reservation-summary-card-v2 status-${res.status.toString().toLowerCase()}`}>
<div className="summary-details-v2">
<h3 className="summary-theme-name-v2">{res.themeName}</h3>
<p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p>
</div>
<button
onClick={() => handleShowDetail(res.id, res.themeName, res.date, res.startAt)}
disabled={isDetailLoading}
className="detail-button-v2"
>
{isDetailLoading && selectedReservation?.id === res.id ? '로딩중...' : '상세보기'}
</button>
</div>
))}
</div>
)}
{isModalOpen && selectedReservation && (
<div className="modal-overlay-v2" onClick={handleCloseModal}>
<div className="modal-content-v2" onClick={(e) => e.stopPropagation()}>
<button className="modal-close-button-v2" onClick={handleCloseModal}>×</button>
<h2>{modalView === 'detail' ? '예약 상세 정보' : '예약 취소'}</h2>
{detailError && <p className="error-message-v2">{detailError}</p>}
{modalView === 'detail' ? (
<ReservationDetailView
reservation={selectedReservation}
onGoToCancel={() => setModalView('cancel')}
/>
) : (
<CancellationView
reservation={selectedReservation}
onCancelSubmit={handleCancelSubmit}
onBack={() => setModalView('detail')}
isCancelling={isCancelling}
/>
)}
</div>
</div>
)}
</div>
);
};
export default MyReservationPageV2;