import { useTranslation } from 'react-i18next'; import { PATHS } from '@/shared/constants/paths'; import { useNavigate } from '@/shared/lib/hooks/use-navigate'; import { HeaderType } from '@/entities/common/model/types'; import { useSetHeaderTitle, useSetHeaderType, useSetFooterMode, useSetOnBack } from '@/widgets/sub-layout/use-sub-layout'; import { useState, useEffect, useRef } from 'react'; import { VerificationItem } from '@/entities/account/model/types'; import { useUserCreateMutation } from '@/entities/user/api/use-user-create-mutation'; import { useUserExistsUseridQuery } from '@/entities/user/api/use-user-exists-userid-query'; import { useLocation } from 'react-router'; import { snackBar } from '@/shared/lib/toast'; import { maskEmail, maskPhoneNumber } from '@/shared/lib/masking'; import { validatePassword } from '@/shared/lib/password-validation'; import { useKeyboardAware } from '@/shared/lib/hooks'; export const UserAddAccountPage = () => { const { t } = useTranslation(); const { navigate } = useNavigate(); const location = useLocation(); const { mid } = location.state || {}; const { handleInputFocus, keyboardAwarePadding } = useKeyboardAware(); const { mutateAsync: userCreate, isPending } = useUserCreateMutation({ onError: (error) => { type ErrorDetail = { errKey?: string; message?: string; }; type ErrorResponse = { error?: ErrorDetail; errorCode?: string; message?: string; }; const responseData = error?.response?.data as ErrorResponse | undefined; const errorCode = responseData?.error?.errKey || responseData?.errorCode; // 에러 코드별 메시지 매핑 const errorMessageMap: Record = { MERCHANT_INFO_MATCH_PASSWORD: t('account.errors.merchantInfoMatchPassword'), PASSWORD_LENGHT: t('account.errors.passwordLength'), DISALLOWED_CHARACTERS_INCLUDED: t('account.errors.disallowedCharactersIncluded'), DISALLOWED_WHITE_SPACE: t('account.errors.disallowedWhiteSpace'), NOT_ENOUGH_COMPLEXITY: t('account.errors.notEnoughComplexity'), REPEATED_CHARACTER_SEQUENCE: t('account.errors.repeatedCharacterSequence'), COMMON_PASSWORD_DETECTED: t('account.errors.commonPasswordDetected') }; const errorMessage = errorCode && errorMessageMap[errorCode] ? errorMessageMap[errorCode] : (error?.response?.data?.message || t('account.userAddFailed')); snackBar(errorMessage); } }); // 폼 상태 관리 const [formData, setFormData] = useState({ usrid: '', password: '', loginRange: 'MID' }); // 에러 상태 관리 const [errors, setErrors] = useState({ usrid: '', password: '' }); // 사용자 ID 검증을 위한 상태 const [shouldCheckUsrid, setShouldCheckUsrid] = useState(false); const [isCheckingUsrid, setIsCheckingUsrid] = useState(false); const debounceTimeout = useRef(null); // 이메일/전화번호 상태 관리 (기본 1개씩 빈 공란 배치) const [newEmails, setNewEmails] = useState(['']); const [newPhones, setNewPhones] = useState(['']); const [editableEmailIndex, setEditableEmailIndex] = useState(0); const [editablePhoneIndex, setEditablePhoneIndex] = useState(0); const [readOnlyEmails, setReadOnlyEmails] = useState>(new Set()); const [readOnlyPhones, setReadOnlyPhones] = useState>(new Set()); // 사용자 ID 존재 여부 확인 쿼리 const { data: userExistsData, isLoading: isUserExistsLoading } = useUserExistsUseridQuery( shouldCheckUsrid && formData.usrid.trim().length > 0 ? formData.usrid : '', { enabled: shouldCheckUsrid && formData.usrid.trim().length > 0, } ); // Handle user exists query result useEffect(() => { if (userExistsData && shouldCheckUsrid) { setIsCheckingUsrid(false); if (userExistsData.exists) { setErrors(prev => ({ ...prev, usrid: t('account.duplicateIdExists') })); } else { setErrors(prev => ({ ...prev, usrid: '' })); } setShouldCheckUsrid(false); } }, [userExistsData, shouldCheckUsrid, t]); // 이메일/전화번호 관리 함수들 (user-login-auth-info-wrap 방식) const handleAddEmail = () => { // 현재 편집 중인 항목을 읽기전용으로 고정 if (editableEmailIndex >= 0) { setReadOnlyEmails(prev => new Set([...prev, editableEmailIndex])); } // 새로운 편집 가능한 항목 추가 setEditableEmailIndex(newEmails.length); setNewEmails([...newEmails, '']); }; const handleAddPhone = () => { // 현재 편집 중인 항목을 읽기전용으로 고정 if (editablePhoneIndex >= 0) { setReadOnlyPhones(prev => new Set([...prev, editablePhoneIndex])); } // 새로운 편집 가능한 항목 추가 setEditablePhoneIndex(newPhones.length); setNewPhones([...newPhones, '']); }; const handleRemoveNewEmail = (index: number) => { const updatedEmails = newEmails.filter((_, i) => i !== index); setNewEmails(updatedEmails); // 읽기전용 인덱스들을 업데이트 const updatedReadOnlyEmails = new Set(); readOnlyEmails.forEach(readOnlyIndex => { if (readOnlyIndex < index) { updatedReadOnlyEmails.add(readOnlyIndex); } else if (readOnlyIndex > index) { updatedReadOnlyEmails.add(readOnlyIndex - 1); } }); setReadOnlyEmails(updatedReadOnlyEmails); // 편집 가능한 인덱스 조정 if (index === editableEmailIndex) { setEditableEmailIndex(-1); } else if (index < editableEmailIndex) { setEditableEmailIndex(editableEmailIndex - 1); } }; const handleRemoveNewPhone = (index: number) => { const updatedPhones = newPhones.filter((_, i) => i !== index); setNewPhones(updatedPhones); // 읽기전용 인덱스들을 업데이트 const updatedReadOnlyPhones = new Set(); readOnlyPhones.forEach(readOnlyIndex => { if (readOnlyIndex < index) { updatedReadOnlyPhones.add(readOnlyIndex); } else if (readOnlyIndex > index) { updatedReadOnlyPhones.add(readOnlyIndex - 1); } }); setReadOnlyPhones(updatedReadOnlyPhones); // 편집 가능한 인덱스 조정 if (index === editablePhoneIndex) { setEditablePhoneIndex(-1); } else if (index < editablePhoneIndex) { setEditablePhoneIndex(editablePhoneIndex - 1); } }; const handleNewEmailChange = (index: number, value: string) => { const updated = [...newEmails]; updated[index] = value; setNewEmails(updated); }; const handleNewPhoneChange = (index: number, value: string) => { const updated = [...newPhones]; updated[index] = value; setNewPhones(updated); }; // 폼 입력 핸들러 const handleInputChange = (field: string, value: string) => { // 사용자 ID의 경우 영문과 숫자만 허용 if (field === 'usrid') { // 영문과 숫자가 아닌 문자 제거 const filteredValue = value.replace(/[^a-zA-Z0-9]/g, ''); setFormData(prev => ({ ...prev, [field]: filteredValue })); // 기존 타이머 클리어 if (debounceTimeout.current) { clearTimeout(debounceTimeout.current); } // 에러 초기화 setErrors(prev => ({ ...prev, usrid: '' })); // 값이 있으면 500ms 후 검증 실행 if (filteredValue.trim().length > 0) { setIsCheckingUsrid(true); debounceTimeout.current = setTimeout(() => { setShouldCheckUsrid(true); }, 500); } else { setIsCheckingUsrid(false); } } else { setFormData(prev => ({ ...prev, [field]: value })); // 다른 필드는 에러만 초기화 if (errors[field as keyof typeof errors]) { setErrors(prev => ({ ...prev, [field]: '' })); } } }; // 비밀번호 blur 핸들러 const handlePasswordBlur = () => { const result = validatePassword(formData.password, t); setErrors(prev => ({ ...prev, password: result.isValid ? '' : result.errorMessage })); }; // 컴포넌트 언마운트 시 타이머 정리 useEffect(() => { return () => { if (debounceTimeout.current) { clearTimeout(debounceTimeout.current); } }; }, []); // 검증 함수들 const isValidEmail = (email: string) => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); }; const isValidPhone = (phone: string) => { const phoneRegex = /^010\d{8}$/; return phoneRegex.test(phone); }; // 이메일 추가 버튼 활성화 조건 const isEmailAddButtonEnabled = () => { if (newEmails.length === 0) return true; const lastEmailIndex = newEmails.length - 1; const lastEmail = newEmails[lastEmailIndex]; return lastEmailIndex >= editableEmailIndex && lastEmail && lastEmail.trim() && isValidEmail(lastEmail) && !hasDuplicateEmail(); }; // 전화번호 추가 버튼 활성화 조건 const isPhoneAddButtonEnabled = () => { if (newPhones.length === 0) return true; const lastPhoneIndex = newPhones.length - 1; const lastPhone = newPhones[lastPhoneIndex]; return lastPhoneIndex >= editablePhoneIndex && lastPhone && lastPhone.trim() && isValidPhone(lastPhone) && !hasDuplicatePhone(); }; // 중복 검증 const hasDuplicateEmail = () => { const validEmails = newEmails.filter(e => e.trim()); const uniqueEmails = new Set(validEmails); return validEmails.length !== uniqueEmails.size; }; const hasDuplicatePhone = () => { const validPhones = newPhones.filter(p => p.trim()); const uniquePhones = new Set(validPhones); return validPhones.length !== uniquePhones.size; }; // 삭제 버튼 활성화 조건 const isDeleteButtonEnabled = () => { const totalCount = newEmails.length + newPhones.length; return totalCount > 1; }; // 저장 버튼 활성화 조건 체크 const isSaveButtonEnabled = () => { // 1. 사용자 ID가 입력되고 중복되지 않아야 함 if (!formData.usrid.trim()) return false; if (errors.usrid) return false; if (isCheckingUsrid || isUserExistsLoading) return false; // 2. 비밀번호 검증 if (!formData.password.trim()) return false; if (!validatePassword(formData.password, t).isValid) return false; // 3. 입력된 모든 이메일과 휴대폰 번호가 유효한 형식이어야 함 const nonEmptyEmails = newEmails.filter(email => email.trim()); const nonEmptyPhones = newPhones.filter(phone => phone.trim()); // 입력된 이메일이 있으면 모두 유효한 형식이어야 함 if (nonEmptyEmails.length > 0) { const invalidEmails = nonEmptyEmails.filter(email => !isValidEmail(email)); if (invalidEmails.length > 0) return false; } // 입력된 휴대폰 번호가 있으면 모두 유효한 형식이어야 함 if (nonEmptyPhones.length > 0) { const invalidPhones = nonEmptyPhones.filter(phone => !isValidPhone(phone)); if (invalidPhones.length > 0) return false; } // 4. 유효한 이메일 또는 휴대폰 번호가 최소 1개 이상 const validEmails = nonEmptyEmails.filter(email => isValidEmail(email)); const validPhones = nonEmptyPhones.filter(phone => isValidPhone(phone)); if (validEmails.length === 0 && validPhones.length === 0) return false; // 5. 중복이 없어야 함 if (hasDuplicateEmail() || hasDuplicatePhone()) return false; return true; }; // 폼 검증 const validateForm = () => { const newErrors = { usrid: '', password: '' }; let isValid = true; // 사용자 ID 검증 if (!formData.usrid.trim()) { newErrors.usrid = t('account.pleaseEnterId'); isValid = false; } // 비밀번호 검증 const passwordResult = validatePassword(formData.password, t); if (!passwordResult.isValid) { newErrors.password = passwordResult.errorMessage; isValid = false; } // 이메일/전화번호 중 하나는 필수 const validEmails = newEmails.filter(email => email.trim() && isValidEmail(email)); const validPhones = newPhones.filter(phone => phone.trim() && isValidPhone(phone)); if (validEmails.length === 0 && validPhones.length === 0) { // 최소 하나의 유효한 연락처가 필요 isValid = false; } // 중복이 있으면 비활성화 if (hasDuplicateEmail() || hasDuplicatePhone()) { isValid = false; } setErrors(newErrors); return isValid; }; // 저장 핸들러 const handleSave = async () => { if (!validateForm()) { return; } try { // verifications 배열 생성 const verifications: VerificationItem[] = []; newEmails.forEach(email => { if (email.trim() && isValidEmail(email)) { verifications.push({ type: 'EMAIL', contact: email }); } }); newPhones.forEach(phone => { if (phone.trim() && isValidPhone(phone)) { verifications.push({ type: 'PHONE', contact: phone }); } }); const request = { mid: mid, usrid: formData.usrid, password: formData.password, loginRange: formData.loginRange, verifications: verifications }; const response = await userCreate(request); if (response?.status) { // 성공 시 사용자 관리 페이지로 이동 snackBar(t('account.userAddedSuccessfully')); navigate(PATHS.account.user.manage); } else if (response?.error) { // 에러 처리 const error = response.error; // 에러 코드별 메시지 매핑 const errorMessageMap: Record = { MERCHANT_INFO_MATCH_PASSWORD: t('account.errors.merchantInfoMatchPassword'), PASSWORD_LENGHT: t('account.errors.passwordLength'), DISALLOWED_CHARACTERS_INCLUDED: t('account.errors.disallowedCharactersIncluded'), DISALLOWED_WHITE_SPACE: t('account.errors.disallowedWhiteSpace'), NOT_ENOUGH_COMPLEXITY: t('account.errors.notEnoughComplexity'), REPEATED_CHARACTER_SEQUENCE: t('account.errors.repeatedCharacterSequence'), COMMON_PASSWORD_DETECTED: t('account.errors.commonPasswordDetected') }; if (error.errKey === 'USER_DUPLICATE') { setErrors(prev => ({ ...prev, usrid: t('account.duplicateIdExists') })); } else if (error.errKey && errorMessageMap[error.errKey]) { // 비밀번호 관련 에러는 password 필드에 표시 const errorMsg = errorMessageMap[error.errKey]; if (errorMsg) { setErrors(prev => ({ ...prev, password: errorMsg })); } } else if (error.errKey === 'VALIDATION_ERROR') { // 검증 에러 처리 const details = error.details as Record; const validationErrors = details?.validationErrors as Record | undefined; if (validationErrors) { // 필드별 에러 메시지 설정 const newErrors = { ...errors }; if (validationErrors.passwordField) { newErrors.password = validationErrors.passwordField; } if (validationErrors.usridField) { newErrors.usrid = validationErrors.usridField; } setErrors(newErrors); } snackBar(error.message || t('account.userAddFailed')); } else { // 기타 에러 처리 snackBar(error.message || t('account.userAddFailed')); } } } catch (error) { console.error('User creation error:', error); // catch된 에러는 onError에서 처리됨 } }; useSetHeaderTitle(t('account.addUser')); useSetHeaderType(HeaderType.LeftArrow); useSetFooterMode(false); useSetOnBack(() => { navigate(PATHS.account.user.manage); }); return ( <>
{t('account.userId')} *
handleInputChange('usrid', e.target.value)} /> {isCheckingUsrid && (
{t('account.checking')}
)}
{errors.usrid &&
{errors.usrid}
} {!errors.usrid && formData.usrid && userExistsData && !userExistsData.exists && (
{t('account.availableId')}
)}
{t('account.password')} *
handleInputChange('password', e.target.value)} onBlur={handlePasswordBlur} />
{errors.password &&
{errors.password}
}
{t('account.loginRange')}
{t('account.identityVerificationInfo')}

{t('account.identityVerificationNotice')}

{t('account.emailAddress')}
{newEmails.map((email, index) => { const isReadOnly = readOnlyEmails.has(index) || index !== editableEmailIndex; const displayValue = isReadOnly && email ? maskEmail(email) : email; return (
handleNewEmailChange(index, e.target.value)} onFocus={(e) => { handleInputFocus(e); setEditableEmailIndex(index); }} onBlur={() => { if (email && isValidEmail(email)) { setEditableEmailIndex(-1); } }} readOnly={isReadOnly} />
); })}
{t('account.phoneNumber')}
{newPhones.map((phone, index) => { const isReadOnly = readOnlyPhones.has(index) || index !== editablePhoneIndex; const displayValue = isReadOnly && phone ? maskPhoneNumber(phone) : phone; return (
handleNewPhoneChange(index, e.target.value)} onFocus={(e) => { handleInputFocus(e); setEditablePhoneIndex(index); }} onBlur={() => { if (phone && isValidPhone(phone)) { setEditablePhoneIndex(-1); } }} readOnly={isReadOnly} />
); })}
); };