generated from pricelees/issue-pr-template
<!-- 제목 양식 --> <!-- [이슈번호] 작업 요약 (예시: [#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>
343 lines
13 KiB
TypeScript
343 lines
13 KiB
TypeScript
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;
|