- 링크결제_분리승인 페이지 추가

- KeyIn결제 FormData 생성
- 링크결제_분리승인 과련 Css 추가
This commit is contained in:
HyeonJongKim
2025-10-15 16:11:39 +09:00
parent 368b553bda
commit 097b1feb0f
20 changed files with 1171 additions and 136 deletions

View File

@@ -137,6 +137,7 @@ export interface KeyInPaymentListItem {
export interface KeyInPaymentListProps {
additionalServiceCategory: AdditionalServiceCategory;
listItems: Record<string, Array<ListItemProps>>;
mid?: string;
}
export interface KeyInPaymentFilterProps extends FilterProps {

View File

@@ -5,7 +5,8 @@ import { ListDateGroup } from '../list-date-group';
export const KeyInPaymentList = ({
additionalServiceCategory,
listItems
listItems,
mid
}: KeyInPaymentListProps) => {
const { navigate } = useNavigate();
@@ -25,7 +26,9 @@ export const KeyInPaymentList = ({
};
const onClickToNavigate = () => {
navigate(PATHS.additionalService.keyInPayment.request)
navigate(PATHS.additionalService.keyInPayment.request, {
state: { mid }
});
};
return (

View File

@@ -0,0 +1,71 @@
import { motion } from 'framer-motion';
import { IMAGE_ROOT } from '@/shared/constants/common';
import { BottomSheetMotionDuration, BottomSheetMotionVaiants } from "@/entities/common/model/constant";
export interface ExtendedPeriodBottomSheetProps {
bottomSheetOn: boolean,
setBottomSheetOn: (bottomSheetOn: boolean) => void;
extendPeriod: () => void;
};
export const ExtendedPeriodBottomSheet = ({
bottomSheetOn,
setBottomSheetOn,
extendPeriod
}: ExtendedPeriodBottomSheetProps) => {
const onClickToClose = () => {
setBottomSheetOn(false);
};
const onClickToResendSms = () => {
if(extendPeriod) {
extendPeriod();
}
onClickToClose();
};
return (
<>
{ (bottomSheetOn) &&
<div className="bg-dim"></div>
}
<motion.div
className="bottomsheet"
initial="hidden"
animate={ (bottomSheetOn)? 'visible': 'hidden' }
variants={ BottomSheetMotionVaiants }
transition={ BottomSheetMotionDuration }
>
<div className="bottomsheet-header">
<div className="bottomsheet-title">
<h2> </h2>
<button
className="close-btn"
type="button"
>
<img
src={ IMAGE_ROOT + '/ico_close.svg' }
alt="취소"
onClick={ () => onClickToClose() }
/>
</button>
</div>
</div>
<div className="bottomsheet-content">
<div className="bottom-section">
<p> ?</p>
<p> .</p>
</div>
</div>
<div className="bottomsheet-footer">
<button
className="btn-50 btn-blue flex-1"
type="button"
onClick={ () => onClickToResendSms() }
></button>
</div>
</motion.div>
</>
);
}

View File

@@ -0,0 +1,71 @@
import { motion } from 'framer-motion';
import { IMAGE_ROOT } from '@/shared/constants/common';
import { BottomSheetMotionDuration, BottomSheetMotionVaiants } from "@/entities/common/model/constant";
export interface LinkBreakBottomSheetProps {
bottomSheetOn: boolean,
setBottomSheetOn: (bottomSheetOn: boolean) => void;
linkBreak: () => void;
};
export const LinkBreakBottomSheet = ({
bottomSheetOn,
setBottomSheetOn,
linkBreak
}: LinkBreakBottomSheetProps) => {
const onClickToClose = () => {
setBottomSheetOn(false);
};
const onClickToLinkBreak = () => {
if(linkBreak) {
linkBreak();
}
onClickToClose();
};
return (
<>
{ (bottomSheetOn) &&
<div className="bg-dim"></div>
}
<motion.div
className="bottomsheet"
initial="hidden"
animate={ (bottomSheetOn)? 'visible': 'hidden' }
variants={ BottomSheetMotionVaiants }
transition={ BottomSheetMotionDuration }
>
<div className="bottomsheet-header">
<div className="bottomsheet-title">
<h2></h2>
<button
className="close-btn"
type="button"
>
<img
src={ IMAGE_ROOT + '/ico_close.svg' }
alt="취소"
onClick={ () => onClickToClose() }
/>
</button>
</div>
</div>
<div className="bottomsheet-content">
<div className="bottom-section">
<p> </p>
<p> .</p>
</div>
</div>
<div className="bottomsheet-footer">
<button
className="btn-50 btn-blue flex-1"
type="button"
onClick={ () => onClickToLinkBreak() }
></button>
</div>
</motion.div>
</>
);
}

View File

@@ -54,7 +54,7 @@ export const LinkPaymentHistoryWrap = () => {
mid: mid,
searchCl: searchType === LinkPaymentSearchType.ALL ? '' : searchType,
searchValue: searchKeyword,
paymentMethod: 'st', // 추후 변경 필요 빼야함
paymentMethod: '',
fromDate: startDate,
toDate: endDate,
paymentStatus: transactionStatus === LinkPaymentTransactionStatus.ALL ? '' : transactionStatus,
@@ -91,7 +91,7 @@ export const LinkPaymentHistoryWrap = () => {
mid: mid,
searchCl: (searchType === LinkPaymentSearchType.ALL)? '': searchType,
searchValue: searchKeyword,
paymentMethod: 'st', // 추후 변경 필요 빼야함
paymentMethod: '',
fromDate: startDate,
toDate: endDate,
paymentStatus: (transactionStatus === LinkPaymentTransactionStatus.ALL)? '': transactionStatus,

View File

@@ -38,6 +38,7 @@ import { KeyInPaymentRequestSuccessPage } from './key-in-payment/request-success
import { AccountHolderSearchRequestPage } from './account-holder-search/request-page';
import { AccountHolderSearchDetailPage } from './account-holder-search/detail-page';
import { AccountHolderAuthDetailPage } from './account-holder-auth/detail-page';
import { LinkPaymentSeparateApprovalPage } from './link-payment/separate-approval/link-payment-separate-approval-page';
export const AdditionalServicePages = () => {
return (
@@ -73,6 +74,7 @@ export const AdditionalServicePages = () => {
<Route path={ROUTE_NAMES.additionalService.linkPayment.confirmSuccess} element={<LinkPaymentApplySuccessPage />} />
<Route path={ROUTE_NAMES.additionalService.linkPayment.detail} element={<LinkPaymentDetailPage />} />
<Route path={ROUTE_NAMES.additionalService.linkPayment.pendingDetail} element={<LinkPaymentWaitDetailPage />} />
<Route path={ROUTE_NAMES.additionalService.linkPayment.separateApproval} element={<LinkPaymentSeparateApprovalPage />} />
</Route>
<Route path={ROUTE_NAMES.additionalService.alimtalk.base}>
<Route path={ROUTE_NAMES.additionalService.alimtalk.list} element={<AlimtalkListPage />} />

View File

@@ -105,7 +105,9 @@ export const ArsListPage = () => {
};
const onClickToNavigate = () => {
navigate(PATHS.additionalService.ars.request);
navigate(PATHS.additionalService.ars.request, {
state: { mid }
});
};
const onClickToDownloadExcel = () => {

View File

@@ -1,23 +1,27 @@
import { ChangeEvent, useState } from 'react';
import { PATHS } from '@/shared/constants/paths';
import { useLocation } from 'react-router';
import { useNavigate } from '@/shared/lib/hooks/use-navigate';
import { IMAGE_ROOT } from '@/shared/constants/common';
import { useExtensionArsApplyMutation } from '@/entities/additional-service/api/ars/use-extension-ars-apply-mutation';
import { HeaderType } from '@/entities/common/model/types';
import {
useSetHeaderTitle,
useSetHeaderType,
useSetFooterMode,
import {
useSetHeaderTitle,
useSetHeaderType,
useSetFooterMode,
useSetOnBack
} from '@/widgets/sub-layout/use-sub-layout';
import { ArsPaymentMethod, ExtensionArsApplyParams } from '@/entities/additional-service/model/ars/types';
export const ArsRequestPage = () => {
const { navigate } = useNavigate();
const location = useLocation();
const { mid: receivedMid } = location.state || {};
const { mutateAsync: arsApply } = useExtensionArsApplyMutation();
const [mid, setMid] = useState<string>('');
const [mid, setMid] = useState<string>(receivedMid || '');
const [moid, setMoid] = useState<string>('');
const [goodsName, setGoodsName] = useState<string>('');
const [amount, setAmount] = useState<number>(0);
@@ -52,30 +56,47 @@ export const ArsRequestPage = () => {
}).catch(() => {
}).finally(() => {
});
};
const onClickToRequest = () => {
callArsApply();
};
};
const isValidPhoneNumber = (phone: string) => {
// 한국 휴대폰 번호: 010, 011, 016, 017, 018, 019로 시작, 10-11자리
const phoneRegex = /^01[0|1|6|7|8|9][0-9]{7,8}$/;
return phoneRegex.test(phone);
};
const isFormValid = () => {
return (
mid.trim() !== '' &&
moid.trim() !== '' &&
goodsName.trim() !== '' &&
amount > 0 &&
buyerName.trim() !== '' &&
isValidPhoneNumber(phoneNumber)
);
};
const getArsPaymentMethodBtns = () => {
let rs = [];
rs.push(
<div
<div
key="ars-payment-method-btns"
className="seg-buttons"
>
<button
className={`btn-36 light ${(arsPaymentMethod === ArsPaymentMethod.SMS)? 'btn-blue': 'btn-white'}`}
onClick={ (e) => setArsPaymentMethod(ArsPaymentMethod.SMS) }
>{ ArsPaymentMethod.SMS }</button>
<button
className={`btn-36 light ${(arsPaymentMethod === ArsPaymentMethod.ARS)? 'btn-blue': 'btn-white'}`}
onClick={ (e) => setArsPaymentMethod(ArsPaymentMethod.ARS) }
>{ ArsPaymentMethod.ARS }</button>
<button
className={`btn-36 light ${(arsPaymentMethod === ArsPaymentMethod.SMS) ? 'btn-blue' : 'btn-white'}`}
onClick={(e) => setArsPaymentMethod(ArsPaymentMethod.SMS)}
>{ArsPaymentMethod.SMS}</button>
<button
className={`btn-36 light ${(arsPaymentMethod === ArsPaymentMethod.ARS) ? 'btn-blue' : 'btn-white'}`}
onClick={(e) => setArsPaymentMethod(ArsPaymentMethod.ARS)}
>{ArsPaymentMethod.ARS}</button>
</div>
);
return rs;
@@ -91,10 +112,9 @@ export const ArsRequestPage = () => {
<div className="billing-row">
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
<input
type="text"
value="nictest00m"
readOnly={ true }
<input
type="text"
value={mid}
/>
</div>
</div>
@@ -102,10 +122,10 @@ export const ArsRequestPage = () => {
<div className="billing-row">
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
<input
type="text"
value={ moid }
onChange={ (e: ChangeEvent<HTMLInputElement>) => setMoid(e.target.value) }
<input
type="text"
value={moid}
onChange={(e: ChangeEvent<HTMLInputElement>) => setMoid(e.target.value)}
/>
</div>
</div>
@@ -113,80 +133,95 @@ export const ArsRequestPage = () => {
<div className="billing-row">
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
<input
type="text"
value={ goodsName }
onChange={ (e: ChangeEvent<HTMLInputElement>) => setGoodsName(e.target.value) }
<input
type="text"
value={goodsName}
onChange={(e: ChangeEvent<HTMLInputElement>) => setGoodsName(e.target.value)}
/>
</div>
</div>
<div className="billing-row">
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
<input
type="text"
value={ amount }
onChange={ (e: ChangeEvent<HTMLInputElement>) => setAmount(parseInt(e.target.value)) }
/>
</div>
<div className="billing-row">
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
<input
type="text"
value={amount}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const onlyNumbers = e.target.value.replace(/[^0-9]/g, ''); // 숫자만 남김
setAmount(onlyNumbers === '' ? 0 : parseInt(onlyNumbers));
}}
inputMode="numeric" // 모바일 키보드 숫자 전용
pattern="[0-9]*" // 브라우저 기본 숫자만 유효하도록
/>
</div>
</div>
<div className="billing-row">
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
<select disabled>
<option selected></option>
<option></option>
</select>
</div>
<div className="billing-row">
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
<select disabled>
<option selected></option>
<option></option>
</select>
</div>
</div>
<div className="billing-row">
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
<input
type="text"
value={ buyerName }
onChange={ (e: ChangeEvent<HTMLInputElement>) => setBuyerName(e.target.value) }
/>
</div>
<div className="billing-row">
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
<input
type="text"
value={buyerName}
onChange={(e: ChangeEvent<HTMLInputElement>) => setBuyerName(e.target.value)}
/>
</div>
</div>
<div className="billing-row">
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
<input
type="text"
value={ phoneNumber }
onChange={ (e: ChangeEvent<HTMLInputElement>) => setPhoneNumber(e.target.value) }
/>
</div>
<div className="billing-row">
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
<input
type="tel"
value={phoneNumber}
placeholder='01012345678'
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const onlyNumbers = e.target.value.replace(/[^0-9]/g, '');
setPhoneNumber(onlyNumbers);
}}
className={phoneNumber && !isValidPhoneNumber(phoneNumber) ? 'error' : ''}
inputMode="numeric"
pattern="[0-9]*"
maxLength={11}
/>
</div>
</div>
<div className="billing-row">
<div className="billing-label"></div>
<div className="billing-field">
<input
type="text"
value={ email }
onChange={ (e: ChangeEvent<HTMLInputElement>) => setEamil(e.target.value) }
/>
</div>
<div className="billing-row">
<div className="billing-label"></div>
<div className="billing-field">
<input
type="text"
value={email}
placeholder='test@nicepay.co.kr'
onChange={(e: ChangeEvent<HTMLInputElement>) => setEamil(e.target.value)}
/>
</div>
</div>
<div className="billing-row">
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
{ getArsPaymentMethodBtns() }
</div>
<div className="billing-row">
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
{getArsPaymentMethodBtns()}
</div>
</div>
</div>
</div>
<div className="apply-row">
<button
<button
className="btn-50 btn-blue flex-1"
onClick={ () => onClickToRequest() }
onClick={() => onClickToRequest()}
disabled={!isFormValid()}
> </button>
</div>
</div>

View File

@@ -206,6 +206,7 @@ export const KeyInPaymentPage = () => {
<KeyInPaymentList
listItems={listItems}
additionalServiceCategory={AdditionalServiceCategory.KeyInPayment}
mid={mid}
></KeyInPaymentList>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { ChangeEvent, useState } from 'react';
import { PATHS } from '@/shared/constants/paths';
import { useLocation } from 'react-router';
import { useNavigate } from '@/shared/lib/hooks/use-navigate';
import { IMAGE_ROOT } from '@/shared/constants/common';
import { HeaderType } from '@/entities/common/model/types';
@@ -10,10 +11,29 @@ import {
useSetFooterMode,
useSetOnBack
} from '@/widgets/sub-layout/use-sub-layout';
import { number } from 'framer-motion';
import { overlay } from 'overlay-kit';
import { Dialog } from '@/shared/ui/dialogs/dialog';
export const KeyInPaymentRequestPage = () => {
const { navigate } = useNavigate();
const location = useLocation();
const { mid: receivedMid } = location.state || {};
const [mid, setMid] = useState<string>(receivedMid || '');
const [goodsName, setGoodsName] = useState<string>('');
const [amount, setAmount] = useState<number>(0);
const [buyerName, setBuyerName] = useState<string>('');
const [email, setEmail] = useState<string>('');
const [phoneNumber, setPhoneNumber] = useState<string>('');
const [cardNo1, setCardNo1] = useState<string>('');
const [cardNo2, setCardNo2] = useState<string>('');
const [cardNo3, setCardNo3] = useState<string>('');
const [cardNo4, setCardNo4] = useState<string>('');
const [cardExpirationMonth, setCardExpirationMonth] = useState<string>('');
const [cardExpirationYear, setCardExpirationYear] = useState<string>('');
const [instmntMonth, setInstmntMonth] = useState<string>('00');
const [moid, setMoid] = useState<string>('');
const { mutateAsync: keyInApply } = useExtensionKeyinApplyMutation();
@@ -25,31 +45,99 @@ export const KeyInPaymentRequestPage = () => {
});
const callKeyInPaymentRequest = () => {
const cardNo = `${cardNo1}${cardNo2}${cardNo3}${cardNo4}`;
const cardExpirationDate = `${cardExpirationMonth}${cardExpirationYear}`;
let keyInApplyParams = {
mid: 'string',
goodsName: 'string',
amount: 0,
buyerName: 'string',
email: 'string',
phoneNumber: 'string',
cardNo: 'string',
cardExpirationDate: 'string',
instmntMonth: 'string',
moid: 'SMS',
mid: mid,
goodsName: goodsName,
amount: amount,
buyerName: buyerName,
email: email,
phoneNumber: phoneNumber,
cardNo: cardNo,
cardExpirationDate: cardExpirationDate,
instmntMonth: instmntMonth,
moid: moid,
};
keyInApply(keyInApplyParams).then((rs) => {
navigate(PATHS.additionalService.keyInPayment.requestSuccess);
console.log(rs)
}).catch(() => {
}).finally(() => {
console.log('결제 성공:', rs);
showSuccessDialog();
}).catch((error) => {
console.error('결제 실패:', error);
showErrorDialog(error?.message || '결제에 실패했습니다');
});
};
const showSuccessDialog = () => {
overlay.open(({ isOpen, close, unmount }) => {
return (
<Dialog
afterLeave={unmount}
open={isOpen}
onClose={close}
onConfirmClick={() => {
close();
navigate(PATHS.additionalService.keyInPayment.list);
}}
message="결제가 성공적으로 처리되었습니다"
buttonLabel={['확인']}
/>
);
});
};
const showErrorDialog = (errorMessage: string) => {
overlay.open(({ isOpen, close, unmount }) => {
return (
<Dialog
afterLeave={unmount}
open={isOpen}
onClose={close}
onConfirmClick={close}
message={errorMessage}
buttonLabel={['확인']}
/>
);
});
};
const isValidPhoneNumber = (phone: string) => {
const phoneRegex = /^01[0|1|6|7|8|9][0-9]{7,8}$/;
return phoneRegex.test(phone);
};
const isValidEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const isValidCardNumber = () => {
return cardNo1.length === 4 && cardNo2.length === 4 &&
cardNo3.length === 4 && cardNo4.length === 4;
};
const isValidCardExpiration = () => {
if (cardExpirationMonth.length !== 2 || cardExpirationYear.length !== 2) {
return false;
}
const month = parseInt(cardExpirationMonth);
return month >= 1 && month <= 12;
};
const isFormValid = () => {
return (
mid.trim() !== '' &&
goodsName.trim() !== '' &&
amount > 0 &&
buyerName.trim() !== '' &&
isValidEmail(email) &&
isValidPhoneNumber(phoneNumber) &&
isValidCardNumber() &&
isValidCardExpiration()
);
};
const onClickToRequest = () => {
callKeyInPaymentRequest();
@@ -65,11 +153,11 @@ export const KeyInPaymentRequestPage = () => {
<div className="billing-row">
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
<select
className="wid-100"
>
<option>nictest00m</option>
</select>
<input
type="text"
value={mid}
readOnly={true}
/>
</div>
</div>
@@ -78,7 +166,8 @@ export const KeyInPaymentRequestPage = () => {
<div className="billing-field">
<input
type="text"
value=""
value={goodsName}
onChange={(e: ChangeEvent<HTMLInputElement>) => setGoodsName(e.target.value)}
/>
</div>
</div>
@@ -88,7 +177,13 @@ export const KeyInPaymentRequestPage = () => {
<div className="billing-field">
<input
type="text"
value=""
value={amount}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const onlyNumbers = e.target.value.replace(/[^0-9]/g, ''); // 숫자만 남김
setAmount(onlyNumbers === '' ? 0 : parseInt(onlyNumbers));
}}
inputMode="numeric" // 모바일 키보드 숫자 전용
pattern="[0-9]*" // 브라우저 기본 숫자만 유효하도록
/>
</div>
</div>
@@ -98,7 +193,8 @@ export const KeyInPaymentRequestPage = () => {
<div className="billing-field">
<input
type="text"
value=""
value={buyerName}
onChange={(e: ChangeEvent<HTMLInputElement>) => setBuyerName(e.target.value)}
/>
</div>
</div>
@@ -108,7 +204,10 @@ export const KeyInPaymentRequestPage = () => {
<div className="billing-field">
<input
type="email"
value=""
value={email}
placeholder='test@nicepay.co.kr'
onChange={(e: ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
className={email && !isValidEmail(email) ? 'error' : ''}
/>
</div>
</div>
@@ -118,8 +217,16 @@ export const KeyInPaymentRequestPage = () => {
<div className="billing-field">
<input
type="tel"
value=""
placeholder=" '-' 제외하고 입력"
value={phoneNumber}
placeholder='01012345678'
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const onlyNumbers = e.target.value.replace(/[^0-9]/g, '');
setPhoneNumber(onlyNumbers);
}}
className={phoneNumber && !isValidPhoneNumber(phoneNumber) ? 'error' : ''}
inputMode="numeric"
pattern="[0-9]*"
maxLength={11}
/>
</div>
</div>
@@ -129,36 +236,90 @@ export const KeyInPaymentRequestPage = () => {
<div className="billing-field">
<input
type="text"
value=""
value={cardNo1}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const onlyNumbers = e.target.value.replace(/[^0-9]/g, '');
if (onlyNumbers.length <= 4) setCardNo1(onlyNumbers);
}}
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
placeholder="1234"
/>
</div>
<div className="billing-field">
<input
type="text"
value=""
value={cardNo2}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const onlyNumbers = e.target.value.replace(/[^0-9]/g, '');
if (onlyNumbers.length <= 4) setCardNo2(onlyNumbers);
}}
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
placeholder="5678"
/>
</div>
<div className="billing-field">
<input
type="text"
value=""
value={cardNo3}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const onlyNumbers = e.target.value.replace(/[^0-9]/g, '');
if (onlyNumbers.length <= 4) setCardNo3(onlyNumbers);
}}
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
placeholder="9012"
/>
</div>
<div className="billing-field">
<input
type="text"
value=""
value={cardNo4}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const onlyNumbers = e.target.value.replace(/[^0-9]/g, '');
if (onlyNumbers.length <= 4) setCardNo4(onlyNumbers);
}}
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
placeholder="3456"
/>
</div>
</div>
<div className="billing-row">
<div className="billing-label">(/)<span>*</span></div>
<div className="billing-field">
<div className="billing-field" style={{display: 'flex', gap: '8px', alignItems: 'center'}}>
<input
type="text"
value=""
placeholder='MM/YY'
value={cardExpirationMonth}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const onlyNumbers = e.target.value.replace(/[^0-9]/g, '');
if (onlyNumbers.length <= 2) setCardExpirationMonth(onlyNumbers);
}}
inputMode="numeric"
pattern="[0-9]*"
maxLength={2}
placeholder='MM'
style={{flex: 1}}
/>
<span>/</span>
<input
type="text"
value={cardExpirationYear}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const onlyNumbers = e.target.value.replace(/[^0-9]/g, '');
if (onlyNumbers.length <= 2) setCardExpirationYear(onlyNumbers);
}}
inputMode="numeric"
pattern="[0-9]*"
maxLength={2}
placeholder='YY'
style={{flex: 1}}
/>
</div>
</div>
@@ -167,9 +328,26 @@ export const KeyInPaymentRequestPage = () => {
<div className="billing-label"> <span>*</span></div>
<div className="billing-field">
<select
className="wid-100"
disabled={amount < 50000}
value={instmntMonth}
onChange={(e: ChangeEvent<HTMLSelectElement>) => setInstmntMonth(e.target.value)}
>
<option></option>
<option value="00"> ()</option>
{amount >= 50000 && (
<>
<option value="02">2</option>
<option value="03">3</option>
<option value="04">4</option>
<option value="05">5</option>
<option value="06">6</option>
<option value="07">7</option>
<option value="08">8</option>
<option value="09">9</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
</>
)}
</select>
</div>
</div>
@@ -179,7 +357,8 @@ export const KeyInPaymentRequestPage = () => {
<div className="billing-field">
<input
type="text"
value=""
value={moid}
onChange={(e: ChangeEvent<HTMLInputElement>) => setMoid(e.target.value)}
/>
</div>
</div>
@@ -188,7 +367,8 @@ export const KeyInPaymentRequestPage = () => {
<div className="apply-row">
<button
className="btn-50 btn-blue flex-1"
onClick={() => onClickToRequest() }
onClick={() => onClickToRequest()}
disabled={!isFormValid()}
> </button>
</div>
</div>

View File

@@ -95,7 +95,6 @@ export const LinkPaymentApplyConfirmPage = () => {
onClick={() => onClickToConfirm()}
> </button>
</div>
<div className="home-indicator"></div>
</main>
</>
);

View File

@@ -111,6 +111,7 @@ export const LinkPaymentApplyPage = () => {
</div>
</div>
</main>
</>
);
};

View File

@@ -11,7 +11,7 @@ export const LinkPaymentApplySuccessPage = () => {
useSetFooterMode(false);
const onClickToHome = () => {
navigate(PATHS.home);
navigate(PATHS.additionalService.linkPayment.base);
};
return (

View File

@@ -76,7 +76,7 @@ export const LinkPaymentDetailPage = () => {
setShowPayment(!showPayment);
};
const onClickToCancel = () => {
const onClickToResend = () => {
let msg = '재발송 하시겠습니까?';
overlay.open(({
@@ -96,6 +96,12 @@ export const LinkPaymentDetailPage = () => {
);
});
};
const onClickToSeparateApproval = () => {
navigate(PATHS.additionalService.linkPayment.separateApproval, {
state: { mid, tid }
});
};
useEffect(() => {
callDetail();
}, []);
@@ -122,12 +128,18 @@ export const LinkPaymentDetailPage = () => {
detailInfo={detailInfo}
></DetailInfoWrap>
</div>
</div>
<div className="apply-row">
<button
className="btn-50 btn-blue flex-1"
onClick={() => onClickToCancel()}
></button>
<div className="apply-row">
<button
className="btn-50 btn-blue flex-1"
onClick={() => onClickToSeparateApproval()}
> </button>
</div>
{/* <div className="apply-row">
<button
className="btn-50 btn-blue flex-1"
onClick={() => onClickToResend()}
>재발송</button>
</div> */}
</div>
</div>
</main >

View File

@@ -0,0 +1,69 @@
import { motion } from 'framer-motion';
import { useNavigate } from '@/shared/lib/hooks/use-navigate';
import { PATHS } from "@/shared/constants/paths";
import {
FilterMotionDuration,
FilterMotionStyle,
FilterMotionVariants
} from '@/entities/common/model/constant';
export interface LinkPaymentApplyFailPageProps {
pageOn: boolean;
setPageOn: (pageOn: boolean) => void;
errorMessage?: string;
}
export const LinkPaymentApplyFailPage = ({
pageOn,
setPageOn,
errorMessage
}: LinkPaymentApplyFailPageProps) => {
const { navigate } = useNavigate();
const onClickToClose = () => {
setPageOn(false);
navigate(PATHS.additionalService.linkPayment.shippingHistory);
};
return (
<>
<motion.div
className="full-menu-modal"
initial="hidden"
animate={(pageOn) ? 'visible' : 'hidden'}
variants={FilterMotionVariants}
transition={FilterMotionDuration}
style={{ ...FilterMotionStyle, overflow: 'hidden' }}
>
<div className="full-menu-container" style={{ justifyContent: 'center', alignItems: 'center' }}>
<div className="success-page">
<div className="success-body">
<div
className="error-icon"
aria-hidden="true"
></div>
<h1 className="success-title">
<span>_분리승인</span>
<br />
<span> </span>
</h1>
<div className="success-result">
<p className="result-text align-left position_label">
<span> :</span>
<span>{errorMessage || '다시 시도해 주세요'}</span>
</p>
</div>
</div>
<div className="apply-row">
<button
className="btn-50 btn-blue flex-1"
onClick={onClickToClose}
></button>
</div>
</div>
</div>
</motion.div>
</>
);
};

View File

@@ -0,0 +1,297 @@
import { useEffect, useState } from 'react';
import { PATHS } from '@/shared/constants/paths';
import { useNavigate } from '@/shared/lib/hooks/use-navigate';
import { HeaderType } from '@/entities/common/model/types';
import {
useSetOnBack,
useSetHeaderTitle,
useSetHeaderType,
useSetFooterMode
} from '@/widgets/sub-layout/use-sub-layout';
import { ExtendedPeriodBottomSheet } from '@/entities/additional-service/ui/link-payment/bottom-sheet/extended-period-bottom-sheet';
import { LinkBreakBottomSheet } from '@/entities/additional-service/ui/link-payment/bottom-sheet/link-break-bottom-sheet';
import { LinkPaymentApplySuccessPage } from './link-payment-separate-approval-success-page';
import { LinkPaymentApplyFailPage } from './link-payment-separate-approval-fail';
interface SeparateApprovalItem {
tid: string;
merchantId: string;
amount: number;
status: string;
validityPeriod: string;
approvalCount: number;
}
export const LinkPaymentSeparateApprovalPage = () => {
const { navigate } = useNavigate();
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [extendedPeriodBottomSheetOn, setExtendedPeriodBottomSheetOn] = useState<boolean>(false);
const [linkBreakBottomSheetOn, setLinkBreakBottomSheetOn] = useState<boolean>(false);
const [successPageOn, setSuccessPageOn] = useState<boolean>(false);
const [failPageOn, setFailPageOn] = useState<boolean>(false);
const [resultMessage, setResultMessage] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>('');
const [mainItem] = useState<SeparateApprovalItem>({
tid: 'NLPDAR2025101315330',
merchantId: '100,000',
amount: 100000,
status: '활성화',
validityPeriod: '2025/10/13',
approvalCount: 0
});
const [additionalItems] = useState<SeparateApprovalItem[]>([
{
tid: '',
merchantId: '(NNN,NNN)',
amount: 0,
status: '(결제상태)',
validityPeriod: 'YYYY/MM/DD',
approvalCount: 0
}
]);
useSetHeaderTitle('분리승인 상세');
useSetHeaderType(HeaderType.RightClose);
useSetOnBack(() => {
navigate(PATHS.additionalService.linkPayment.shippingHistory);
});
useSetFooterMode(false);
const handleCheckboxChange = (tid: string) => {
setSelectedItems(prev => {
if (prev.includes(tid)) {
return prev.filter(id => id !== tid);
} else {
return [...prev, tid];
}
});
};
const handleMainCheckboxChange = () => {
const mainTid = mainItem.tid;
setSelectedItems(prev => {
if (prev.includes(mainTid)) {
return prev.filter(id => id !== mainTid);
} else {
return [...prev, mainTid];
}
});
};
const handleAdditionalCheckboxChange = (index: number) => {
const itemTid = `additional-${index}`;
handleCheckboxChange(itemTid);
};
const onClickToValidityPeriod = () => {
// 기간연장 바텀시트 열기
setExtendedPeriodBottomSheetOn(true);
};
const onClickToSendLink = () => {
// 링크중단 바텀시트 열기
setLinkBreakBottomSheetOn(true);
};
const handleExtendPeriod = () => {
// 바텀시트 닫기
setExtendedPeriodBottomSheetOn(false);
// 기간연장 API 호출 로직
console.log('기간연장 실행', selectedItems);
// TODO: 실제 API 호출
const apiCallSuccess = true; // 임시로 성공으로 설정
if (apiCallSuccess) {
setResultMessage('기간이 성공적으로 연장되었습니다');
setSuccessPageOn(true);
} else {
setErrorMessage('기간 연장에 실패했습니다');
setFailPageOn(true);
}
};
const handleLinkBreak = () => {
// 바텀시트 닫기
setLinkBreakBottomSheetOn(false);
// 링크중단 API 호출 로직
console.log('링크중단 실행', selectedItems);
// TODO: 실제 API 호출
const apiCallSuccess = true; // 임시로 성공으로 설정
if (apiCallSuccess) {
setResultMessage('링크가 성공적으로 중단되었습니다');
setSuccessPageOn(true);
} else {
setErrorMessage('링크 중단에 실패했습니다');
setFailPageOn(true);
}
};
useEffect(() => {
// API 호출하여 데이터 로드
// const { mid, tid } = location.state || {};
}, []);
return (
<>
<main className="separate-approval-main">
<div className="tab-content">
<div className="tab-pane sub active">
<div className="separate-approval-section">
<div className="approval-notice-box">
<p> 기간: 최대 7, 3 </p>
<p> 중단: 유효기간 , , </p>
</div>
<div className="approval-cards-wrapper">
{/* Main Item */}
<div className={`approval-card ${selectedItems.includes(mainItem.tid) ? 'selected' : ''}`}>
<div className="card-header">
<input
type="checkbox"
id={`checkbox-${mainItem.tid}`}
name={`checkbox-${mainItem.tid}`}
checked={selectedItems.includes(mainItem.tid)}
onChange={handleMainCheckboxChange}
className="card-checkbox"
/>
<span className="card-tag">[MAIN]</span>
<span className="card-tid">{mainItem.tid}</span>
</div>
<div className="card-body">
<ul className="info-list">
<li>
<span className="label"> :</span>
<span className="value">{mainItem.merchantId}</span>
</li>
<li>
<span className="label"> :</span>
<span className="value">{mainItem.status}</span>
</li>
<li>
<span className="label"> :</span>
<span className="value">{mainItem.validityPeriod}</span>
</li>
<li>
<span className="label"> :</span>
<span className="value">{mainItem.approvalCount}</span>
</li>
</ul>
</div>
<div className="card-footer">
<div className="period-selector">
<label></label>
<select>
<option value=""></option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
</select>
</div>
</div>
</div>
{/* Additional Items */}
{additionalItems.map((item, index) => (
<div key={index} className={`approval-card ${selectedItems.includes(`additional-${index}`) ? 'selected' : ''}`}>
<input
type="checkbox"
checked={selectedItems.includes(`additional-${index}`)}
onChange={() => handleAdditionalCheckboxChange(index)}
className="card-checkbox"
/>
<div className="card-header">
<span className="card-tag">()</span>
<span className="card-tig"></span>
</div>
<div className="card-body">
<ul className="info-list">
<li>
<span className="label"> :</span>
<span className="value">{item.merchantId}</span>
</li>
<li>
<span className="label"> :</span>
<span className="value">{item.status}</span>
</li>
<li>
<span className="label"> :</span>
<span className="value">{item.validityPeriod}</span>
</li>
<li>
<span className="label"> :</span>
<span className="value">(N)</span>
</li>
</ul>
</div>
<div className="card-footer">
<div className="period-selector">
<label></label>
<select>
<option value=""></option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
</select>
</div>
</div>
</div>
))}
</div>
</div>
<div className="apply-row two-button">
<button
className="btn-50 btn-blue flex-1"
onClick={onClickToValidityPeriod}
>
</button>
<button
className="btn-50 btn-blue flex-1"
onClick={onClickToSendLink}
>
</button>
</div>
</div>
</div>
</main>
<ExtendedPeriodBottomSheet
bottomSheetOn={extendedPeriodBottomSheetOn}
setBottomSheetOn={setExtendedPeriodBottomSheetOn}
extendPeriod={handleExtendPeriod}
/>
<LinkBreakBottomSheet
bottomSheetOn={linkBreakBottomSheetOn}
setBottomSheetOn={setLinkBreakBottomSheetOn}
linkBreak={handleLinkBreak}
/>
<LinkPaymentApplySuccessPage
pageOn={successPageOn}
setPageOn={setSuccessPageOn}
resultMessage={resultMessage}
/>
<LinkPaymentApplyFailPage
pageOn={failPageOn}
setPageOn={setFailPageOn}
errorMessage={errorMessage}
/>
</>
);
};

View File

@@ -0,0 +1,66 @@
import { motion } from 'framer-motion';
import { useNavigate } from '@/shared/lib/hooks/use-navigate';
import { PATHS } from "@/shared/constants/paths";
import {
FilterMotionDuration,
FilterMotionStyle,
FilterMotionVariants
} from '@/entities/common/model/constant';
export interface LinkPaymentApplySuccessPageProps {
pageOn: boolean;
setPageOn: (pageOn: boolean) => void;
resultMessage?: string;
}
export const LinkPaymentApplySuccessPage = ({
pageOn,
setPageOn,
resultMessage
}: LinkPaymentApplySuccessPageProps) => {
const { navigate } = useNavigate();
const onClickToClose = () => {
setPageOn(false);
navigate(PATHS.additionalService.linkPayment.shippingHistory);
};
return (
<>
<motion.div
className="full-menu-modal"
initial="hidden"
animate={(pageOn) ? 'visible' : 'hidden'}
variants={FilterMotionVariants}
transition={FilterMotionDuration}
style={{ ...FilterMotionStyle, overflow: 'hidden' }}
>
<div className="full-menu-container" style={{ paddingTop: '0px' }}>
<div className="success-page">
<div className="success-body">
<div
className="success-icon"
aria-hidden="true"
></div>
<h1 className="success-title">
<span>_분리승인</span>
</h1>
<div className="success-result">
<p className="result-text align-left position_label">
<span> :</span>
<span>{resultMessage || '성공적으로 처리되었습니다'}</span>
</p>
</div>
</div>
<div className="apply-row">
<button
className="btn-50 btn-blue flex-1"
onClick={onClickToClose}
></button>
</div>
</div>
</div>
</motion.div>
</>
);
};

View File

@@ -227,6 +227,10 @@ export const PATHS: RouteNamesType = {
pendingDetail: generatePath(
`${ROUTE_NAMES.additionalService.base}${ROUTE_NAMES.additionalService.linkPayment.base}`,
ROUTE_NAMES.additionalService.linkPayment.pendingDetail,
),
separateApproval: generatePath(
`${ROUTE_NAMES.additionalService.base}${ROUTE_NAMES.additionalService.linkPayment.base}`,
ROUTE_NAMES.additionalService.linkPayment.separateApproval
)
},
alimtalk: {

View File

@@ -101,7 +101,8 @@ export const ROUTE_NAMES = {
requestConfirm: 'request-confirm',
confirmSuccess: 'confirm-success',
detail: 'detail',
pendingDetail: 'pending-detail'
pendingDetail: 'pending-detail',
separateApproval: 'separate-approval',
},
alimtalk: {
base: '/alimtalk/*',

View File

@@ -1261,7 +1261,7 @@ input[type="radio"] {
border-radius: 50%;
background: var(--color-CCCCCC);
cursor: pointer;
transition: all 0.2s ease;
transition: all 0.4s ease;
}
.banner-dot.active {
@@ -5948,3 +5948,223 @@ ul.txn-amount-detail li span:last-child {
box-sizing: border-box;
}
/* 분할승인 상세 */
.separate-approval-main {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
}
.separate-approval-main .tab-content {
height: 100%;
}
.separate-approval-main .tab-pane.sub.active {
height: 100%;
display: flex;
flex-direction: column;
}
.separate-approval-section {
padding: 0;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.approval-cards-wrapper {
padding-bottom: 20px;
}
.separate-approval-main .apply-row.two-button {
flex-shrink: 0;
position: sticky;
bottom: 0;
background: white;
z-index: 10;
}
/* 분할승인 안내 박스 */
.approval-notice-box {
background: var(--color-white);
padding: 8px;
margin-bottom: 16px;
border-radius: 12px;
flex-shrink: 0;
}
.approval-notice-box p {
font-size: var(--fs-15);
color: var(--color-666666);
line-height: 1.5;
margin: 0;
}
.approval-notice-box p + p {
margin-top: 4px;
}
/* 분할승인 카드 */
.approval-card {
position: relative;
background: var(--color-white);
border: 2px solid var(--color-d6d6d6);
border-radius: 16px;
padding: 16px;
margin-bottom: 16px;
transition: all 0.4s ease;
}
.approval-card.selected {
border-color: var(--color-3E6AFC);
background: var(--color-F4F8FF);
}
/* 분할승인 체크박스 */
.approval-card .card-checkbox {
display: block !important;
position: absolute;
top: 16px;
left: 16px;
width: 24px;
height: 24px;
margin: 0;
padding: 0;
appearance: none;
border: 2px solid var(--color-d6d6d6);
border-radius: 5px;
background-color: var(--color-white);
cursor: pointer;
outline: none;
box-sizing: border-box;
z-index: 1;
}
.approval-card .card-checkbox:focus,
.approval-card .card-checkbox:active {
outline: none !important;
box-shadow: none !important;
border-color: var(--color-d6d6d6);
}
.approval-card .card-checkbox:checked {
background-color: var(--color-3E6AFC);
border-color: var(--color-3E6AFC);
outline: none;
}
.approval-card .card-checkbox:checked::after {
content: '';
position: absolute;
left: 5px;
top: 2px;
width: 6px;
height: 11px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* 분할승인 카드 헤더 */
.approval-card .card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding-bottom: 12px;
padding-left: 36px;
border-bottom: 1px solid var(--color-E5E5E5);
}
.approval-card .card-tag {
font-size: var(--fs-14);
font-weight: var(--fw-700);
padding: 2px 8px;
border-radius: 4px;
color: var(--color-666666);
background: var(--color-F3F3F3);
transition: all 0.4s ease;
}
.approval-card .card-tag.main-tag {
color: var(--color-3E6AFC);
background: var(--color-E9F1FB);
}
.approval-card.selected .card-tag {
color: var(--color-3E6AFC);
background: var(--color-E9F1FB);
}
.approval-card .card-tid {
font-size: var(--fs-16);
color: var(--color-2D3436);
font-weight: var(--fw-500);
}
/* 분할승인 카드 바디 */
.approval-card .card-body {
margin-bottom: 16px;
}
.approval-card .info-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.approval-card .info-list li {
display: flex;
align-items: center;
font-size: var(--fs-14);
color: var(--color-2D3436);
}
.approval-card .info-list .label {
min-width: 85px;
color: var(--color-666666);
font-weight: var(--fw-400);
}
.approval-card .info-list .value {
color: var(--color-2D3436);
font-weight: var(--fw-500);
}
/* 분할승인 카드 푸터 */
.approval-card .card-footer {
padding-top: 12px;
border-top: 1px solid var(--color-E5E5E5);
}
.approval-card .period-selector {
display: flex;
align-items: center;
gap: 12px;
}
.approval-card .period-selector label {
font-size: var(--fs-14);
color: var(--color-666666);
font-weight: var(--fw-500);
min-width: 60px;
}
.approval-card .period-selector select {
flex: 1;
height: 36px;
font-size: var(--fs-14);
padding: 6px 30px 6px 12px;
border: 1px solid var(--color-d6d6d6);
border-radius: 4px;
background-color: var(--color-white);
transition: all 0.2s ease;
}