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, useState, useTransition } from 'react';
import { Button } from '@/components/ui/button';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Modal } from '@/components/ui/modal';
import { useFeedback } from '@/app/_hooks/use-feedback';
import {
ADMIN_LOGIN_ID_HELP_TEXT,
ADMIN_PASSWORD_HELP_TEXT,
} from '@/lib/domain/admin-member-form';
import {
INITIAL_ADMIN_MEMBER_FORM_STATE,
checkAdminLoginId,
createAdminMemberAction,
type LoginIdCheckResult,
} from '../_actions';
import {
AdminMemberFormFields,
FieldError,
} from './admin-member-form-fields';
interface AdminMemberCreateModalProps {
onClose: () => void;
}
const FORM_ID = 'admin-member-create-form';
/**
* 관리자 등록 팝업(시안 ADM_ADM_102_p).
*
* **ID는 중복 확인을 통과해야 저장할 수 있다**(시안 ①). 확인은 등록 폼과 별개의 서버 호출인데,
* 폼 안에 폼을 중첩할 수 없어 `useActionState` 대신 값을 직접 넘기는 Server Action
* (`checkAdminLoginId`)을 `useTransition`으로 호출한다. 확인 후 사용자가 ID를 고칠 수 있으므로
* "확인에 성공한 ID"와 "지금 입력창의 값"이 같을 때만 통과로 본다 — 확인만 받아 두고 다른 ID로
* 바꿔 저장하는 경로를 막기 위해서다. 그래도 최종 방어선은 Server Action의 재확인이다.
*
* 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다 — 네이티브 HTML의 `form`
* 속성은 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 form을
* 제출한다.
*
* 비밀번호는 형식만 검증하고 저장되지 않는다 — 백엔드에 등록 API가 없어 지금은 mock 저장소가
* 받는데, 평문 비밀번호를 메모리에 들고 있지 않기로 했기 때문이다(Repository·mock 주석 참조).
*/
export function AdminMemberCreateModal({
onClose,
}: AdminMemberCreateModalProps) {
const { showToast } = useFeedback();
const [state, formAction, isPending] = useActionState(
createAdminMemberAction,
INITIAL_ADMIN_MEMBER_FORM_STATE
);
const [loginId, setLoginId] = useState('');
const [checkResult, setCheckResult] = useState<LoginIdCheckResult>({
status: 'idle',
});
const [isChecking, startChecking] = useTransition();
useEffect(() => {
if (state.status === 'success') {
showToast({ variant: 'success', message: '관리자를 등록했습니다.' });
onClose();
}
}, [state, showToast, onClose]);
const isLoginIdVerified =
checkResult.status === 'available' &&
checkResult.loginId === loginId.trim();
function handleCheckLoginId() {
startChecking(async () => {
setCheckResult(await checkAdminLoginId(loginId));
});
}
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
return (
<Modal
title="관리자 등록"
onClose={onClose}
footer={
<>
<Button type="button" variant="ghost" onClick={onClose}>
취소
</Button>
<Button
type="submit"
form={FORM_ID}
variant="primary"
disabled={isPending || !isLoginIdVerified}
title={isLoginIdVerified ? undefined : 'ID 중복 확인이 필요합니다.'}
>
{isPending ? '저장 중...' : '저장'}
</Button>
</>
}
>
<form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
<p className="text-right text-body-sm text-danger">
* 는 필수 항목입니다.
</p>
<Field label="이름 *">
<Input type="text" name="name" placeholder="이름을 입력하세요." />
</Field>
<FieldError message={errors.name} />
<Field label="ID * (중복확인 필수)">
<div className="flex items-center gap-2">
<Input
type="text"
name="loginId"
value={loginId}
onChange={(event) => setLoginId(event.target.value)}
placeholder="관리자 ID를 입력하세요."
autoComplete="off"
/>
<Button
type="button"
variant="secondary"
onClick={handleCheckLoginId}
disabled={isChecking || loginId.trim().length === 0}
className="shrink-0"
>
{isChecking ? '확인 중...' : '중복 확인'}
</Button>
</div>
</Field>
<p className="text-body-sm text-foreground-muted">
{ADMIN_LOGIN_ID_HELP_TEXT}
</p>
{checkResult.status === 'unavailable' && (
<FieldError message={checkResult.message} />
)}
{isLoginIdVerified && (
<p className="text-body-sm text-success">사용할 수 있는 ID입니다.</p>
)}
<FieldError message={errors.loginId} />
<Field label="비밀번호 *">
<Input
type="password"
name="password"
placeholder="비밀번호를 입력하세요."
autoComplete="new-password"
/>
</Field>
<p className="text-body-sm text-foreground-muted">
{ADMIN_PASSWORD_HELP_TEXT}
</p>
<FieldError message={errors.password} />
<AdminMemberFormFields errors={errors} />
{state.status === 'error' && state.message && (
<p className="text-body-sm text-danger">{state.message}</p>
)}
</form>
</Modal>
);
}