diff --git a/src/entities/transaction/ui/cash-receipt-hand-written-issuance-step1.tsx b/src/entities/transaction/ui/cash-receipt-hand-written-issuance-step1.tsx
index fea081d..558d1a3 100644
--- a/src/entities/transaction/ui/cash-receipt-hand-written-issuance-step1.tsx
+++ b/src/entities/transaction/ui/cash-receipt-hand-written-issuance-step1.tsx
@@ -6,6 +6,7 @@ import { CashReceiptPurposeType } from '../model/types';
import { PatternFormat } from 'react-number-format';
import { useTranslation } from 'react-i18next';
import { useKeyboardAware } from '@/shared/lib/hooks/use-keyboard-aware';
+import { MaskedNameInput, MaskedEmailInput, MaskedPhoneInput } from '@/shared/ui/masked-input';
export interface CashReceiptHandWrittenIssuanceStep1Props {
businessNumber?: string;
@@ -95,11 +96,10 @@ export const CashReceiptHandWrittenIssuanceStep1 = ({
{t('transaction.fields.buyer')}
- ) => setBuyerName(e.target.value)}
onFocus={handleInputFocus}
/>
@@ -120,11 +120,10 @@ export const CashReceiptHandWrittenIssuanceStep1 = ({
{t('account.emailAddress')}
- ) => setEmail(e.target.value)}
onFocus={handleInputFocus}
/>
@@ -132,14 +131,12 @@ export const CashReceiptHandWrittenIssuanceStep1 = ({
{t('account.phoneNumber')}
-
) => setPhoneNumber(e.target.value)}
onFocus={handleInputFocus}
- >
+ />
diff --git a/src/locales/en.json b/src/locales/en.json
index 8bfb730..c5e7a8a 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -774,7 +774,23 @@
"billing": {
"title": "Billing",
"detailTitle": "Billing Details",
- "charge": "Billing Charge"
+ "charge": "Billing Charge",
+ "paymentInfoInput": "Enter Payment Information",
+ "billKey": "Bill Key",
+ "productAmount": "Product Amount",
+ "paymentRequestDate": "Payment Request Date",
+ "selectDate": "Select Date",
+ "installmentMonth": "Installment Months",
+ "lumpSum": "Lump Sum",
+ "months": "{{count}} Months",
+ "chargeRequest": "Request Payment",
+ "billKeyRequired": "Bill key is required.",
+ "productNameRequired": "Product name is required.",
+ "productAmountRequired": "Product amount is required.",
+ "productAmountMinimum": "Product amount must be greater than 0.",
+ "orderNumberRequired": "Order number is required.",
+ "buyerNameRequired": "Buyer name is required.",
+ "chargeSuccess": "Payment request was successful."
},
"payment": {
"title": "Payment Management",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index 34cded2..5815dce 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -774,7 +774,23 @@
"billing": {
"title": "빌링",
"detailTitle": "빌링 상세",
- "charge": "빌링 청구"
+ "charge": "빌링 청구",
+ "paymentInfoInput": "결제 정보 입력",
+ "billKey": "빌키",
+ "productAmount": "상품금액",
+ "paymentRequestDate": "결제 요청일자",
+ "selectDate": "날짜 선택",
+ "installmentMonth": "할부 개월",
+ "lumpSum": "일시불",
+ "months": "{{count}}개월",
+ "chargeRequest": "결제 신청",
+ "billKeyRequired": "빌키는 필수 입력 항목입니다.",
+ "productNameRequired": "상품명은 필수 입력 항목입니다.",
+ "productAmountRequired": "상품금액은 필수 입력 항목입니다.",
+ "productAmountMinimum": "상품금액은 0보다 커야 합니다.",
+ "orderNumberRequired": "주문번호는 필수 입력 항목입니다.",
+ "buyerNameRequired": "구매자명은 필수 입력 항목입니다.",
+ "chargeSuccess": "결제 신청을 성공하였습니다."
},
"payment": {
"title": "결제 관리",
diff --git a/src/pages/additional-service/account-holder-search/request-page.tsx b/src/pages/additional-service/account-holder-search/request-page.tsx
index c72dd72..235f226 100644
--- a/src/pages/additional-service/account-holder-search/request-page.tsx
+++ b/src/pages/additional-service/account-holder-search/request-page.tsx
@@ -11,11 +11,11 @@ import {
import { useExtensionAccountHolderSearchRequestMutation } from '@/entities/additional-service/api/account-holder-search/use-extension-account-holder-search-reqeust-mutation';
import { ExtensionAccountHolderSearchRequestParams, ExtensionAccountHolderSearchRequestResponse } from '@/entities/additional-service/model/account-holder-search/types';
import { useStore } from '@/shared/model/store';
-import { NumericFormat } from 'react-number-format';
import { snackBar } from '@/shared/lib';
import { useTranslation } from 'react-i18next';
import { showAlert } from '@/widgets/show-alert';
import { AccountHolderSearchDetail } from '@/entities/additional-service/ui/account-holder-search/detail/account-holder-search-detail';
+import { MaskedAccountNumberInput } from '@/shared/ui/masked-input';
export const AccountHolderSearchRequestPage = () => {
const { navigate } = useNavigate();
@@ -145,18 +145,11 @@ export const AccountHolderSearchRequestPage = () => {
{t('transaction.fields.accountNo')}
- {
- const { value } = values;
- return !value || value.length <= 14;
- }}
- onValueChange={(values) => {
- setFormData({ ...formData, accountNo: values.value });
- }}
+ placeholder=''
+ onChange={(value) => setFormData({ ...formData, accountNo: value })}
+ maxLength={14}
/>
diff --git a/src/pages/support/qna/register-page.tsx b/src/pages/support/qna/register-page.tsx
index b1223f2..35439b6 100644
--- a/src/pages/support/qna/register-page.tsx
+++ b/src/pages/support/qna/register-page.tsx
@@ -11,7 +11,6 @@ import {
} from '@/widgets/sub-layout/use-sub-layout';
import { useStore } from '@/shared/model/store';
import { useTranslation } from 'react-i18next';
-import { PatternFormat } from 'react-number-format';
import { overlay } from 'overlay-kit';
import { Dialog } from '@/shared/ui/dialogs/dialog';
import { QnaSaveParams, QnaSaveResponse } from '@/entities/support/model/types';
@@ -19,6 +18,7 @@ import { checkGrant } from '@/shared/lib/check-grant';
import { useKeyboardAware } from '@/shared/lib/hooks/use-keyboard-aware';
import { snackBar } from '@/shared/lib';
import { showAlert } from '@/widgets/show-alert';
+import { MaskedNameInput, MaskedPhoneInput, MaskedEmailInput } from '@/shared/ui/masked-input';
export enum QnaRegisterPropsName {
Mid = 'Mid',
@@ -189,11 +189,10 @@ export const QnaRegisterPage = () => {
{t('support.qna.formLabels.requesterName')}
{t('support.qna.formLabels.required')}
{t('support.qna.formLabels.emailAddress')}
- ) => setInputValue(e, QnaRegisterPropsName.RequestEmail)}
+ onChange={setRequestEmail}
onFocus={handleInputFocus}
/>
diff --git a/src/pages/transaction/billing/charge-page.tsx b/src/pages/transaction/billing/charge-page.tsx
index c2f0471..0560ec0 100644
--- a/src/pages/transaction/billing/charge-page.tsx
+++ b/src/pages/transaction/billing/charge-page.tsx
@@ -19,6 +19,7 @@ import { notiBar, snackBar } from '@/shared/lib';
import { BillingChargeParams, BillingChargeResponse } from '@/entities/transaction/model/types';
import { useKeyboardAware } from '@/shared/lib/hooks/use-keyboard-aware';
import { checkGrant } from '@/shared/lib/check-grant';
+import { MaskedNameInput } from '@/shared/ui/masked-input';
const menuId = 34;
export const BillingChargePage = () => {
@@ -56,23 +57,23 @@ export const BillingChargePage = () => {
const onClickToBillingCharge = () => {
if(checkGrant(menuId, 'W')){
if (!billKey) {
- showAlert('빌키는 필수 입력 항목입니다.');
+ showAlert(t('billing.billKeyRequired'));
return;
}
else if (!productName) {
- showAlert('상품명은 필수 입력 항목입니다.');
+ showAlert(t('billing.productNameRequired'));
}
else if (!productAmount) {
- showAlert('상품금액은 필수 입력 항목입니다.');
+ showAlert(t('billing.productAmountRequired'));
}
else if (productAmount <= 0) {
- showAlert('상품금액은 0보다 커야 합니다.');
+ showAlert(t('billing.productAmountMinimum'));
}
else if (!orderNumber) {
- showAlert('주문번호는 필수 입력 항목입니다.');
+ showAlert(t('billing.orderNumberRequired'));
}
else if (!buyerName) {
- showAlert('구매자명은 필수 입력 항목입니다.');
+ showAlert(t('billing.buyerNameRequired'));
}
let params: BillingChargeParams = {
@@ -85,7 +86,7 @@ export const BillingChargePage = () => {
installmentMonth: installmentMonth
};
billingCharge(params).then((rs: BillingChargeResponse) => {
- snackBar('결제 신청을 성공하였습니다.', function () {
+ snackBar(t('billing.chargeSuccess'), function () {
navigate(PATHS.transaction.billing.list);
}, 3000);
@@ -98,8 +99,8 @@ export const BillingChargePage = () => {
}
else{
showAlert(t('common.nopermission'));
- }
-
+ }
+
};
const onChangeBillKey = (value: string) => {
@@ -116,13 +117,13 @@ export const BillingChargePage = () => {
+ >{t('common.select')}
);
rs.push(
+ >{t('billing.lumpSum')}
);
for (let i = 2; i <= 33; i++) {
let val = (i < 10) ? '0' + i : '' + i;
@@ -130,7 +131,7 @@ export const BillingChargePage = () => {
+ >{t('billing.months', { count: i })}
);
};
return rs;
@@ -142,10 +143,10 @@ export const BillingChargePage = () => {
-
결제 정보 입력
+
{t('billing.paymentInfoInput')}
-
빌키 *
+
{t('billing.billKey')} *
{
-
상품명 *
+
{t('transaction.fields.productName')} *
{
-
상품금액 *
+
{t('billing.productAmount')} *
{
-
주문번호 *
+
{t('transaction.fields.orderNumber')} *
{
-
구매자명 *
+
{t('transaction.fields.buyer')} *
- ) => setBuyerName(e.target.value)}
+ placeholder=''
+ onChange={setBuyerName}
onFocus={handleInputFocus}
/>
-
결제 요청일자
+
{t('billing.paymentRequestDate')}
@@ -226,7 +227,7 @@ export const BillingChargePage = () => {
-
할부 개월
+
{t('billing.installmentMonth')}
diff --git a/src/pages/transaction/cash-receipt/list-page.tsx b/src/pages/transaction/cash-receipt/list-page.tsx
index c51ca9d..3819a9e 100644
--- a/src/pages/transaction/cash-receipt/list-page.tsx
+++ b/src/pages/transaction/cash-receipt/list-page.tsx
@@ -247,7 +247,7 @@ export const CashReceiptListPage = () => {
setTransactionType(val);
};
const onClickToNavigate = () => {
- if(checkGrant(menuId, 'X')){
+ if(checkGrant(menuId, 'W')){
navigate(PATHS.transaction.cashReceipt.handWrittenIssuance);
}
else{
diff --git a/src/shared/lib/masking-utils.ts b/src/shared/lib/masking-utils.ts
new file mode 100644
index 0000000..010740d
--- /dev/null
+++ b/src/shared/lib/masking-utils.ts
@@ -0,0 +1,66 @@
+/**
+ * 마스킹 유틸리티 함수
+ * 개인정보 보호를 위한 데이터 마스킹 처리
+ */
+
+/**
+ * 이름 마스킹
+ * - 1글자: 그대로 표시
+ * - 2글자: 첫 글자 + * (홍*, j*)
+ * - 3글자: 첫 글자 + * + 마지막 글자 (홍*동, j*n)
+ * - 4글자 이상: 첫 글자 + *** + 마지막 글자 (선**녀, j***n)
+ */
+export const getMaskedName = (name?: string): string => {
+ if (!name) return '';
+ const length = name.length;
+ if (length <= 1) return name;
+ if (length === 2) return name[0] + '*';
+ if (length === 3) return name[0] + '*' + name[2];
+ const firstChar = name[0];
+ const lastChar = name[length - 1];
+ const maskedLength = length - 2;
+ const masked = '*'.repeat(maskedLength);
+ return firstChar + masked + lastChar;
+};
+
+/**
+ * 전화번호 마스킹
+ * - 마지막 4자리 마스킹
+ * - 예: 0101234****
+ */
+export const getMaskedPhoneNumber = (phone?: string): string => {
+ if (!phone) return '';
+ if (phone.length <= 7) return phone;
+ const visiblePart = phone.slice(0, -4);
+ return visiblePart + '****';
+};
+
+/**
+ * 이메일 마스킹
+ * - 앞 2자리만 표시, @ 이전 부분 마스킹
+ * - 예: te**@nicepay.co.kr
+ */
+export const getMaskedEmail = (email?: string): string => {
+ if (!email) return '';
+ const atIndex = email.indexOf('@');
+ if (atIndex === -1 || atIndex <= 2) return email;
+ const visiblePart = email.slice(0, 2);
+ const domainPart = email.slice(atIndex);
+ const maskedLength = atIndex - 2;
+ const masked = '*'.repeat(maskedLength);
+ return visiblePart + masked + domainPart;
+};
+
+/**
+ * 계좌번호 마스킹
+ * - 마지막 5자리만 표시
+ * - 예: *********74018
+ */
+export const getMaskedAccountNumber = (accountNo?: string): string => {
+ if (!accountNo) return '';
+ if (accountNo.length <= 5) return accountNo;
+ const visiblePart = accountNo.slice(-5);
+ const maskedLength = accountNo.length - 5;
+ const masked = '*'.repeat(maskedLength);
+ return masked + visiblePart;
+};
diff --git a/src/shared/ui/masked-input/index.ts b/src/shared/ui/masked-input/index.ts
new file mode 100644
index 0000000..b3e736e
--- /dev/null
+++ b/src/shared/ui/masked-input/index.ts
@@ -0,0 +1,8 @@
+export { MaskedNameInput } from './masked-name-input';
+export { MaskedPhoneInput } from './masked-phone-input';
+export { MaskedEmailInput } from './masked-email-input';
+export { MaskedAccountNumberInput } from './masked-account-number-input';
+export type { MaskedNameInputProps } from './masked-name-input';
+export type { MaskedPhoneInputProps } from './masked-phone-input';
+export type { MaskedEmailInputProps } from './masked-email-input';
+export type { MaskedAccountNumberInputProps } from './masked-account-number-input';
diff --git a/src/shared/ui/masked-input/masked-account-number-input.tsx b/src/shared/ui/masked-input/masked-account-number-input.tsx
new file mode 100644
index 0000000..d37ba95
--- /dev/null
+++ b/src/shared/ui/masked-input/masked-account-number-input.tsx
@@ -0,0 +1,54 @@
+import { ChangeEvent } from 'react';
+import { getMaskedAccountNumber } from '@/shared/lib/masking-utils';
+
+export interface MaskedAccountNumberInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ onFocus?: (e: React.FocusEvent
) => void;
+ className?: string;
+ maxLength?: number;
+}
+
+export const MaskedAccountNumberInput = ({
+ value,
+ onChange,
+ placeholder = '*********74018',
+ onFocus,
+ className = '',
+ maxLength = 14
+}: MaskedAccountNumberInputProps) => {
+ return (
+ {
+ // 백스페이스 처리
+ if (e.key === 'Backspace') {
+ e.preventDefault();
+ onChange(value.slice(0, -1));
+ }
+ // 숫자 입력 처리
+ else if (/^[0-9]$/.test(e.key)) {
+ e.preventDefault();
+ if (value.length < maxLength) {
+ onChange(value + e.key);
+ }
+ }
+ }}
+ onChange={(e: ChangeEvent) => {
+ // Android 등에서 한글 키보드로 입력 시 대비
+ const input = e.target.value;
+ const digitsOnly = input.replace(/\*/g, '').replace(/[^0-9]/g, '');
+ if (digitsOnly.length <= maxLength) {
+ onChange(digitsOnly);
+ }
+ }}
+ inputMode="numeric"
+ pattern="[0-9]*"
+ onFocus={onFocus}
+ />
+ );
+};
diff --git a/src/shared/ui/masked-input/masked-email-input.tsx b/src/shared/ui/masked-input/masked-email-input.tsx
new file mode 100644
index 0000000..c65ff43
--- /dev/null
+++ b/src/shared/ui/masked-input/masked-email-input.tsx
@@ -0,0 +1,61 @@
+import { ChangeEvent } from 'react';
+import { getMaskedEmail } from '@/shared/lib/masking-utils';
+
+export interface MaskedEmailInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ onFocus?: (e: React.FocusEvent) => void;
+ className?: string;
+ isValid?: (value: string) => boolean;
+}
+
+export const MaskedEmailInput = ({
+ value,
+ onChange,
+ placeholder = 'te**@nicepay.co.kr',
+ onFocus,
+ className = '',
+ isValid
+}: MaskedEmailInputProps) => {
+ const validationClass = isValid && value ? (isValid(value) ? '' : 'error') : '';
+
+ return (
+ {
+ // 백스페이스 처리
+ if (e.key === 'Backspace') {
+ e.preventDefault();
+ onChange(value.slice(0, -1));
+ }
+ // 영문, 숫자, @, ., - 등 이메일에 허용되는 문자만 입력 가능
+ else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
+ if (/^[a-zA-Z0-9@._-]$/.test(e.key)) {
+ e.preventDefault();
+ onChange(value + e.key);
+ } else {
+ e.preventDefault();
+ }
+ }
+ }}
+ onChange={(e: ChangeEvent) => {
+ // 복사/붙여넣기 등 특수 입력 대비
+ const input = e.target.value;
+ const filteredInput = input.replace(/[^a-zA-Z0-9@._-]/g, '');
+ const atIndex = filteredInput.indexOf('@');
+ if (atIndex !== -1) {
+ const beforeAt = filteredInput.slice(0, atIndex).replace(/\*/g, '');
+ const afterAt = filteredInput.slice(atIndex);
+ onChange(beforeAt + afterAt);
+ } else {
+ onChange(filteredInput.replace(/\*/g, ''));
+ }
+ }}
+ onFocus={onFocus}
+ />
+ );
+};
diff --git a/src/shared/ui/masked-input/masked-name-input.tsx b/src/shared/ui/masked-input/masked-name-input.tsx
new file mode 100644
index 0000000..c0b615e
--- /dev/null
+++ b/src/shared/ui/masked-input/masked-name-input.tsx
@@ -0,0 +1,66 @@
+import { ChangeEvent, useState } from 'react';
+import { getMaskedName } from '@/shared/lib/masking-utils';
+
+export interface MaskedNameInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ onFocus?: (e: React.FocusEvent) => void;
+ className?: string;
+}
+
+export const MaskedNameInput = ({
+ value,
+ onChange,
+ placeholder = '홍*동',
+ onFocus,
+ className = ''
+}: MaskedNameInputProps) => {
+ const [isComposing, setIsComposing] = useState(false);
+
+ return (
+ {
+ // 스페이스바 입력 차단
+ if (e.key === ' ') {
+ e.preventDefault();
+ }
+ // 백스페이스 처리 (composition 중이 아닐 때만)
+ else if (e.key === 'Backspace' && !isComposing) {
+ e.preventDefault();
+ onChange(value.slice(0, -1));
+ }
+ // 영문자 입력 처리 (composition 중이 아닐 때만)
+ else if (!isComposing && e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
+ if (/^[a-zA-Z]$/.test(e.key)) {
+ e.preventDefault();
+ onChange(value + e.key);
+ }
+ // 한글이 아닌 다른 문자는 차단
+ else if (!/^[ㄱ-ㅎㅏ-ㅣ가-힣]$/.test(e.key)) {
+ e.preventDefault();
+ }
+ }
+ }}
+ onCompositionStart={() => setIsComposing(true)}
+ onCompositionEnd={(e: any) => {
+ setIsComposing(false);
+ // 스페이스 제거 후 저장
+ const valueWithoutSpace = e.target.value.replace(/\s/g, '');
+ onChange(valueWithoutSpace);
+ }}
+ onChange={(e: ChangeEvent) => {
+ if (isComposing) {
+ // composition 중에는 스페이스 제거
+ const valueWithoutSpace = e.target.value.replace(/\s/g, '');
+ onChange(valueWithoutSpace);
+ }
+ }}
+ onFocus={onFocus}
+ />
+ );
+};
diff --git a/src/shared/ui/masked-input/masked-phone-input.tsx b/src/shared/ui/masked-input/masked-phone-input.tsx
new file mode 100644
index 0000000..0ab2203
--- /dev/null
+++ b/src/shared/ui/masked-input/masked-phone-input.tsx
@@ -0,0 +1,59 @@
+import { ChangeEvent } from 'react';
+import { getMaskedPhoneNumber } from '@/shared/lib/masking-utils';
+
+export interface MaskedPhoneInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ onFocus?: (e: React.FocusEvent) => void;
+ className?: string;
+ maxLength?: number;
+ isValid?: (value: string) => boolean;
+}
+
+export const MaskedPhoneInput = ({
+ value,
+ onChange,
+ placeholder = '0101234****',
+ onFocus,
+ className = '',
+ maxLength = 11,
+ isValid
+}: MaskedPhoneInputProps) => {
+ const validationClass = isValid && value ? (isValid(value) ? '' : 'error') : '';
+
+ return (
+ {
+ // 백스페이스 처리
+ if (e.key === 'Backspace') {
+ e.preventDefault();
+ onChange(value.slice(0, -1));
+ }
+ // 숫자 입력 처리
+ else if (/^[0-9]$/.test(e.key)) {
+ e.preventDefault();
+ if (value.length < maxLength) {
+ onChange(value + e.key);
+ }
+ }
+ }}
+ onChange={(e: ChangeEvent) => {
+ // Android 등에서 한글 키보드로 입력 시 대비
+ const input = e.target.value;
+ const digitsOnly = input.replace(/\*/g, '').replace(/[^0-9]/g, '');
+ if (digitsOnly.length <= maxLength) {
+ onChange(digitsOnly);
+ }
+ }}
+ inputMode="numeric"
+ pattern="[0-9]*"
+ maxLength={15}
+ onFocus={onFocus}
+ />
+ );
+};