'use client'; import { useActionState, useEffect, useRef, useState, useTransition, } from 'react'; import { FoxButton } from '@fox/core/components/fox-button'; import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; import { FoxFormLabel } from '@fox/core/components/fox-form-label'; import { FoxInput } from '@fox/core/components/fox-input'; import { FoxSelect } from '@fox/core/components/fox-select'; import { FoxModal } from '@fox/core/components/fox-modal'; import { foxPasswordValidator } from '@fox/core/validation'; import { FoxChatCenteredDotsIcon } from '@fox/core/icons'; import { useFeedback } from '@/app/_hooks/use-feedback'; import { DEFAULT_ADMIN_ROLE_CODE } from '@/lib/domain/admin-member'; import { ADMIN_LOGIN_ID_HELP_TEXT, ADMIN_PASSWORD_HELP_TEXT, ADMIN_PASSWORD_POLICY, INITIAL_ADMIN_MEMBER_FORM_STATE, } from '@/lib/domain/admin-member-form'; import { checkAdminLoginId, createAdminMemberAction, type LoginIdCheckResult, } from '../_actions'; import { AdminMemberFormFields } from './admin-member-form-fields'; import styles from './admin-member-modal.module.scss'; interface AdminMemberCreateModalProps { onClose: () => void; } /** 목록에 없는 주소를 위해 도메인 칸을 입력으로 바꾸는 값. 실제 도메인과 겹치지 않게 잡았다. */ const CUSTOM_EMAIL_DOMAIN = '__custom__'; /** 시안의 도메인 목록. 맨 아래 "직접입력"은 같은 칸을 입력으로 바꾼다(상자를 늘리지 않는다). */ const EMAIL_DOMAIN_OPTIONS = [ 'naver.com', 'gmail.com', 'daum.net', 'hanmail.net', 'nate.com', ]; /** * 관리자 등록 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_102_p (5227:2035) * * **ID는 중복 확인을 통과해야 저장할 수 있다**(시안 ①). 확인은 등록 폼과 별개의 서버 호출인데, * 폼 안에 폼을 중첩할 수 없어 `useActionState` 대신 값을 직접 넘기는 Server Action * (`checkAdminLoginId`)을 `useTransition`으로 호출한다. 확인 후 사용자가 ID를 고칠 수 있으므로 * "확인에 성공한 ID"와 "지금 입력창의 값"이 같을 때만 통과로 본다 — 확인만 받아 두고 다른 ID로 * 바꿔 저장하는 경로를 막기 위해서다. 그래도 최종 방어선은 Server Action의 재확인이다. * * 저장 버튼은 `FoxModal`의 actions 슬롯에 있어 폼 **바깥**에 그려진다. `FoxButton`에는 네이티브 * `form` 속성이 없으므로 폼을 ref로 잡아 `requestSubmit()`을 부른다 — `useActionState`의 폼 * 액션이 그대로 타는 정식 제출이라 검증·상태 흐름이 동일하다. * * @fox의 제어 위젯(전화번호·이메일·역할·메뉴)은 값이 FormData에 실리지 않아 이 컴포넌트가 값을 * 들고 hidden input으로 제출한다(`AdminMemberFormFields` 주석 참조). * * 비밀번호는 형식만 검증하고 백엔드가 SHA-256으로 암호화해 저장한다(`MngrAdminServiceImpl`). */ export function AdminMemberCreateModal({ onClose, }: AdminMemberCreateModalProps) { const { showToast } = useFeedback(); const [state, formAction, isPending] = useActionState( createAdminMemberAction, INITIAL_ADMIN_MEMBER_FORM_STATE ); const formRef = useRef(null); const [loginId, setLoginId] = useState(''); const [checkResult, setCheckResult] = useState({ status: 'idle', }); const [isChecking, startChecking] = useTransition(); const [phoneNumber, setPhoneNumber] = useState(''); const [emailLocal, setEmailLocal] = useState(''); const [emailDomain, setEmailDomain] = useState(EMAIL_DOMAIN_OPTIONS[0]); const [isCustomDomain, setIsCustomDomain] = useState(false); const [roleCode, setRoleCode] = useState(DEFAULT_ADMIN_ROLE_CODE); const [menuCodes, setMenuCodes] = useState([]); useEffect(() => { if (state.status === 'success') { showToast({ variant: 'success', message: '관리자를 등록했습니다.' }); onClose(); } }, [state, showToast, onClose]); const isLoginIdVerified = checkResult.status === 'available' && checkResult.loginId === loginId.trim(); const errors = state.status === 'error' ? (state.errors ?? {}) : {}; const email = emailLocal || emailDomain ? `${emailLocal}@${emailDomain}` : ''; // 중복 확인 결과는 서버 검증 오류보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다. // 값이 나쁜 것(unavailable)과 확인을 못 한 것(failed)은 사유가 다르므로 둘 다 문구로 낸다. const loginIdFailure = checkResult.status === 'unavailable' || checkResult.status === 'failed' ? checkResult.message : undefined; const loginIdMessage = loginIdFailure ?? (isLoginIdVerified ? '사용할 수 있는 ID입니다.' : (errors.loginId ?? ADMIN_LOGIN_ID_HELP_TEXT)); return ( formRef.current?.requestSubmit()} /> } >
} invalid={Boolean(loginIdFailure) || Boolean(errors.loginId)} /> startChecking(async () => { try { setCheckResult(await checkAdminLoginId(loginId)); } catch { // Server Action 호출 자체가 깨진 경우(네트워크 등). 조용히 끝나면 버튼만 // 원복되고 아무 말도 남지 않아, 사유를 알 수 없어도 실패는 알린다. setCheckResult({ status: 'failed', message: '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.', }); } }) } />
} invalid={Boolean(errors.password)} // 규칙은 도메인이 한 벌로 갖는다 — 저장 직전 서버 검증이 같은 값을 본다. validators={[foxPasswordValidator({ ...ADMIN_PASSWORD_POLICY, required: true })]} validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }} />
이메일
{isCustomDomain ? ( ) : ( ({ value: domain, label: domain, })), { value: CUSTOM_EMAIL_DOMAIN, label: '직접입력' }, ]} value={emailDomain} onValueChange={(next) => { if (next === CUSTOM_EMAIL_DOMAIN) { setIsCustomDomain(true); setEmailDomain(''); return; } setEmailDomain(next); }} error={Boolean(errors.email)} /> )}
{errors.email &&

{errors.email}

}
} /> {state.status === 'error' && state.message && (

{state.message}

)}
); }