Files
nice-app-web/src/pages/account/user/add-account-page.tsx
Jay Sheen 9711b50b5f 취소 비밀번호 변경 기능 추가 및 사용자 계정 관리 개선
🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:24:36 +09:00

557 lines
19 KiB
TypeScript

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';
export const UserAddAccountPage = () => {
const { navigate } = useNavigate();
const location = useLocation();
const { mid } = location.state || {};
const { mutateAsync: userCreate, isPending } = useUserCreateMutation({
onSuccess: () => {
snackBar('사용자가 성공적으로 추가되었습니다.');
},
onError: (error) => {
snackBar(error?.response?.data?.message || '사용자 추가에 실패했습니다.');
}
});
// 폼 상태 관리
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: '동일한 ID가 이미 존재합니다.' }));
} else {
setErrors(prev => ({ ...prev, usrid: '' }));
}
setShouldCheckUsrid(false);
}
}, [userExistsData, shouldCheckUsrid]);
// 이메일/전화번호 관리 함수들 (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 validatePassword = (password: string) => {
if (!password.trim()) {
return '비밀번호를 입력해 주세요';
} else if (password.length < 8) {
return '8자리 이상 입력해 주세요';
}
return '';
};
// 폼 입력 핸들러
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// 사용자 ID의 경우 디바운스 적용하여 자동 검증
if (field === 'usrid') {
// 기존 타이머 클리어
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
// 에러 초기화
setErrors(prev => ({ ...prev, usrid: '' }));
// 값이 있으면 500ms 후 검증 실행
if (value.trim().length > 0) {
setIsCheckingUsrid(true);
debounceTimeout.current = setTimeout(() => {
setShouldCheckUsrid(true);
}, 500);
} else {
setIsCheckingUsrid(false);
}
} else {
// 다른 필드는 에러만 초기화
if (errors[field as keyof typeof errors]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
}
};
// 비밀번호 blur 핸들러
const handlePasswordBlur = () => {
const passwordError = validatePassword(formData.password);
if (passwordError) {
setErrors(prev => ({ ...prev, password: passwordError }));
}
};
// 컴포넌트 언마운트 시 타이머 정리
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. 비밀번호가 8자리 이상
if (!formData.password.trim() || formData.password.length < 8) 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 = 'ID를 입력해 주세요';
isValid = false;
}
// 비밀번호 검증
if (!formData.password.trim()) {
newErrors.password = '비밀번호를 입력해 주세요';
isValid = false;
} else if (formData.password.length < 8) {
newErrors.password = '8자리 이상 입력해 주세요';
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('사용자가 성공적으로 추가되었습니다.');
navigate(PATHS.account.user.manage);
} else if (response.error) {
// 에러 처리
if (response.error.errKey === 'USER_DUPLICATE') {
setErrors(prev => ({ ...prev, usrid: '동일한 ID가 이미 존재합니다.' }));
} else {
// 기타 에러 처리
console.error('User creation failed:', response.error.message);
}
}
} catch (error) {
console.error('User creation error:', error);
}
};
useSetHeaderTitle('사용자 추가');
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 add">
<div className="user-add">
<div className="ua-row">
<div className="ua-label">ID <span className="red">*</span></div>
<div style={{ position: 'relative' }}>
<input
className={`wid-100 ${errors.usrid ? 'error' : ''}`}
type="text"
placeholder="ID를 입력해 주세요"
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'
}}>
...
</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' }}> ID입니다.</div>
)}
<div className="ua-row">
<div className="ua-label"> <span className="red">*</span></div>
<input
className={`wid-100 ${errors.password ? 'error' : ''}`}
type="password"
placeholder="8자리 이상 입력해 주세요"
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"> </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"> </div>
<p className="ua-note"> .</p>
</div>
<div className="ua-group">
<div className="ua-group-header">
<div className="ua-group-title"> </div>
<button
className="ic20 plus"
type="button"
aria-label="이메일 추가"
onClick={handleAddEmail}
disabled={!isEmailAddButtonEnabled()}
></button>
</div>
{newEmails.map((email, index) => (
<div className="ua-input-row" key={index}>
<input
className="wid-100"
type="text"
placeholder="example@domain.com"
value={email}
onChange={(e) => handleNewEmailChange(index, e.target.value)}
readOnly={readOnlyEmails.has(index) || index !== editableEmailIndex}
/>
<button
className="icon-btn minus"
type="button"
aria-label="삭제"
onClick={() => handleRemoveNewEmail(index)}
disabled={!isDeleteButtonEnabled()}
></button>
</div>
))}
</div>
<div className="ua-group">
<div className="ua-group-header">
<div className="ua-group-title"> </div>
<button
className="ic20 plus"
type="button"
aria-label="휴대폰 추가"
onClick={handleAddPhone}
disabled={!isPhoneAddButtonEnabled()}
></button>
</div>
{newPhones.map((phone, index) => (
<div className="ua-input-row" key={index}>
<input
className="wid-100"
type="tel"
placeholder="01012345678"
value={phone}
onChange={(e) => handleNewPhoneChange(index, e.target.value)}
readOnly={readOnlyPhones.has(index) || index !== editablePhoneIndex}
/>
<button
className="icon-btn minus"
type="button"
aria-label="삭제"
onClick={() => handleRemoveNewPhone(index)}
disabled={!isDeleteButtonEnabled()}
></button>
</div>
))}
</div>
</div>
<div className="apply-row bottom-padding">
<button
className="btn-50 btn-blue flex-1"
type="button"
onClick={handleSave}
disabled={!isSaveButtonEnabled() || isPending}
>
{isPending ? '저장 중...' : '저장'}
</button>
</div>
</div>
</div>
</div>
</main>
</>
);
};