File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'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 { 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,
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<HTMLFormElement>(null);
const [loginId, setLoginId] = useState('');
const [checkResult, setCheckResult] = useState<LoginIdCheckResult>({
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<string>(DEFAULT_ADMIN_ROLE_CODE);
const [menuCodes, setMenuCodes] = useState<string[]>([]);
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}` : '';
// 중복 확인 결과는 서버 검증 오류보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다.
const loginIdMessage =
checkResult.status === 'unavailable'
? checkResult.message
: isLoginIdVerified
? '사용할 수 있는 ID입니다.'
: (errors.loginId ?? ADMIN_LOGIN_ID_HELP_TEXT);
return (
<FoxModal
open
onClose={onClose}
size="sm"
title="관리자 등록"
actions={
<FoxButtonGroup size="md">
<FoxButton type="default" size="md" label="취소" onAction={onClose} />
<FoxButton
type="primary"
size="md"
label="저장"
loading={isPending}
disabled={!isLoginIdVerified}
onAction={() => formRef.current?.requestSubmit()}
/>
</FoxButtonGroup>
}
>
<form ref={formRef} action={formAction} className={styles.formList}>
<FoxInput
size="md"
name="name"
label="이름"
requirement="required"
placeholder="이름을 입력하세요."
message={errors.name}
invalid={Boolean(errors.name)}
/>
<div className={styles.fieldWithAction}>
<FoxInput
size="md"
name="loginId"
label="ID"
requirement="required"
placeholder="관리자 ID를 입력하세요."
autoComplete="off"
value={loginId}
onChange={setLoginId}
message={loginIdMessage}
messageIcon={<FoxChatCenteredDotsIcon />}
invalid={
checkResult.status === 'unavailable' || Boolean(errors.loginId)
}
/>
<FoxButton
type="secondary"
size="md"
fullWidth
label="중복 확인"
loading={isChecking}
disabled={loginId.trim().length === 0}
onAction={() =>
startChecking(async () => {
setCheckResult(await checkAdminLoginId(loginId));
})
}
/>
</div>
<FoxInput
size="md"
type="password"
name="password"
label="비밀번호"
requirement="required"
placeholder="비밀번호를 입력하세요."
autoComplete="new-password"
message={errors.password ?? ADMIN_PASSWORD_HELP_TEXT}
messageIcon={<FoxChatCenteredDotsIcon />}
invalid={Boolean(errors.password)}
/>
<AdminMemberFormFields
phoneNumber={phoneNumber}
onPhoneNumberChange={setPhoneNumber}
roleCode={roleCode}
onRoleCodeChange={setRoleCode}
menuCodes={menuCodes}
onMenuCodesChange={setMenuCodes}
errors={errors}
emailField={
<>
<div className={styles.field}>
<FoxFormLabel as="span" requirement="required">
이메일
</FoxFormLabel>
<div className={styles.emailRow}>
<span className={styles.emailPart}>
<FoxInput
size="md"
aria-label="이메일 아이디"
placeholder="1234-5678"
value={emailLocal}
onChange={setEmailLocal}
invalid={Boolean(errors.email)}
/>
</span>
<span aria-hidden="true">@</span>
<span className={styles.emailPart}>
{isCustomDomain ? (
<FoxInput
size="md"
aria-label="이메일 도메인"
placeholder="직접입력"
value={emailDomain}
onChange={setEmailDomain}
invalid={Boolean(errors.email)}
/>
) : (
<FoxSelect
size="md"
options={[
...EMAIL_DOMAIN_OPTIONS.map((domain) => ({
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)}
/>
)}
</span>
</div>
{errors.email && <p role="alert">{errors.email}</p>}
</div>
<input type="hidden" name="email" value={email} />
</>
}
/>
{state.status === 'error' && state.message && (
<p role="alert">{state.message}</p>
)}
</form>
</FoxModal>
);
}