557 lines
19 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}; |