Files
nice-app-web/src/pages/account/user/add-account-page.tsx
2025-11-20 18:51:50 +09:00

648 lines
24 KiB
TypeScript

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<string, string> = {
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<NodeJS.Timeout | null>(null);
// 이메일/전화번호 상태 관리 (기본 1개씩 빈 공란 배치)
const [newEmails, setNewEmails] = useState<string[]>(['']);
const [newPhones, setNewPhones] = useState<string[]>(['']);
const [editableEmailIndex, setEditableEmailIndex] = useState<number>(0);
const [editablePhoneIndex, setEditablePhoneIndex] = useState<number>(0);
const [readOnlyEmails, setReadOnlyEmails] = useState<Set<number>>(new Set());
const [readOnlyPhones, setReadOnlyPhones] = useState<Set<number>>(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<number>();
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<number>();
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<string, string> = {
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<string, unknown>;
const validationErrors = details?.validationErrors as Record<string, string> | 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 (
<>
<main>
<div className="tab-content">
<div className="tab-pane sub active">
<div className="ing-list pb-86">
<div className="user-add">
<div className="ua-row">
<div className="ua-label">{t('account.userId')} <span className="red">*</span></div>
<div style={{ position: 'relative' }}>
<input
className={`wid-100 ${errors.usrid ? 'error' : ''}`}
type="text"
placeholder={t('account.pleaseEnterId')}
value={formData.usrid}
onChange={(e) => handleInputChange('usrid', e.target.value)}
/>
{isCheckingUsrid && (
<div style={{
position: 'absolute',
right: '10px',
top: '50%',
transform: 'translateY(-50%)',
fontSize: '12px',
color: '#666'
}}>
{t('account.checking')}
</div>
)}
</div>
</div>
{errors.usrid && <div className="ua-help error pt-10">{errors.usrid}</div>}
{!errors.usrid && formData.usrid && userExistsData && !userExistsData.exists && (
<div className="ua-help" style={{ color: '#78D197' }}>{t('account.availableId')}</div>
)}
<div className="ua-row">
<div className="ua-label">{t('account.password')} <span className="red">*</span></div>
<input
className={`wid-100 ${errors.password ? 'error' : ''}`}
type="password"
placeholder={t('account.pleaseEnter8OrMoreCharacters')}
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
onBlur={handlePasswordBlur}
/>
</div>
{errors.password && <div className="ua-help error pt-10">{errors.password}</div>}
<div className="ua-row">
<div className="ua-label">{t('account.loginRange')}</div>
<select
className="wid-100"
value={formData.loginRange}
onChange={(e) => handleInputChange('loginRange', e.target.value)}
>
<option value="MID">MID</option>
<option value="GID">MID + GID</option>
</select>
</div>
</div>
<div className="info-divider"></div>
<div className="user-add info">
<div className="ua-desc">
<div className="ua-title">{t('account.identityVerificationInfo')}</div>
<p className="ua-note">{t('account.identityVerificationNotice')}</p>
</div>
<div className="ua-group">
<div className="ua-group-header">
<div className="ua-group-title">{t('account.emailAddress')}</div>
<button
className="ic20 plus"
type="button"
aria-label={t('account.addEmail')}
onClick={handleAddEmail}
disabled={!isEmailAddButtonEnabled()}
></button>
</div>
{newEmails.map((email, index) => {
const isReadOnly = readOnlyEmails.has(index) || index !== editableEmailIndex;
const displayValue = isReadOnly && email ? maskEmail(email) : email;
return (
<div className="ua-input-row" key={index}>
<input
className="wid-100"
type="text"
placeholder="example@domain.com"
value={displayValue}
onChange={(e) => handleNewEmailChange(index, e.target.value)}
onFocus={(e) => {
handleInputFocus(e);
setEditableEmailIndex(index);
}}
onBlur={() => {
if (email && isValidEmail(email)) {
setEditableEmailIndex(-1);
}
}}
readOnly={isReadOnly}
/>
<button
className="icon-btn minus"
type="button"
aria-label={t('common.delete')}
onClick={() => handleRemoveNewEmail(index)}
disabled={!isDeleteButtonEnabled()}
></button>
</div>
);
})}
</div>
<div className="ua-group">
<div className="ua-group-header">
<div className="ua-group-title">{t('account.phoneNumber')}</div>
<button
className="ic20 plus"
type="button"
aria-label={t('account.addPhone')}
onClick={handleAddPhone}
disabled={!isPhoneAddButtonEnabled()}
></button>
</div>
{newPhones.map((phone, index) => {
const isReadOnly = readOnlyPhones.has(index) || index !== editablePhoneIndex;
const displayValue = isReadOnly && phone ? maskPhoneNumber(phone) : phone;
return (
<div className="ua-input-row" key={index}
style={keyboardAwarePadding}
>
<input
className="wid-100"
type="tel"
placeholder="01012345678"
value={displayValue}
onChange={(e) => handleNewPhoneChange(index, e.target.value)}
onFocus={(e) => {
handleInputFocus(e);
setEditablePhoneIndex(index);
}}
onBlur={() => {
if (phone && isValidPhone(phone)) {
setEditablePhoneIndex(-1);
}
}}
readOnly={isReadOnly}
/>
<button
className="icon-btn minus"
type="button"
aria-label={t('common.delete')}
onClick={() => handleRemoveNewPhone(index)}
disabled={!isDeleteButtonEnabled()}
></button>
</div>
);
})}
</div>
</div>
<div className="apply-row">
<button
className="btn-50 btn-blue flex-1"
type="button"
onClick={handleSave}
disabled={!isSaveButtonEnabled() || isPending}
>
{isPending ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
</div>
</div>
</main>
</>
);
};