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 {
foxPasswordValidator,
foxValidationMessage,
foxValidators,
} 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_EMAIL_MESSAGES,
ADMIN_LOGIN_ID_HELP_TEXT,
ADMIN_LOGIN_ID_MESSAGES,
ADMIN_LOGIN_ID_VALIDATORS,
ADMIN_PASSWORD_HELP_TEXT,
ADMIN_PASSWORD_POLICY,
INITIAL_ADMIN_MEMBER_FORM_STATE,
adminEmailValidators,
} 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';
import { submitFormAction } from '@/app/_hooks/submit-form-action';
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);
// 이메일은 칸이 둘로 나뉘어 있어 검증 대상이 **합친 값**이다 — FoxInput 한 칸이 스스로
// 판정할 수 없어 여기서 돌리고, `touched`도 직접 든다(FoxInput의 blur 규칙과 같은 시점).
const [isEmailTouched, setIsEmailTouched] = 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 ?? {}) : {};
// 둘 중 하나라도 비면 **빈 이메일**로 본다 — 도메인만 고른 상태(`@naver.com`)를 값으로 보내면
// "형식이 올바르지 않습니다"가 뜨지만, 사용자가 한 일은 아무것도 입력하지 않은 것이다.
const email = emailLocal && emailDomain ? `${emailLocal}@${emailDomain}` : '';
const emailMessage =
errors.email ??
(isEmailTouched
? foxValidationMessage(
foxValidators.compose(adminEmailValidators(true))(email),
ADMIN_EMAIL_MESSAGES
)
: undefined);
// 중복 확인 결과는 서버 검증 오류보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다.
// 값이 나쁜 것(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 (
<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} onSubmit={(event) => submitFormAction(event, 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={Boolean(loginIdFailure) || Boolean(errors.loginId)}
validators={ADMIN_LOGIN_ID_VALIDATORS}
validationMessages={ADMIN_LOGIN_ID_MESSAGES}
/>
<FoxButton
type="secondary"
size="md"
fullWidth
label="중복 확인"
loading={isChecking}
disabled={loginId.trim().length === 0}
onAction={() =>
startChecking(async () => {
try {
setCheckResult(await checkAdminLoginId(loginId));
} catch {
// Server Action 호출 자체가 깨진 경우(네트워크 등). 조용히 끝나면 버튼만
// 원복되고 아무 말도 남지 않아, 사유를 알 수 없어도 실패는 알린다.
setCheckResult({
status: 'failed',
message: '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.',
});
}
})
}
/>
</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)}
// 규칙은 도메인이 한 벌로 갖는다 — 저장 직전 서버 검증이 같은 값을 본다.
validators={[foxPasswordValidator({ ...ADMIN_PASSWORD_POLICY, required: true })]}
validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
/>
<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}
onBlur={() => setIsEmailTouched(true)}
invalid={Boolean(emailMessage)}
/>
</span>
<span aria-hidden="true">@</span>
<span className={styles.emailPart}>
{isCustomDomain ? (
<FoxInput
size="md"
aria-label="이메일 도메인"
placeholder="직접입력"
value={emailDomain}
onChange={setEmailDomain}
onBlur={() => setIsEmailTouched(true)}
invalid={Boolean(emailMessage)}
/>
) : (
<FoxSelect
size="md"
options={[
...EMAIL_DOMAIN_OPTIONS.map((domain) => ({
value: domain,
label: domain,
})),
{ value: CUSTOM_EMAIL_DOMAIN, label: '직접입력' },
]}
value={emailDomain}
onValueChange={(next) => {
setIsEmailTouched(true);
if (next === CUSTOM_EMAIL_DOMAIN) {
setIsCustomDomain(true);
setEmailDomain('');
return;
}
setEmailDomain(next);
}}
error={Boolean(emailMessage)}
/>
)}
</span>
</div>
{emailMessage && (
<p className={styles.fieldError} role="alert">
{emailMessage}
</p>
)}
</div>
<input type="hidden" name="email" value={email} />
</>
}
/>
{state.status === 'error' && state.message && (
<p role="alert">{state.message}</p>
)}
</form>
</FoxModal>
);
}