임동욱 임동욱 08-11
feat: 관리자 회원 목록·등록·수정·삭제 화면 추가
사이드바 「관리자정보관리 > 관리자 회원」(/admins) 신설.
목록은 백엔드 GET /api/v1/mngr/admin/pagination으로 조회하고,
등록·수정·삭제는 백엔드 API가 없어 메모리 mock 오버레이로 처리한다
(lib/data/mock/admin-member-store.ts — API 추가 시 Repository 세 함수만 교체).

백엔드 계약 확인에 따른 결정:
- 응답에 휴대전화번호·이메일·생성일이 없어 해당 열은 `-`로 표기(열은 유지)
- totalCount가 현재 페이지 행 수라 신뢰할 수 없어, 전체를 받아 서버에서
  검색·정렬·페이징한다(총건수 확정 → 번호 역순·현재페이지 표기 성립)
- searchCondition=3(휴대전화번호)이 TB_ADM_USER에 없는 USER_TELNO를
  참조해 검색 대상에서 제외
- admRoleCd 코드 정의가 백엔드에 없어 잠정 매핑 + 미지의 코드는 원문 노출

엑셀다운로드는 버튼만 배치(추후 진행), 아이콘 부재로 관리 열은 텍스트 버튼.

Co-Authored-By: Claude Opus 5 
@c51cf6cdc5c3bf9c7cc438980ac3e9a5c607140e
app/(protected)/(basic)/_components/admin-sidebar.tsx
--- app/(protected)/(basic)/_components/admin-sidebar.tsx
+++ app/(protected)/(basic)/_components/admin-sidebar.tsx
@@ -10,10 +10,9 @@
 // 그룹 헤딩(라우트 없음) 아래 자식 링크를 두는 2단 구조다 — 「학생 회원」은 「회원정보관리」
 // 그룹의 자식이다(「학생회원관리」가 아니다).
 //
-// 시안에는 이 외에도 관리자정보관리·꾸미기아이템관리·게시판관리(고객센터)·포인트관리·
-// 콘텐츠관리·시스템관리 6개 그룹이 더 있지만, 전부 대응하는 화면/라우트가 아직 없어 지금
-// 추가하면 눌러도 갈 곳 없는 죽은 메뉴가 된다. 각 화면이 실제로 구현될 때 해당 그룹과
-// 자식 링크를 추가한다.
+// 시안에는 이 외에도 꾸미기아이템관리·게시판관리(고객센터)·포인트관리·콘텐츠관리·시스템관리
+// 5개 그룹이 더 있지만, 전부 대응하는 화면/라우트가 아직 없어 지금 추가하면 눌러도 갈 곳 없는
+// 죽은 메뉴가 된다. 각 화면이 실제로 구현될 때 해당 그룹과 자식 링크를 추가한다.
 const SIDEBAR_NAV_TREE: SidebarNavNode[] = [
   { type: 'link', label: '홈', href: '/' },
   {
@@ -21,6 +20,13 @@
     label: '회원정보관리',
     children: [{ type: 'link', label: '학생 회원', href: '/students' }],
   },
+  {
+    // 기획(Figma ADM_ADM_101)의 화면경로가 「관리자 회원 < 관리자정보관리」다 —
+    // 「회원정보관리」가 아니라 별도 그룹의 자식이다.
+    type: 'group',
+    label: '관리자정보관리',
+    children: [{ type: 'link', label: '관리자 회원', href: '/admins' }],
+  },
 ];
 
 interface AdminSidebarProps {
 
app/(protected)/(basic)/admins/_actions.ts (added)
+++ app/(protected)/(basic)/admins/_actions.ts
@@ -0,0 +1,219 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { verifySession } from '@/lib/auth/dal';
+import {
+  createAdminMember,
+  deleteAdminMember,
+  isAdminLoginIdTaken,
+  updateAdminMember,
+} from '@/lib/data/repositories/admin-member-repository';
+import { ADMIN_MENU_OPTIONS } from '@/lib/domain/admin-member';
+import {
+  joinPhoneNumber,
+  validateAdminLoginId,
+  validateAdminMemberCreate,
+  validateAdminMemberUpdate,
+  type AdminMemberEditableValues,
+  type AdminMemberFormErrors,
+} from '@/lib/domain/admin-member-form';
+import { ADMIN_MEMBERS_PATH } from '@/lib/domain/admin-member-query';
+
+/**
+ * 관리자 회원 등록/수정/삭제 Server Action.
+ *
+ * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접
+ * POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §8).
+ *
+ * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/admin-member-form.ts`의 규칙을 호출) —
+ * 화면의 required 속성은 편의일 뿐 신뢰 경계가 아니다.
+ *
+ * 실제 저장은 Repository에 맡긴다. 백엔드에 등록/수정/삭제 API가 아직 없어 Repository가 mock
+ * 저장소로 위임하고 있지만, **이 파일은 그 사실을 알지 못한다** — 백엔드 API가 생겨도 이 파일은
+ * 바뀌지 않는다.
+ */
+
+export type AdminMemberFormState =
+  | { status: 'idle' }
+  | { status: 'error'; message?: string; errors?: AdminMemberFormErrors }
+  | { status: 'success' };
+
+export const INITIAL_ADMIN_MEMBER_FORM_STATE: AdminMemberFormState = {
+  status: 'idle',
+};
+
+const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
+const SELF_DELETE_MESSAGE = '현재 로그인한 본인 계정은 삭제할 수 없습니다.';
+const DUPLICATE_LOGIN_ID_MESSAGE = '이미 사용 중인 ID입니다.';
+
+function readString(formData: FormData, key: string): string {
+  const value = formData.get(key);
+  return typeof value === 'string' ? value : '';
+}
+
+/** 체크박스처럼 같은 이름으로 여러 번 오는 값 — 허용 목록에 있는 것만 남긴다. */
+function readMenuCodes(formData: FormData): string[] {
+  return formData
+    .getAll('menuCodes')
+    .filter((value): value is string => typeof value === 'string')
+    .filter((value) =>
+      ADMIN_MENU_OPTIONS.some((option) => option.value === value)
+    );
+}
+
+/**
+ * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 시안대로 3칸으로 나뉘어 오므로 여기서
+ * 하나의 값으로 합친다 — 형식이 어긋나면 빈 문자열이 되어 검증에서 걸린다.
+ */
+function readEditableValues(formData: FormData): AdminMemberEditableValues {
+  const phoneNumber = joinPhoneNumber([
+    readString(formData, 'phoneNumber1'),
+    readString(formData, 'phoneNumber2'),
+    readString(formData, 'phoneNumber3'),
+  ]);
+
+  return {
+    password: readString(formData, 'password'),
+    phoneNumber: phoneNumber ?? '',
+    email: readString(formData, 'email'),
+    roleCode: readString(formData, 'roleCode'),
+    menuCodes: readMenuCodes(formData),
+  };
+}
+
+/** 시안 ADM_ADM_102_p — 관리자 등록. */
+export async function createAdminMemberAction(
+  _prevState: AdminMemberFormState,
+  formData: FormData
+): Promise<AdminMemberFormState> {
+  await verifySession();
+
+  const validation = validateAdminMemberCreate({
+    ...readEditableValues(formData),
+    name: readString(formData, 'name'),
+    loginId: readString(formData, 'loginId'),
+  });
+
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  const { name, loginId, phoneNumber, email, roleCode, menuCodes } =
+    validation.values;
+
+  // 화면의 [중복 확인]은 편의 기능일 뿐이라 저장 직전에 다시 확인한다 — 확인을 누르지 않고
+  // 직접 제출하는 경로가 열려 있고, 확인 후 저장까지의 사이에 선점될 수도 있다.
+  if (await isAdminLoginIdTaken(loginId)) {
+    return { status: 'error', errors: { loginId: DUPLICATE_LOGIN_ID_MESSAGE } };
+  }
+
+  // 비밀번호는 형식만 검증하고 값은 넘기지 않는다 — 저장할 백엔드 API가 아직 없고, mock
+  // 저장소에 평문 비밀번호를 보관하지 않기 때문이다(`admin-member-store.ts` 주석 참조).
+  await createAdminMember({
+    name,
+    loginId,
+    phoneNumber,
+    email,
+    roleCode,
+    menuCodes,
+  });
+
+  revalidatePath(ADMIN_MEMBERS_PATH);
+  return { status: 'success' };
+}
+
+/** 시안 ADM_ADM_103_p — 관리자 수정. 이름·ID는 읽기 전용이라 변경 대상이 아니다. */
+export async function updateAdminMemberAction(
+  _prevState: AdminMemberFormState,
+  formData: FormData
+): Promise<AdminMemberFormState> {
+  await verifySession();
+
+  const id = readString(formData, 'id');
+  if (!id) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  const validation = validateAdminMemberUpdate(readEditableValues(formData));
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  const { phoneNumber, email, roleCode, menuCodes } = validation.values;
+
+  await updateAdminMember(id, {
+    phoneNumber,
+    email,
+    roleCode,
+    menuCodes,
+    // 시안 ③의 "잠김여부"는 활성/비활성으로 표기되고 활성이 곧 잠기지 않은 상태다.
+    isLocked: readString(formData, 'isLocked') === 'true',
+  });
+
+  revalidatePath(ADMIN_MEMBERS_PATH);
+  return { status: 'success' };
+}
+
+/**
+ * 시안 ADM_ADM_101 ⑤ — 삭제. 확인 얼럿은 화면이 띄우고, 여기서는 권한과 자기 계정만 확인한다.
+ *
+ * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의
+ * (prevState, formData) 규약 대신 id를 직접 받는다.
+ */
+export async function deleteAdminMemberAction(
+  id: string
+): Promise<AdminMemberFormState> {
+  const admin = await verifySession();
+
+  if (!id) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  // 시안의 "최고관리자 본인 계정 삭제 방지". 세션의 adminId는 백엔드 accessToken의 `adminId`
+  // 클레임이고 그 값이 곧 목록의 `admUserId`라(EgovJwtTokenUtil.generateAccessToken) 그대로
+  // 비교할 수 있다. 역할과 무관하게 "로그인한 본인"을 막는다 — 자기 계정을 지워 스스로
+  // 로그인 불가 상태가 되는 것은 어떤 역할이든 사고이기 때문이다.
+  if (id === admin.id) {
+    return { status: 'error', message: SELF_DELETE_MESSAGE };
+  }
+
+  await deleteAdminMember(id);
+
+  revalidatePath(ADMIN_MEMBERS_PATH);
+  return { status: 'success' };
+}
+
+export type LoginIdCheckResult =
+  | { status: 'idle' }
+  | { status: 'available'; loginId: string }
+  | { status: 'unavailable'; message: string };
+
+/**
+ * 시안 ADM_ADM_102_p ① — ID 중복 확인.
+ *
+ * 폼 제출이 아니라 버튼 클릭에 반응하는 단발 호출이라 `useActionState`의 (prevState, formData)
+ * 규약 대신 값을 직접 받는다 — 등록 폼 안에 또 다른 폼을 중첩할 수 없기 때문이다(HTML 제약).
+ * 클라이언트에서 일반 함수처럼 `await` 한다.
+ *
+ * 형식이 맞지 않는 ID는 중복 여부를 물을 필요도 없이 되돌린다. 확인에 성공하면 **검사한 ID를 함께**
+ * 돌려주는데, 화면이 "확인한 ID"와 "지금 입력창의 ID"를 비교해 확인 후 값을 고친 경우를 잡아내기
+ * 위해서다.
+ */
+export async function checkAdminLoginId(
+  loginId: string
+): Promise<LoginIdCheckResult> {
+  await verifySession();
+
+  const normalized = loginId.trim();
+
+  const formatError = validateAdminLoginId(normalized);
+  if (formatError) {
+    return { status: 'unavailable', message: formatError };
+  }
+
+  if (await isAdminLoginIdTaken(normalized)) {
+    return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
+  }
+
+  return { status: 'available', loginId: normalized };
+}
 
app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx (added)
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
@@ -0,0 +1,164 @@
+'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>
+  );
+}
 
app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx (added)
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
@@ -0,0 +1,150 @@
+'use client';
+
+import { useActionState, useEffect, useState } 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 { Select } from '@/components/ui/select';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import {
+  ADMIN_LOCK_STATUS_OPTIONS,
+  type AdminMember,
+} from '@/lib/domain/admin-member';
+import { ADMIN_PASSWORD_HELP_TEXT } from '@/lib/domain/admin-member-form';
+import {
+  INITIAL_ADMIN_MEMBER_FORM_STATE,
+  updateAdminMemberAction,
+} from '../_actions';
+import {
+  AdminMemberFormFields,
+  FieldError,
+} from './admin-member-form-fields';
+
+interface AdminMemberEditModalProps {
+  member: AdminMember;
+  onClose: () => void;
+}
+
+const FORM_ID = 'admin-member-edit-form';
+
+/**
+ * 관리자 수정 팝업(시안 ADM_ADM_103_p).
+ *
+ * 이름·ID는 읽기 전용이다(시안 ①). 다만 화면의 readOnly는 표시일 뿐이라 Server Action은 두 값을
+ * 아예 반영하지 않는다 — 직접 POST로 다른 값을 보내도 바뀌지 않는다.
+ *
+ * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있다**(시안 ②). 비워 두면 기존 비밀번호를
+ * 유지한다는 뜻이라 Server Action도 그때는 형식 검증을 건너뛴다. 지금은 저장할 백엔드 API가 없어
+ * 입력값이 검증 후 버려진다 — 흐름만 완성해 둔 상태다.
+ *
+ * "잠김여부"는 활성/비활성으로 표기되지만 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가
+ * 뒤집혀 있다. 그 변환은 도메인(`ADMIN_LOCK_STATUS_OPTIONS`)이 한 곳에서 책임진다.
+ */
+export function AdminMemberEditModal({
+  member,
+  onClose,
+}: AdminMemberEditModalProps) {
+  const { showToast } = useFeedback();
+  const [state, formAction, isPending] = useActionState(
+    updateAdminMemberAction,
+    INITIAL_ADMIN_MEMBER_FORM_STATE
+  );
+
+  const [isPasswordEditable, setIsPasswordEditable] = useState(false);
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      showToast({ variant: 'success', message: '관리자 정보를 수정했습니다.' });
+      onClose();
+    }
+  }, [state, showToast, onClose]);
+
+  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}
+          >
+            {isPending ? '수정 중...' : '수정'}
+          </Button>
+        </>
+      }
+    >
+      <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
+        {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다 —
+            Server Action도 그 둘을 읽지 않으므로 위조해 보내도 반영되지 않는다. */}
+        <input type="hidden" name="id" value={member.id} />
+
+        <p className="text-right text-body-sm text-danger">
+          * 는 필수 항목입니다.
+        </p>
+
+        <Field label="이름">
+          <Input type="text" value={member.name} readOnly />
+        </Field>
+
+        <Field label="ID">
+          <Input type="text" value={member.loginId} readOnly />
+        </Field>
+
+        <Field label="비밀번호">
+          <div className="flex items-center gap-2">
+            <Input
+              type="password"
+              name="password"
+              placeholder="비밀번호를 입력하세요."
+              autoComplete="new-password"
+              disabled={!isPasswordEditable}
+            />
+            <Button
+              type="button"
+              variant="secondary"
+              onClick={() => setIsPasswordEditable(true)}
+              disabled={isPasswordEditable}
+              className="shrink-0"
+            >
+              비밀번호 변경
+            </Button>
+          </div>
+        </Field>
+        {isPasswordEditable && (
+          <p className="text-body-sm text-foreground-muted">
+            {ADMIN_PASSWORD_HELP_TEXT}
+          </p>
+        )}
+        <FieldError message={errors.password} />
+
+        <AdminMemberFormFields member={member} errors={errors} />
+
+        <Field label="잠김여부">
+          <Select
+            name="isLocked"
+            defaultValue={String(member.isLocked ?? false)}
+          >
+            {ADMIN_LOCK_STATUS_OPTIONS.map((option) => (
+              <option key={option.value} value={option.value}>
+                {option.label}
+              </option>
+            ))}
+          </Select>
+        </Field>
+
+        {state.status === 'error' && state.message && (
+          <p className="text-body-sm text-danger">{state.message}</p>
+        )}
+      </form>
+    </Modal>
+  );
+}
 
app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx (added)
+++ app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import { Field } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Select } from '@/components/ui/select';
+import {
+  ADMIN_ROLE_OPTIONS,
+  DEFAULT_ADMIN_ROLE_CODE,
+  type AdminMember,
+} from '@/lib/domain/admin-member';
+import {
+  splitPhoneNumber,
+  type AdminMemberFormErrors,
+} from '@/lib/domain/admin-member-form';
+import { AdminMenuPicker } from './admin-menu-picker';
+
+interface AdminMemberFormFieldsProps {
+  /** 수정 팝업의 기존 값. 등록 팝업은 넘기지 않는다(빈 폼). */
+  member?: AdminMember;
+  errors: AdminMemberFormErrors;
+}
+
+/**
+ * 등록·수정 팝업이 공유하는 입력 항목 — 휴대전화 번호 / 이메일 / 역할 선택 / 메뉴 선택.
+ * 두 시안(ADM_ADM_102_p ②③ / 103_p ②)에서 규칙이 같은 부분만 모았다. 서로 다른 부분
+ * (이름·ID의 편집 가능 여부, 비밀번호 입력 방식, 잠김여부)은 각 팝업이 직접 그린다.
+ *
+ * 휴대전화 번호는 시안대로 3칸으로 나눠 받고 Server Action이 하나로 합친다
+ * (`joinPhoneNumber`) — 칸을 나누는 것은 화면 표현이고, 저장 형식은 도메인이 정한다.
+ */
+export function AdminMemberFormFields({
+  member,
+  errors,
+}: AdminMemberFormFieldsProps) {
+  const phoneParts = splitPhoneNumber(member?.phoneNumber ?? null);
+
+  return (
+    <>
+      <Field label="휴대전화 번호 *">
+        <div className="flex items-center gap-2">
+          <Input
+            type="tel"
+            name="phoneNumber1"
+            defaultValue={phoneParts[0]}
+            maxLength={3}
+            inputMode="numeric"
+            aria-label="휴대전화 번호 앞자리"
+            placeholder="010"
+          />
+          <Input
+            type="tel"
+            name="phoneNumber2"
+            defaultValue={phoneParts[1]}
+            maxLength={4}
+            inputMode="numeric"
+            aria-label="휴대전화 번호 가운데자리"
+            placeholder="0000"
+          />
+          <Input
+            type="tel"
+            name="phoneNumber3"
+            defaultValue={phoneParts[2]}
+            maxLength={4}
+            inputMode="numeric"
+            aria-label="휴대전화 번호 끝자리"
+            placeholder="0000"
+          />
+        </div>
+      </Field>
+      <FieldError message={errors.phoneNumber} />
+
+      <Field label="이메일">
+        <Input
+          type="email"
+          name="email"
+          defaultValue={member?.email ?? ''}
+          placeholder="이메일을 입력하세요."
+        />
+      </Field>
+      <FieldError message={errors.email} />
+
+      <Field label="역할 선택 *">
+        <Select
+          name="roleCode"
+          defaultValue={member?.roleCode ?? DEFAULT_ADMIN_ROLE_CODE}
+        >
+          {ADMIN_ROLE_OPTIONS.map((option) => (
+            <option key={option.value} value={option.value}>
+              {option.label}
+            </option>
+          ))}
+        </Select>
+      </Field>
+      <FieldError message={errors.roleCode} />
+
+      <Field label="메뉴 선택 *">
+        <AdminMenuPicker defaultValue={member?.menuCodes ?? []} />
+      </Field>
+      <FieldError message={errors.menuCodes} />
+    </>
+  );
+}
+
+/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */
+export function FieldError({ message }: { message?: string }) {
+  if (!message) {
+    return null;
+  }
+  return <p className="text-body-sm text-danger">{message}</p>;
+}
 
app/(protected)/(basic)/admins/_components/admin-member-list-toolbar.tsx (added)
+++ app/(protected)/(basic)/admins/_components/admin-member-list-toolbar.tsx
@@ -0,0 +1,99 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState, type ChangeEvent } from 'react';
+import { Button } from '@/components/ui/button';
+import { Select } from '@/components/ui/select';
+import {
+  ADMIN_MEMBER_PAGE_SIZE_OPTIONS,
+  ADMIN_MEMBER_SORT_OPTIONS,
+  buildAdminMemberHref,
+  type AdminMemberPageSize,
+  type AdminMemberQuery,
+  type AdminMemberSortOption,
+} from '@/lib/domain/admin-member-query';
+import { AdminMemberCreateModal } from './admin-member-create-modal';
+
+interface AdminMemberListToolbarProps {
+  query: AdminMemberQuery;
+}
+
+const EXCEL_UNSUPPORTED_NOTE = '엑셀 다운로드는 추후 제공됩니다.';
+
+/**
+ * 목록 툴바(시안 ADM_ADM_101 ②③) — 정렬/페이지 크기 select는 변경 즉시 URL을 갱신해 서버
+ * 데이터를 다시 조회하게 한다. 두 값 모두 바뀌면 1페이지로 되돌린다(기존 페이지 번호가 새 정렬·
+ * 크기 기준으로는 의미가 달라지므로).
+ *
+ * **엑셀다운로드는 버튼만 배치하고 동작은 구현하지 않는다**(사용자 지시 — 추후 진행).
+ * disabled로 두어 클릭해도 아무 일도 일어나지 않게 한다. 학생 회원 목록처럼 백엔드에 엑셀 API가
+ * 있으면 라우트 핸들러로 중계하면 되지만, 관리자 쪽에는 그 API가 아직 없다
+ * (`/api/v1/mngr/admin/**`에 목록 조회 하나뿐).
+ *
+ * [신규 등록]은 시안대로 등록 팝업을 연다 — 팝업 열림 상태를 이 컴포넌트가 소유한다.
+ */
+export function AdminMemberListToolbar({ query }: AdminMemberListToolbarProps) {
+  const router = useRouter();
+  const [isCreateOpen, setIsCreateOpen] = useState(false);
+
+  function handleSortChange(event: ChangeEvent<HTMLSelectElement>) {
+    const sort = event.target.value as AdminMemberSortOption;
+    router.replace(buildAdminMemberHref(query, { sort, page: 1 }));
+  }
+
+  function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) {
+    const pageSize = Number(event.target.value) as AdminMemberPageSize;
+    router.replace(buildAdminMemberHref(query, { pageSize, page: 1 }));
+  }
+
+  return (
+    <div className="flex flex-wrap items-center justify-end gap-3">
+      <Button
+        type="button"
+        variant="success"
+        disabled
+        title={EXCEL_UNSUPPORTED_NOTE}
+      >
+        엑셀다운로드
+      </Button>
+
+      <Button
+        type="button"
+        variant="primary"
+        onClick={() => setIsCreateOpen(true)}
+      >
+        신규 등록
+      </Button>
+
+      <Select
+        name="sort"
+        aria-label="정렬"
+        defaultValue={query.sort}
+        onChange={handleSortChange}
+      >
+        {ADMIN_MEMBER_SORT_OPTIONS.map((option) => (
+          <option key={option.value} value={option.value}>
+            {option.label}
+          </option>
+        ))}
+      </Select>
+
+      <Select
+        name="pageSize"
+        aria-label="페이지 크기"
+        defaultValue={query.pageSize}
+        onChange={handlePageSizeChange}
+      >
+        {ADMIN_MEMBER_PAGE_SIZE_OPTIONS.map((size) => (
+          <option key={size} value={size}>
+            {size}개씩
+          </option>
+        ))}
+      </Select>
+
+      {isCreateOpen && (
+        <AdminMemberCreateModal onClose={() => setIsCreateOpen(false)} />
+      )}
+    </div>
+  );
+}
 
app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx (added)
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
@@ -0,0 +1,102 @@
+'use client';
+
+import { useState, useTransition } from 'react';
+import { Button } from '@/components/ui/button';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { AdminMember } from '@/lib/domain/admin-member';
+import { deleteAdminMemberAction } from '../_actions';
+import { AdminMemberEditModal } from './admin-member-edit-modal';
+
+interface AdminMemberRowActionsProps {
+  member: AdminMember;
+  /** 현재 로그인한 본인의 행인가 — 삭제를 막는다. */
+  isSelf: boolean;
+}
+
+const SELF_DELETE_HINT = '현재 로그인한 본인 계정은 삭제할 수 없습니다.';
+
+/**
+ * 목록 행의 "관리" 셀(시안 ADM_ADM_101 ⑤) — 수정 팝업 열림 상태만 소유하는 최말단 상호작용
+ * 경계다(테이블 전체를 클라이언트로 내리지 않기 위해 이 셀만 분리했다).
+ *
+ * 삭제는 시안대로 **확인 얼럿을 거친 뒤** 실행한다. 전역 얼럿(FeedbackProvider)의 actions 슬롯에
+ * 직접 버튼을 넣어 확인/취소를 구성한다.
+ *
+ * **본인 계정 삭제는 막는다**(시안 ⑤). 화면에서 버튼을 비활성으로 두되, 실제 차단은 Server
+ * Action이 세션의 adminId와 대조해 수행한다 — 화면 비활성은 안내일 뿐 신뢰 경계가 아니다.
+ *
+ * 시안은 연필·휴지통 아이콘 버튼을 지정하지만 `components/ui/icons/`에 두 아이콘이 아직 없다
+ * (신규 아이콘 추가는 공용 컴포넌트 신설이라 design 레인 소관). 로직 우선 단계라 텍스트
+ * 버튼으로 대체했고, 아이콘 부재는 별도로 보고한다.
+ */
+export function AdminMemberRowActions({
+  member,
+  isSelf,
+}: AdminMemberRowActionsProps) {
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [isEditOpen, setIsEditOpen] = useState(false);
+  const [isDeleting, startDeleting] = useTransition();
+
+  function runDelete() {
+    hideAlert();
+    startDeleting(async () => {
+      const result = await deleteAdminMemberAction(member.id);
+      if (result.status === 'error') {
+        showToast({
+          variant: 'danger',
+          message: result.message ?? '삭제하지 못했습니다.',
+        });
+        return;
+      }
+      showToast({ variant: 'success', message: '관리자를 삭제했습니다.' });
+    });
+  }
+
+  function confirmDelete() {
+    showAlert({
+      variant: 'danger',
+      title: '관리자를 삭제하시겠습니까?',
+      message: `${member.name}(${member.loginId}) 계정을 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      actions: (
+        <>
+          <Button type="button" variant="ghost" onClick={hideAlert}>
+            취소
+          </Button>
+          <Button type="button" variant="primary" onClick={runDelete}>
+            삭제
+          </Button>
+        </>
+      ),
+    });
+  }
+
+  return (
+    <div className="flex items-center gap-1">
+      <Button
+        type="button"
+        variant="ghost"
+        size="sm"
+        onClick={() => setIsEditOpen(true)}
+      >
+        수정
+      </Button>
+      <Button
+        type="button"
+        variant="ghost"
+        size="sm"
+        onClick={confirmDelete}
+        disabled={isSelf || isDeleting}
+        title={isSelf ? SELF_DELETE_HINT : undefined}
+      >
+        {isDeleting ? '삭제 중...' : '삭제'}
+      </Button>
+
+      {isEditOpen && (
+        <AdminMemberEditModal
+          member={member}
+          onClose={() => setIsEditOpen(false)}
+        />
+      )}
+    </div>
+  );
+}
 
app/(protected)/(basic)/admins/_components/admin-member-search-bar.tsx (added)
+++ app/(protected)/(basic)/admins/_components/admin-member-search-bar.tsx
@@ -0,0 +1,74 @@
+import Link from 'next/link';
+import { Button } from '@/components/ui/button';
+import { Field } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Select } from '@/components/ui/select';
+import {
+  ADMIN_MEMBERS_PATH,
+  ADMIN_MEMBER_SEARCH_FIELD_OPTIONS,
+  DEFAULT_ADMIN_MEMBER_SEARCH_FIELD,
+  buildAdminMemberHref,
+  type AdminMemberQuery,
+} from '@/lib/domain/admin-member-query';
+
+interface AdminMemberSearchBarProps {
+  query: AdminMemberQuery;
+}
+
+/**
+ * 검색 영역(시안 ADM_ADM_101 ①) — 순수 `<form method="get">` GET 제출로 URL을 갱신한다
+ * (자바스크립트 없이도 동작). 현재 `sort`·`pageSize`는 hidden input으로 함께 제출해 검색 후에도
+ * 유지되게 하고, 검색 자체는 항상 1페이지로 돌아간다(hidden page 필드를 두지 않아
+ * `parseAdminMemberQuery`의 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 제출이 아니라
+ * 기본값으로 되돌린 링크라 현재 입력값과 무관하게 동작한다. 상호작용이 전부 네이티브 폼
+ * 제출/링크 내비게이션이라 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다.
+ *
+ * 검색 대상이 시안의 셋이 아니라 둘인 이유는 `admin-member-query.ts` 주석에 적어 두었다.
+ */
+export function AdminMemberSearchBar({ query }: AdminMemberSearchBarProps) {
+  const resetHref = buildAdminMemberHref(query, {
+    searchField: DEFAULT_ADMIN_MEMBER_SEARCH_FIELD,
+    keyword: '',
+    page: 1,
+  });
+
+  return (
+    <form
+      method="get"
+      action={ADMIN_MEMBERS_PATH}
+      className="flex flex-wrap items-end gap-3"
+    >
+      <input type="hidden" name="sort" value={query.sort} />
+      <input type="hidden" name="pageSize" value={query.pageSize} />
+
+      <Field label="검색 대상">
+        <Select name="searchField" defaultValue={query.searchField}>
+          {ADMIN_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => (
+            <option key={option.value} value={option.value}>
+              {option.label}
+            </option>
+          ))}
+        </Select>
+      </Field>
+
+      <Field label="검색어">
+        <Input
+          type="text"
+          name="keyword"
+          defaultValue={query.keyword}
+          placeholder="검색어를 입력하세요."
+        />
+      </Field>
+
+      <Button type="submit" variant="secondary">
+        검색
+      </Button>
+      <Link
+        href={resetHref}
+        className="text-body-md text-foreground-muted underline-offset-2 hover:underline"
+      >
+        초기화
+      </Link>
+    </form>
+  );
+}
 
app/(protected)/(basic)/admins/_components/admin-member-table.tsx (added)
+++ app/(protected)/(basic)/admins/_components/admin-member-table.tsx
@@ -0,0 +1,90 @@
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeaderCell,
+  TableRow,
+} from '@/components/ui/table';
+import {
+  formatAdminRoleLabel,
+  formatOptionalValue,
+  type AdminMember,
+} from '@/lib/domain/admin-member';
+import { AdminMemberRowActions } from './admin-member-row-actions';
+
+interface AdminMemberTableProps {
+  items: AdminMember[];
+  page: number;
+  pageSize: number;
+  totalCount: number;
+  /** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */
+  currentAdminId: string;
+}
+
+const COLUMN_HEADERS = [
+  '번호',
+  '이름',
+  '아이디',
+  '휴대전화번호',
+  '이메일',
+  '역할',
+  '생성일',
+  '관리',
+];
+
+/**
+ * 관리자 회원 목록 테이블(시안 ADM_ADM_101 ④). 개인정보 마스킹은 시안에 "마스킹 처리 없음"으로
+ * 명시돼 있어 원본 값을 그대로 노출한다.
+ *
+ * **"번호"는 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다** — 시안이 6·5·4처럼
+ * 내림차순으로 표기하고, 목록의 기본 정렬이 생성일 최신순이라 "가장 최근에 만들어진 계정이 가장
+ * 큰 번호"가 된다. 이 계산은 정확한 전체 건수를 전제하는데, Repository가 전체를 받아 세기 때문에
+ * 그 전제가 성립한다(백엔드 `totalCount`는 신뢰할 수 없다 — Repository 주석 참조).
+ *
+ * 백엔드가 아직 주지 않는 항목(휴대전화번호·이메일·생성일)은 열을 그대로 유지한 채 `-`로 표시한다
+ * — 백엔드가 필드를 추가하면 Repository 매핑만 늘리면 이 파일은 그대로 값이 채워진다.
+ *
+ * "관리" 열은 행별 수정/삭제 트리거(AdminMemberRowActions)에 위임한다 — 상호작용이 필요한 것은
+ * 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 경계로 뗀다.
+ */
+export function AdminMemberTable({
+  items,
+  page,
+  pageSize,
+  totalCount,
+  currentAdminId,
+}: AdminMemberTableProps) {
+  const offset = (page - 1) * pageSize;
+
+  return (
+    <Table>
+      <TableHead>
+        <TableRow>
+          {COLUMN_HEADERS.map((header) => (
+            <TableHeaderCell key={header}>{header}</TableHeaderCell>
+          ))}
+        </TableRow>
+      </TableHead>
+      <TableBody>
+        {items.map((member, index) => (
+          <TableRow key={member.id}>
+            <TableCell>{totalCount - offset - index}</TableCell>
+            <TableCell>{member.name}</TableCell>
+            <TableCell>{member.loginId}</TableCell>
+            <TableCell>{formatOptionalValue(member.phoneNumber)}</TableCell>
+            <TableCell>{formatOptionalValue(member.email)}</TableCell>
+            <TableCell>{formatAdminRoleLabel(member.roleCode)}</TableCell>
+            <TableCell>{formatOptionalValue(member.createdAt)}</TableCell>
+            <TableCell>
+              <AdminMemberRowActions
+                member={member}
+                isSelf={member.id === currentAdminId}
+              />
+            </TableCell>
+          </TableRow>
+        ))}
+      </TableBody>
+    </Table>
+  );
+}
 
app/(protected)/(basic)/admins/_components/admin-menu-picker.tsx (added)
+++ app/(protected)/(basic)/admins/_components/admin-menu-picker.tsx
@@ -0,0 +1,89 @@
+'use client';
+
+import { useState } from 'react';
+import { ADMIN_MENU_OPTIONS } from '@/lib/domain/admin-member';
+
+interface AdminMenuPickerProps {
+  /** 초기 선택 상태(수정 팝업의 기존 값). */
+  defaultValue: string[];
+}
+
+/**
+ * "메뉴 선택" 칩 목록(시안 ADM_ADM_102_p ③) — 다중 선택이며 맨 앞의 [전체]는 전체 선택/해제
+ * 토글이다.
+ *
+ * 선택 결과는 hidden input(`name="menuCodes"`)으로 폼에 실린다 — 칩이 `<button>`이라 그 자체로는
+ * 폼 값이 되지 않기 때문이다. 같은 이름의 input을 여러 개 두면 `FormData.getAll('menuCodes')`가
+ * 전부 읽어 간다(Server Action의 `readMenuCodes`가 그렇게 읽는다).
+ *
+ * 선택지는 아직 mock 카탈로그다 — 백엔드에 관리자 메뉴 API가 없다(`admin-member.ts` 주석 참조).
+ *
+ * 비주얼(선택 상태 칩)은 공용 컴포넌트에 아직 없어 여기서 Tailwind 토큰으로 직접 그렸다.
+ * 시안이 확정되면 design 레인의 공용 칩 컴포넌트로 교체할 자리다.
+ */
+export function AdminMenuPicker({ defaultValue }: AdminMenuPickerProps) {
+  const [selected, setSelected] = useState<string[]>(defaultValue);
+
+  const isAllSelected = selected.length === ADMIN_MENU_OPTIONS.length;
+
+  function toggleAll() {
+    setSelected(
+      isAllSelected ? [] : ADMIN_MENU_OPTIONS.map((option) => option.value)
+    );
+  }
+
+  function toggle(value: string) {
+    setSelected((prev) =>
+      prev.includes(value)
+        ? prev.filter((code) => code !== value)
+        : [...prev, value]
+    );
+  }
+
+  return (
+    <div className="flex flex-wrap gap-2">
+      {selected.map((value) => (
+        <input key={value} type="hidden" name="menuCodes" value={value} />
+      ))}
+
+      <Chip isSelected={isAllSelected} onClick={toggleAll}>
+        전체
+      </Chip>
+
+      {ADMIN_MENU_OPTIONS.map((option) => (
+        <Chip
+          key={option.value}
+          isSelected={selected.includes(option.value)}
+          onClick={() => toggle(option.value)}
+        >
+          {option.label}
+        </Chip>
+      ))}
+    </div>
+  );
+}
+
+function Chip({
+  isSelected,
+  onClick,
+  children,
+}: {
+  isSelected: boolean;
+  onClick: () => void;
+  children: string;
+}) {
+  return (
+    <button
+      type="button"
+      aria-pressed={isSelected}
+      onClick={onClick}
+      className={`rounded-full border px-3 py-1 text-body-sm transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary ${
+        isSelected
+          ? 'border-primary text-primary'
+          : 'border-border text-foreground-muted hover:bg-surface-muted'
+      }`}
+    >
+      {children}
+    </button>
+  );
+}
 
app/(protected)/(basic)/admins/error.tsx (added)
+++ app/(protected)/(basic)/admins/error.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { useEffect } from 'react';
+
+interface ErrorPageProps {
+  error: Error & { digest?: string };
+  unstable_retry: () => void;
+}
+
+// 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만 콘솔에 남기고(프로덕션에서는
+// Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로 치환해 전달한다),
+// 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라 세그먼트
+// 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다.
+export default function ErrorPage({ error, unstable_retry }: ErrorPageProps) {
+  useEffect(() => {
+    console.error(error);
+  }, [error]);
+
+  return (
+    <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8">
+      <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1>
+      <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p>
+      <button
+        type="button"
+        onClick={() => unstable_retry()}
+        className="mt-4 bg-foreground px-4 py-2 text-background"
+      >
+        다시 시도
+      </button>
+    </section>
+  );
+}
 
app/(protected)/(basic)/admins/loading.tsx (added)
+++ app/(protected)/(basic)/admins/loading.tsx
@@ -0,0 +1,12 @@
+import { Spinner } from '@/components/ui/spinner';
+
+// page.tsx가 스트리밍되는 동안의 폴백 — 같은 section 배치(flex-1 items-center justify-center)를
+// 써서 콘텐츠 교체 시 레이아웃이 흔들리지 않게 한다. Server Component로 충분해 'use client'를
+// 붙이지 않는다.
+export default function Loading() {
+  return (
+    <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8">
+      <Spinner size="lg" />
+    </section>
+  );
+}
 
app/(protected)/(basic)/admins/page.tsx (added)
+++ app/(protected)/(basic)/admins/page.tsx
@@ -0,0 +1,87 @@
+import type { Metadata } from 'next';
+import Link from 'next/link';
+import { Alert } from '@/components/ui/alert';
+import { Pagination } from '@/components/ui/pagination';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchAdminMembers } from '@/lib/data/repositories/admin-member-repository';
+import {
+  buildAdminMemberHref,
+  parseAdminMemberQuery,
+} from '@/lib/domain/admin-member-query';
+import { AdminMemberListToolbar } from './_components/admin-member-list-toolbar';
+import { AdminMemberSearchBar } from './_components/admin-member-search-bar';
+import { AdminMemberTable } from './_components/admin-member-table';
+
+export const metadata: Metadata = {
+  title: '관리자 회원',
+};
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+/**
+ * 관리자 회원 목록(시안 ADM_ADM_101).
+ *
+ * 학생 회원 목록과 달리 전체 건수가 **확정값**이라 페이지 수를 그대로 계산한다 — Repository가
+ * 백엔드 페이징을 쓰지 않고 전체를 받아 직접 세기 때문이다(그 이유는 Repository 주석 참조).
+ *
+ * `verifySession()`의 반환값을 목록까지 내려보내는 이유는 본인 계정의 삭제 버튼을 막기 위해서다
+ * (시안 ⑤). 실제 차단은 Server Action이 다시 확인한다.
+ */
+export default async function Page({ searchParams }: PageProps) {
+  const admin = await verifySession();
+
+  const query = parseAdminMemberQuery(await searchParams);
+  const { items, totalCount } = await fetchAdminMembers(query);
+
+  const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
+  // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐) 마지막 페이지로 맞춘다 —
+  // 표의 순번 계산도 이 값을 기준으로 해야 헤더의 "현재페이지"와 어긋나지 않는다.
+  const currentPage = Math.min(query.page, totalPages);
+
+  return (
+    <section className="flex flex-1 flex-col gap-6 p-8">
+      <h1 className="text-headline-sm text-foreground">관리자 회원 목록</h1>
+
+      <AdminMemberSearchBar query={query} />
+
+      <AdminMemberListToolbar query={query} />
+
+      <p className="text-body-md text-foreground-muted">
+        총 {totalCount}명 | 현재페이지 {currentPage}/{totalPages}
+      </p>
+
+      {items.length === 0 ? (
+        <Alert
+          title="검색 결과가 없습니다."
+          actions={
+            <Link
+              href={buildAdminMemberHref(query, { keyword: '', page: 1 })}
+              className="bg-foreground px-4 py-2 text-background"
+            >
+              확인
+            </Link>
+          }
+        >
+          다른 검색어로 다시 시도해 주세요.
+        </Alert>
+      ) : (
+        <>
+          <AdminMemberTable
+            items={items}
+            page={currentPage}
+            pageSize={query.pageSize}
+            totalCount={totalCount}
+            currentAdminId={admin.id}
+          />
+          <Pagination
+            currentPage={currentPage}
+            totalPages={totalPages}
+            buildHref={(page: number) => buildAdminMemberHref(query, { page })}
+          />
+        </>
+      )}
+    </section>
+  );
+}
 
lib/data/mock/admin-member-store.ts (added)
+++ lib/data/mock/admin-member-store.ts
@@ -0,0 +1,165 @@
+import 'server-only';
+import type { AdminMember } from '@/lib/domain/admin-member';
+
+/**
+ * 관리자 회원 등록/수정/삭제의 **mock 저장소** — 백엔드에 해당 API가 없어 임시로 두는 계층이다.
+ *
+ * 백엔드(edupay-backend develop b742bb4)의 관리자 도메인에는 목록 조회
+ * (`GET /api/v1/mngr/admin/pagination`)와 로그인 조회밖에 없다 — 등록·수정·삭제·단건 조회
+ * 어느 것도 컨트롤러·서비스·매퍼에 존재하지 않는다(패키지 전체를 읽어 확인). 그래서 시안
+ * (ADM_ADM_101 ③⑤ / 102_p / 103_p)의 쓰기 흐름은 이 파일이 대신 처리한다.
+ *
+ * **동작 방식은 "덮어쓰기 오버레이"다.** 목록의 원본은 여전히 백엔드이고, 이 저장소는 그 위에
+ * 얹을 변경분(추가된 행 / 필드 패치 / 삭제된 id)만 들고 있다. Repository가 백엔드 응답을 받은 뒤
+ * `applyMockOverlay`로 합쳐 화면에 넘긴다.
+ *
+ * **한계를 분명히 해 둔다 — 이건 데모용이지 저장소가 아니다.**
+ * - 서버 프로세스 메모리에만 있다. 재시작하면 사라지고, 인스턴스가 여럿이면 공유되지 않는다.
+ * - 비밀번호는 **보관하지 않는다.** mock이라 검증할 곳이 없고, 평문 비밀번호를 메모리에 들고
+ *   있는 형태를 코드에 남기고 싶지 않다. 등록/수정 시 형식 검증만 하고 값은 버린다.
+ *
+ * 백엔드 API가 생기면 이 파일을 삭제하고 Repository의 쓰기 함수 본문만 실제 호출로 교체한다 —
+ * 화면·Server Action은 그대로다.
+ */
+
+/** 신규 등록 행의 id 접두사 — 백엔드 `admUserId`와 섞이지 않게 구분한다. */
+const MOCK_ID_PREFIX = 'mock-admin-';
+
+type AdminMemberPatch = Partial<
+  Pick<
+    AdminMember,
+    'phoneNumber' | 'email' | 'roleCode' | 'menuCodes' | 'isLocked'
+  >
+>;
+
+type MockStoreState = {
+  created: AdminMember[];
+  patches: Map<string, AdminMemberPatch>;
+  deletedIds: Set<string>;
+  nextId: number;
+};
+
+/**
+ * 개발 중 HMR로 모듈이 다시 평가돼도 상태가 초기화되지 않도록 globalThis에 붙인다 — 저장한
+ * 내용이 코드 수정 때마다 사라지면 mock으로 흐름을 확인하는 의미가 없다. mock 전용 편법이며,
+ * 실제 데이터 계층에는 쓰지 않는다.
+ */
+const globalStore = globalThis as typeof globalThis & {
+  __adminMemberMockStore?: MockStoreState;
+};
+
+function getState(): MockStoreState {
+  globalStore.__adminMemberMockStore ??= {
+    created: [],
+    patches: new Map(),
+    deletedIds: new Set(),
+    nextId: 1,
+  };
+  return globalStore.__adminMemberMockStore;
+}
+
+/** 오늘 날짜(YYYY-MM-DD) — 신규 등록 행의 생성일. */
+function today(): string {
+  return new Date().toISOString().slice(0, 10);
+}
+
+export type CreateAdminMemberInput = {
+  name: string;
+  loginId: string;
+  phoneNumber: string;
+  email: string;
+  roleCode: string;
+  menuCodes: string[];
+};
+
+export type UpdateAdminMemberInput = {
+  phoneNumber: string;
+  email: string;
+  roleCode: string;
+  menuCodes: string[];
+  isLocked: boolean;
+};
+
+/**
+ * 백엔드 목록에 mock 변경분을 얹는다 — 삭제된 행을 걷어내고, 수정된 행에 패치를 입히고,
+ * 신규 등록 행을 **앞에** 붙인다(기본 정렬이 생성일 최신순이라 방금 만든 행이 맨 위에 온다).
+ *
+ * 정렬·검색·페이징은 이 함수가 하지 않는다 — 합쳐진 전체 목록을 대상으로 Repository가 처리한다.
+ */
+export function applyMockOverlay(backendItems: AdminMember[]): AdminMember[] {
+  const { created, patches, deletedIds } = getState();
+
+  const merged = [...created, ...backendItems];
+
+  return merged
+    .filter((item) => !deletedIds.has(item.id))
+    .map((item) => {
+      const patch = patches.get(item.id);
+      return patch ? { ...item, ...patch } : item;
+    });
+}
+
+/** mock으로 등록된 행들의 로그인 ID — 중복 확인이 백엔드 목록과 함께 검사할 수 있게 노출한다. */
+export function listMockCreatedLoginIds(): string[] {
+  return getState().created.map((item) => item.loginId);
+}
+
+export function createMockAdminMember(
+  input: CreateAdminMemberInput
+): AdminMember {
+  const state = getState();
+  const id = `${MOCK_ID_PREFIX}${state.nextId++}`;
+
+  const member: AdminMember = {
+    id,
+    name: input.name,
+    loginId: input.loginId,
+    phoneNumber: input.phoneNumber,
+    email: input.email || null,
+    roleCode: input.roleCode,
+    menuCodes: input.menuCodes,
+    createdAt: today(),
+    isLocked: false,
+    isActive: true,
+    loginFailCount: 0,
+  };
+
+  state.created.unshift(member);
+  return member;
+}
+
+/**
+ * 수정 — 신규 등록 행은 원본을 직접 고치고, 백엔드에서 온 행은 패치로 기록해 둔다(원본을 우리가
+ * 소유하지 않으므로 매 조회마다 덧입힌다).
+ */
+export function updateMockAdminMember(
+  id: string,
+  input: UpdateAdminMemberInput
+): void {
+  const state = getState();
+
+  const patch: AdminMemberPatch = {
+    phoneNumber: input.phoneNumber,
+    email: input.email || null,
+    roleCode: input.roleCode,
+    menuCodes: input.menuCodes,
+    isLocked: input.isLocked,
+  };
+
+  const createdIndex = state.created.findIndex((item) => item.id === id);
+  if (createdIndex >= 0) {
+    state.created[createdIndex] = { ...state.created[createdIndex], ...patch };
+    return;
+  }
+
+  state.patches.set(id, { ...state.patches.get(id), ...patch });
+}
+
+export function deleteMockAdminMember(id: string): void {
+  const state = getState();
+
+  state.created = state.created.filter((item) => item.id !== id);
+  state.patches.delete(id);
+  // 백엔드에서 오는 행은 매 조회마다 다시 등장하므로 삭제 사실을 따로 기억해야 한다.
+  state.deletedIds.add(id);
+}
 
lib/data/repositories/admin-member-repository.ts (added)
+++ lib/data/repositories/admin-member-repository.ts
@@ -0,0 +1,267 @@
+import 'server-only';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import type { AdminMember } from '@/lib/domain/admin-member';
+import type {
+  AdminMemberQuery,
+  AdminMemberSearchField,
+} from '@/lib/domain/admin-member-query';
+import {
+  applyMockOverlay,
+  createMockAdminMember,
+  deleteMockAdminMember,
+  listMockCreatedLoginIds,
+  updateMockAdminMember,
+  type CreateAdminMemberInput,
+  type UpdateAdminMemberInput,
+} from '@/lib/data/mock/admin-member-store';
+
+/**
+ * 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 조회하는지"만 안다(엔드포인트·파라미터·
+ * 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은
+ * `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다.
+ *
+ *   GET /api/v1/mngr/admin/pagination  (ROLE_ADMIN 전용)
+ *   → data: { list: [{ rnum, admUserId, admNm, loginId, admRoleCd,
+ *                      loginFailCnt, acctLockYn, useYn }], page, size, totalCount, totalPages }
+ *
+ * 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다
+ * — MngrAdminApiController / MngrAdminServiceImpl / MngrAdminMapper.xml / PaginationUtil.
+ *
+ * - **응답에 휴대전화번호·이메일·생성일이 없다.** 조회 SQL의 select 목록 자체에 그 컬럼들이 없고
+ *   `MngrAdminVo`에도 필드가 없다. 그래서 시안의 해당 열은 `null` → `-`다. 백엔드가 컬럼과 VO
+ *   필드를 추가하면 `toAdminMember`의 매핑만 늘리면 되고 화면은 손대지 않는다.
+ * - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY RNUM DESC`가 하드코딩돼 있다. rnum이
+ *   `ROW_NUMBER() OVER (ORDER BY frst_reg_dt, adm_nm DESC)`, 즉 최초등록일시 오름차순 순번이라
+ *   그것을 뒤집은 고정 순서가 곧 **생성일 최신순**이다. 그래서 생성일순 정렬은 "백엔드가 준 순서를
+ *   그대로 쓴다"는 뜻이고, 이름순만 우리가 정렬한다.
+ * - **`totalCount`가 전체 건수가 아니다.** count 쿼리가 없어 `PaginationUtil.execute`가
+ *   `list.size()`(= 그 페이지의 행 수)를 총건수로 그대로 쓴다. 그래서 이 값은 신뢰하지 않는다.
+ *
+ * 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용이다. 세션에 보관된 백엔드 accessToken을 DAL에서
+ * 꺼내 Bearer로 붙인다.
+ *
+ * 캐시: `no-store` — 개인정보 목록이고 검색 조건이 매 요청 다르다.
+ */
+
+const ADMIN_MEMBER_PAGINATION_PATH = '/api/v1/mngr/admin/pagination';
+
+/**
+ * 한 번에 받아올 최대 행 수. **이 화면은 백엔드 페이징을 쓰지 않고 전체를 받아 여기서 자른다.**
+ * 이유가 세 가지 겹친다:
+ *
+ * 1. **총건수가 없다.** 위에서 적었듯 `totalCount`가 현재 페이지 행 수라, 그 값을 믿으면
+ *    페이지가 가득 찰 때마다 `totalPages`가 1로 계산돼 2페이지 이후에 영원히 닿을 수 없다.
+ *    시안(ADM_ADM_101)은 "총 N명 | 현재페이지 1/1"과 번호 열(총건수에서 거꾸로 세는 순번)을
+ *    요구하는데, 둘 다 정확한 전체 건수를 전제한다.
+ * 2. **검색·정렬을 백엔드에 맡길 수 없다.** 이름순 정렬 파라미터가 없고, 휴대전화번호 검색
+ *    분기는 관리자 테이블에 없는 컬럼을 참조한다(`admin-member-query.ts` 주석 참조).
+ * 3. **등록/수정/삭제가 mock이다.** mock으로 만든 행과 백엔드 행이 같은 검색·정렬·페이징 규칙을
+ *    따라야 하는데, 페이징이 백엔드에 있으면 두 출처를 일관되게 합칠 방법이 없다.
+ *
+ * 관리자 계정은 본래 수십 건 규모라 전체를 받아도 부담이 없다. 이 전제가 깨질 정도로 늘면
+ * 백엔드에 count·정렬·검색 파라미터가 필요하다 — 상한 인상은 임시방편일 뿐이다.
+ * (같은 이유로 학생 목록의 이름순 정렬과 엑셀 다운로드도 이미 같은 방식을 쓴다.)
+ */
+const ADMIN_MEMBER_FETCH_LIMIT = 10_000;
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
+}
+
+function readRequiredString(
+  source: Record<string, unknown>,
+  key: string
+): string {
+  const value = source[key];
+  if (typeof value !== 'string' || value.length === 0) {
+    throw new Error(`관리자 회원 응답에 ${key}가 없습니다.`);
+  }
+  return value;
+}
+
+function readOptionalString(
+  source: Record<string, unknown>,
+  key: string
+): string | null {
+  const value = source[key];
+  return typeof value === 'string' && value.length > 0 ? value : null;
+}
+
+/** 백엔드의 Y/N 플래그 → boolean. 값이 없거나 Y/N이 아니면 "모름"(null)이다. */
+function parseYesNo(value: unknown): boolean | null {
+  if (value === 'Y') return true;
+  if (value === 'N') return false;
+  return null;
+}
+
+/**
+ * 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`(목록은 빈 배열)로 둔다.
+ *
+ * `rnum`은 담지 않는다 — 표의 "번호"는 백엔드의 행 번호가 아니라 전체 건수 기준 역순 순번이고
+ * (시안이 6·5·4처럼 내림차순으로 표기한다), 그 계산은 표 컴포넌트가 한다.
+ *
+ * 식별자·이름·ID 세 필드는 없으면 예외로 끊는다(fail-fast) — 목록의 존재 이유인 값이라
+ * 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다.
+ */
+function toAdminMember(raw: unknown): AdminMember {
+  if (!isRecord(raw)) {
+    throw new Error('관리자 회원 응답 항목의 형식이 올바르지 않습니다.');
+  }
+
+  const loginFailCnt = raw.loginFailCnt;
+
+  return {
+    id: readRequiredString(raw, 'admUserId'),
+    name: readRequiredString(raw, 'admNm'),
+    loginId: readRequiredString(raw, 'loginId'),
+    phoneNumber: null,
+    email: null,
+    roleCode: readOptionalString(raw, 'admRoleCd') ?? '',
+    menuCodes: [],
+    createdAt: null,
+    isLocked: parseYesNo(raw.acctLockYn),
+    isActive: parseYesNo(raw.useYn),
+    loginFailCount: typeof loginFailCnt === 'number' ? loginFailCnt : null,
+  };
+}
+
+/**
+ * 백엔드 목록 전체를 한 번에 받아온다.
+ *
+ * 검색 파라미터(`searchCondition`/`searchKeyword`)를 **의도적으로 보내지 않는다** — 검색은
+ * mock 행까지 포함해 일관되게 걸러야 하므로 아래 `filterByKeyword`가 전담한다. 백엔드에 검색을
+ * 맡기면 백엔드 행만 걸러지고 mock 행은 그대로 남아 결과가 어긋난다.
+ */
+async function fetchAllAdminMembers(): Promise<AdminMember[]> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(ADMIN_MEMBER_PAGINATION_PATH, {
+    method: 'GET',
+    // `PaginationUtil`이 offset을 직접 계산해 firstIndex를 덮어쓰므로 실제로 쓰이는 파라미터는
+    // 이 둘뿐이다 — 나머지(pageUnit/pageSize/firstIndex/lastIndex)는 읽히지 않아 보내지 않는다.
+    query: { pageIndex: 1, recordCountPerPage: ADMIN_MEMBER_FETCH_LIMIT },
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  const data = result.data;
+  if (!isRecord(data) || !Array.isArray(data.list)) {
+    throw new Error('관리자 회원 목록 응답의 형식이 올바르지 않습니다.');
+  }
+
+  return data.list.map(toAdminMember);
+}
+
+/** 검색 대상 → 비교할 값. 목록에 실제로 보이는 값으로만 거른다. */
+const SEARCH_VALUE_BY_FIELD: Record<
+  AdminMemberSearchField,
+  (member: AdminMember) => string
+> = {
+  name: (member) => member.name,
+  loginId: (member) => member.loginId,
+};
+
+function filterByKeyword(
+  items: AdminMember[],
+  query: AdminMemberQuery
+): AdminMember[] {
+  const keyword = query.keyword.trim().toLowerCase();
+  if (!keyword) {
+    return items;
+  }
+
+  const readValue = SEARCH_VALUE_BY_FIELD[query.searchField];
+  return items.filter((item) => readValue(item).toLowerCase().includes(keyword));
+}
+
+/**
+ * 정렬. 생성일순은 **정렬하지 않는다** — 백엔드가 이미 생성일 최신순으로 내려주고(고정
+ * `ORDER BY rnum DESC`), mock 신규 등록 행은 오버레이가 맨 앞에 붙여 두기 때문이다. 응답에
+ * `createdAt` 값 자체가 없어 우리가 다시 정렬할 수단도 없다.
+ */
+function sortItems(
+  items: AdminMember[],
+  query: AdminMemberQuery
+): AdminMember[] {
+  if (query.sort !== 'name') {
+    return items;
+  }
+  return [...items].sort((a, b) => a.name.localeCompare(b.name, 'ko'));
+}
+
+export type AdminMemberPage = {
+  items: AdminMember[];
+  /** 검색 조건을 적용한 전체 건수. 전체를 손에 쥐고 세므로 확정값이다. */
+  totalCount: number;
+};
+
+/** 검색·정렬·페이징이 적용된 관리자 회원 목록을 조회한다. */
+export async function fetchAdminMembers(
+  query: AdminMemberQuery
+): Promise<AdminMemberPage> {
+  const all = applyMockOverlay(await fetchAllAdminMembers());
+  const matched = sortItems(filterByKeyword(all, query), query);
+
+  const offset = (query.page - 1) * query.pageSize;
+
+  return {
+    items: matched.slice(offset, offset + query.pageSize),
+    totalCount: matched.length,
+  };
+}
+
+/** 단건 조회 — 수정 팝업이 쓰는 진입점. 백엔드에 단건 API가 없어 목록에서 찾는다. */
+export async function findAdminMemberById(
+  id: string
+): Promise<AdminMember | null> {
+  const all = applyMockOverlay(await fetchAllAdminMembers());
+  return all.find((item) => item.id === id) ?? null;
+}
+
+/**
+ * ID 중복 확인(시안 ADM_ADM_102_p ①). 백엔드에 중복 확인 API가 없어 목록에 이미 있는 ID인지로
+ * 판정한다 — 목록은 실제 계정 전부를 담으므로 판정 자체는 맞지만, 확인 시점과 저장 시점 사이에
+ * 다른 관리자가 같은 ID를 선점하는 경쟁 조건은 막지 못한다. 저장 시 유일성 보장은 결국 백엔드
+ * (DB 유니크 제약)의 몫이다.
+ */
+export async function isAdminLoginIdTaken(loginId: string): Promise<boolean> {
+  const normalized = loginId.trim().toLowerCase();
+
+  const backendLoginIds = (await fetchAllAdminMembers()).map(
+    (item) => item.loginId
+  );
+
+  return [...backendLoginIds, ...listMockCreatedLoginIds()].some(
+    (existing) => existing.toLowerCase() === normalized
+  );
+}
+
+/*
+ * ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
+ * 백엔드에 등록·수정·삭제 API가 없어 세 함수 모두 mock 저장소에 위임한다
+ * (`lib/data/mock/admin-member-store.ts`의 주석에 한계를 적어 두었다).
+ * 백엔드 API가 생기면 **이 세 함수의 본문만** `backendFetch` 호출로 바꾸면 되고,
+ * Server Action과 화면은 그대로다 — 그러라고 호출부가 이 계층만 보게 두었다.
+ */
+
+export async function createAdminMember(
+  input: CreateAdminMemberInput
+): Promise<void> {
+  createMockAdminMember(input);
+}
+
+export async function updateAdminMember(
+  id: string,
+  input: UpdateAdminMemberInput
+): Promise<void> {
+  updateMockAdminMember(id, input);
+}
+
+export async function deleteAdminMember(id: string): Promise<void> {
+  deleteMockAdminMember(id);
+}
 
lib/domain/admin-member-form.ts (added)
+++ lib/domain/admin-member-form.ts
@@ -0,0 +1,222 @@
+/**
+ * 관리자 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음).
+ *
+ * 규칙은 시안(ADM_ADM_102_p / ADM_ADM_103_p)의 안내 문구를 그대로 옮긴 것이다:
+ *   - ID   : "영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다."
+ *   - 비밀번호: "영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상"
+ *
+ * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를
+ * 거치고, 화면의 안내 문구도 여기 상수를 그대로 쓴다 — 규칙이 화면과 서버에서 갈라지는 것을 막는다.
+ * 화면 입력 단계의 즉시 피드백이 아니라 **제출 시 서버 검증**이 최종 방어선이다(Server Action은
+ * UI를 거치지 않고 직접 호출될 수 있다).
+ *
+ * **등록과 수정의 검증을 분리한 이유**: 수정 팝업에서 이름·ID는 읽기 전용이다(시안 103_p ①).
+ * 그 값에까지 등록용 형식 규칙을 다시 적용하면, 규칙이 생기기 전에 만들어졌거나 규칙 밖에서
+ * 발급된 기존 계정(예: 숫자가 없는 ID)이 **자기 정보를 저장할 수 없게 된다** — 바꾸지도 않는
+ * 필드 때문에. 그래서 수정은 실제로 바뀔 수 있는 항목만 검증한다.
+ *
+ * 백엔드에 등록/수정 API가 생기면 백엔드 규칙과 대조해 이 파일을 맞춰야 한다 — 지금은 시안이
+ * 유일한 근거다(백엔드에 관리자 등록 관련 코드 자체가 없다).
+ */
+
+import {
+  ADMIN_MENU_OPTIONS,
+  ADMIN_ROLE_OPTIONS,
+  type AdminRoleCode,
+} from '@/lib/domain/admin-member';
+
+export const ADMIN_LOGIN_ID_HELP_TEXT =
+  '영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다.';
+export const ADMIN_PASSWORD_HELP_TEXT =
+  '영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상';
+
+const LOGIN_ID_MIN_LENGTH = 4;
+const LOGIN_ID_MAX_LENGTH = 20;
+const PASSWORD_MIN_LENGTH = 10;
+const NAME_MAX_LENGTH = 50;
+const EMAIL_MAX_LENGTH = 100;
+
+/** 휴대전화번호는 시안처럼 3칸으로 나뉘어 입력된다. 앞자리는 010 등 3자리, 가운데 3~4자리, 끝 4자리. */
+const PHONE_PART_PATTERNS = [/^\d{3}$/, /^\d{3,4}$/, /^\d{4}$/] as const;
+
+/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */
+export type AdminMemberEditableValues = {
+  password: string;
+  phoneNumber: string;
+  email: string;
+  roleCode: AdminRoleCode;
+  menuCodes: string[];
+};
+
+/** 등록은 위 항목에 더해 이름·ID를 입력받는다(수정에서는 읽기 전용). */
+export type AdminMemberCreateValues = AdminMemberEditableValues & {
+  name: string;
+  loginId: string;
+};
+
+/** 필드별 오류 메시지 — 키는 폼 필드 이름과 일치시켜 화면이 그대로 붙여 쓸 수 있게 한다. */
+export type AdminMemberFormErrors = Partial<
+  Record<keyof AdminMemberCreateValues, string>
+>;
+
+export type ValidationResult<T> =
+  | { ok: true; values: T }
+  | { ok: false; errors: AdminMemberFormErrors };
+
+/**
+ * 비밀번호 조합 종류 수 — 영문 소문자 / 숫자 / 특수문자 중 몇 종류가 섞였는지 센다.
+ * 시안이 "2종류 이상"만 요구하므로 대문자는 별도 종류로 세지 않는다(있어도 무방하다).
+ */
+function countCharacterKinds(password: string): number {
+  const kinds = [/[a-z]/, /\d/, /[^a-zA-Z0-9]/];
+  return kinds.filter((pattern) => pattern.test(password)).length;
+}
+
+/**
+ * ID 형식 검증 — 문제가 있으면 안내 문구, 없으면 null.
+ *
+ * 중복 확인(`checkAdminLoginId`)도 이 함수를 그대로 쓴다 — 중복을 묻기 전에 형식부터 봐야 하고,
+ * 그 판단 기준이 등록 시점과 달라지면 안 되기 때문이다.
+ */
+export function validateAdminLoginId(loginId: string): string | null {
+  const value = loginId.trim();
+
+  if (!value) {
+    return 'ID를 입력해 주세요.';
+  }
+  if (value.length < LOGIN_ID_MIN_LENGTH || value.length > LOGIN_ID_MAX_LENGTH) {
+    return `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`;
+  }
+  // "영어 소문자, 숫자를 조합" — 허용 문자를 두 종류로 제한하고, 둘 다 포함되어야 한다.
+  if (!/^[a-z0-9]+$/.test(value) || !/[a-z]/.test(value) || !/\d/.test(value)) {
+    return ADMIN_LOGIN_ID_HELP_TEXT;
+  }
+  return null;
+}
+
+/**
+ * 휴대전화번호 3칸을 하나의 문자열(`010-1234-5678`)로 합친다. 비어 있는 칸이 하나라도 있거나
+ * 형식이 맞지 않으면 null — 부분적으로 채워진 번호를 저장하지 않기 위해서다.
+ */
+export function joinPhoneNumber(parts: string[]): string | null {
+  if (parts.length !== PHONE_PART_PATTERNS.length) {
+    return null;
+  }
+
+  const trimmed = parts.map((part) => part.trim());
+  const isValid = trimmed.every((part, index) =>
+    PHONE_PART_PATTERNS[index].test(part)
+  );
+
+  return isValid ? trimmed.join('-') : null;
+}
+
+/** 저장된 번호를 다시 3칸으로 나눈다(수정 팝업의 초기값). 형식이 다르면 빈 칸들을 돌려준다. */
+export function splitPhoneNumber(phoneNumber: string | null): string[] {
+  const parts = (phoneNumber ?? '').split('-');
+  return parts.length === PHONE_PART_PATTERNS.length ? parts : ['', '', ''];
+}
+
+function isValidEmail(email: string): boolean {
+  // 공백 없는 `로컬부@도메인.최상위` 정도만 본다 — 이메일의 완전한 문법 검증은 정규식으로
+  // 할 수 없고, 실제 유효성은 발송으로만 확인된다. 오탈자를 걸러내는 것이 목적이다.
+  return (
+    /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= EMAIL_MAX_LENGTH
+  );
+}
+
+/**
+ * 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다.
+ *
+ * `isPasswordRequired`로 등록과 수정을 가른다 — 등록은 비밀번호가 필수지만(시안 102_p ①),
+ * 수정은 [비밀번호 변경]을 눌러 입력한 경우에만 검사한다(비우면 기존 비밀번호 유지).
+ */
+function validateEditableValues(
+  values: AdminMemberEditableValues,
+  errors: AdminMemberFormErrors,
+  options: { isPasswordRequired: boolean }
+): AdminMemberEditableValues {
+  const password = values.password;
+  const phoneNumber = values.phoneNumber.trim();
+  const email = values.email.trim();
+
+  if (options.isPasswordRequired || password) {
+    if (
+      password.length < PASSWORD_MIN_LENGTH ||
+      countCharacterKinds(password) < 2
+    ) {
+      errors.password = ADMIN_PASSWORD_HELP_TEXT;
+    }
+  }
+
+  if (!phoneNumber) {
+    errors.phoneNumber = '휴대전화 번호를 정확히 입력해 주세요.';
+  }
+
+  // 이메일은 선택 항목이라 비어 있는 것 자체는 오류가 아니다(시안에 필수 표시가 없다).
+  if (email && !isValidEmail(email)) {
+    errors.email = '이메일 형식이 올바르지 않습니다.';
+  }
+
+  if (!ADMIN_ROLE_OPTIONS.some((option) => option.value === values.roleCode)) {
+    errors.roleCode = '역할을 선택해 주세요.';
+  }
+
+  const menuCodes = values.menuCodes.filter((code) =>
+    ADMIN_MENU_OPTIONS.some((option) => option.value === code)
+  );
+  if (menuCodes.length === 0) {
+    errors.menuCodes = '메뉴를 1개 이상 선택해 주세요.';
+  }
+
+  return { password, phoneNumber, email, roleCode: values.roleCode, menuCodes };
+}
+
+/** 시안 ADM_ADM_102_p — 등록 검증(이름·ID 포함). */
+export function validateAdminMemberCreate(
+  values: AdminMemberCreateValues
+): ValidationResult<AdminMemberCreateValues> {
+  const errors: AdminMemberFormErrors = {};
+
+  const name = values.name.trim();
+  if (!name) {
+    errors.name = '이름을 입력해 주세요.';
+  } else if (name.length > NAME_MAX_LENGTH) {
+    errors.name = `이름은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`;
+  }
+
+  const loginIdError = validateAdminLoginId(values.loginId);
+  if (loginIdError) {
+    errors.loginId = loginIdError;
+  }
+
+  const editable = validateEditableValues(values, errors, {
+    isPasswordRequired: true,
+  });
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+
+  return {
+    ok: true,
+    values: { ...editable, name, loginId: values.loginId.trim() },
+  };
+}
+
+/** 시안 ADM_ADM_103_p — 수정 검증. 이름·ID는 읽기 전용이라 검증 대상이 아니다(파일 상단 주석). */
+export function validateAdminMemberUpdate(
+  values: AdminMemberEditableValues
+): ValidationResult<AdminMemberEditableValues> {
+  const errors: AdminMemberFormErrors = {};
+
+  const editable = validateEditableValues(values, errors, {
+    isPasswordRequired: false,
+  });
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+
+  return { ok: true, values: editable };
+}
 
lib/domain/admin-member-query.ts (added)
+++ lib/domain/admin-member-query.ts
@@ -0,0 +1,167 @@
+/**
+ * 관리자 회원 목록의 검색·정렬·페이징 조건 — 순수 규칙(허용 값·기본값·URL 직렬화)만 담는다.
+ * next/react 의존이 없다(`URLSearchParams`는 서버·브라우저 양쪽에서 쓸 수 있는 표준 Web API).
+ *
+ * `app/(protected)/(basic)/admins/page.tsx`가 `searchParams`를 `parseAdminMemberQuery`로
+ * 정규화하는 지점이자 단일 진실원천이며, 검색바·툴바·페이지네이션은 모두 `buildAdminMemberHref`로
+ * 같은 규칙에 따라 URL을 만들어 파라미터 이름·기본값이 여러 파일에 흩어져 드리프트하는 것을 막는다.
+ *
+ * 학생 회원 목록(`student-member-query.ts`)과 구조가 같지만 파일을 합치지 않았다 — 두 화면의
+ * 검색 대상·정렬 기준·기본값이 각자의 기획(ADM_MEM_101 / ADM_ADM_101)을 따라 서로 다르게
+ * 움직이고, 한쪽 기획 변경이 다른 화면을 건드리게 되는 결합이 공통화의 이득보다 크다.
+ */
+
+/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */
+export const ADMIN_MEMBERS_PATH = '/admins';
+
+/**
+ * 검색 대상 — 시안(ADM_ADM_101 ①)은 회원명/ID/휴대전화번호 셋을 요구하지만 **휴대전화번호는 뺐다.**
+ *
+ * 두 가지 이유가 겹친다:
+ * 1. 목록 응답에 휴대전화번호가 없다(`MngrAdminMapper.xml`의 select 목록에도, `MngrAdminVo`에도
+ *    없다). 화면에 보이지도 않는 값으로 걸러낸 결과는 사용자가 검증할 수 없다.
+ * 2. 백엔드의 휴대전화번호 검색 분기(`searchCondition == "3"`)는 `USER_TELNO` 컬럼을 참조하는데,
+ *    이는 학생 목록 쿼리(TB_COM_USER 대상)에서 그대로 복사된 것으로 보인다 — 관리자 테이블
+ *    (TB_ADM_USER)의 select 목록에는 그런 컬럼이 없다. 컬럼이 실제로 없다면 이 검색은 SQL 오류로
+ *    떨어져 화면 전체가 에러 경계로 넘어간다.
+ *
+ * 백엔드가 휴대전화번호를 응답에 포함하면 여기와 `admin-member-repository.ts`의 필터에 함께
+ * 추가한다.
+ */
+export type AdminMemberSearchField = 'name' | 'loginId';
+
+export const ADMIN_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
+  value: AdminMemberSearchField;
+  label: string;
+}> = [
+  { value: 'name', label: '회원명' },
+  { value: 'loginId', label: 'ID' },
+];
+
+/**
+ * 정렬 기준 — 시안의 select는 "가입일순"이지만 관리자 회원의 해당 값은 생성일이라 이름을 맞췄다.
+ *
+ * `createdAt`은 백엔드가 응답에 담아 주지 않는데도 정렬이 가능한 이유는, 목록 SQL의 고정
+ * `ORDER BY rnum DESC`가 곧 생성일 최신순이기 때문이다(rnum은 `frst_reg_dt` 오름차순 행번호).
+ * 즉 이 정렬은 "백엔드가 준 순서를 그대로 쓴다"는 뜻이다 — 자세한 근거는 Repository 주석 참조.
+ */
+export type AdminMemberSortOption = 'createdAt' | 'name';
+
+export const ADMIN_MEMBER_SORT_OPTIONS: ReadonlyArray<{
+  value: AdminMemberSortOption;
+  label: string;
+}> = [
+  { value: 'createdAt', label: '생성일순' },
+  { value: 'name', label: '이름순' },
+];
+
+export const ADMIN_MEMBER_PAGE_SIZE_OPTIONS = [10, 30, 50] as const;
+export type AdminMemberPageSize =
+  (typeof ADMIN_MEMBER_PAGE_SIZE_OPTIONS)[number];
+
+export const DEFAULT_ADMIN_MEMBER_SEARCH_FIELD: AdminMemberSearchField = 'name';
+export const DEFAULT_ADMIN_MEMBER_SORT: AdminMemberSortOption = 'createdAt';
+export const DEFAULT_ADMIN_MEMBER_PAGE_SIZE: AdminMemberPageSize = 10;
+const DEFAULT_PAGE = 1;
+const MAX_KEYWORD_LENGTH = 100;
+
+export type AdminMemberQuery = {
+  searchField: AdminMemberSearchField;
+  keyword: string;
+  sort: AdminMemberSortOption;
+  page: number;
+  pageSize: AdminMemberPageSize;
+};
+
+/** Next.js `page.tsx`의 `searchParams`가 리졸브하는 값 형태를 그대로 옮긴 구조 타입 —
+ *  next 패키지를 import하지 않고도 같은 shape을 표현해 domain 계층의 무의존 규칙을 지킨다. */
+type RawSearchParams = Record<string, string | string[] | undefined>;
+
+function readParam(params: RawSearchParams, key: string): string | undefined {
+  const value = params[key];
+  return Array.isArray(value) ? value[0] : value;
+}
+
+function isAdminMemberSearchField(
+  value: string | undefined
+): value is AdminMemberSearchField {
+  return (
+    value !== undefined &&
+    ADMIN_MEMBER_SEARCH_FIELD_OPTIONS.some((option) => option.value === value)
+  );
+}
+
+function isAdminMemberSortOption(
+  value: string | undefined
+): value is AdminMemberSortOption {
+  return (
+    value !== undefined &&
+    ADMIN_MEMBER_SORT_OPTIONS.some((option) => option.value === value)
+  );
+}
+
+function isAdminMemberPageSize(value: number): value is AdminMemberPageSize {
+  return (ADMIN_MEMBER_PAGE_SIZE_OPTIONS as readonly number[]).includes(value);
+}
+
+/**
+ * URL의 searchParams를 검증된 `AdminMemberQuery`로 정규화한다. 값이 없거나 허용 목록을
+ * 벗어나면 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라
+ * 신뢰하지 않는다.
+ */
+export function parseAdminMemberQuery(
+  searchParams: RawSearchParams
+): AdminMemberQuery {
+  const searchFieldRaw = readParam(searchParams, 'searchField');
+  const sortRaw = readParam(searchParams, 'sort');
+  const keywordRaw = readParam(searchParams, 'keyword');
+  const pageRaw = Number(readParam(searchParams, 'page'));
+  const pageSizeRaw = Number(readParam(searchParams, 'pageSize'));
+
+  return {
+    searchField: isAdminMemberSearchField(searchFieldRaw)
+      ? searchFieldRaw
+      : DEFAULT_ADMIN_MEMBER_SEARCH_FIELD,
+    keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH),
+    sort: isAdminMemberSortOption(sortRaw)
+      ? sortRaw
+      : DEFAULT_ADMIN_MEMBER_SORT,
+    page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE,
+    pageSize: isAdminMemberPageSize(pageSizeRaw)
+      ? pageSizeRaw
+      : DEFAULT_ADMIN_MEMBER_PAGE_SIZE,
+  };
+}
+
+/**
+ * `AdminMemberQuery`(+ 부분 override)를 `/admins` 링크로 직렬화한다. `parseAdminMemberQuery`의
+ * 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다.
+ */
+export function buildAdminMemberHref(
+  query: AdminMemberQuery,
+  overrides: Partial<AdminMemberQuery> = {}
+): string {
+  const merged = { ...query, ...overrides };
+  const params = new URLSearchParams();
+
+  if (merged.searchField !== DEFAULT_ADMIN_MEMBER_SEARCH_FIELD) {
+    params.set('searchField', merged.searchField);
+  }
+  if (merged.keyword) {
+    params.set('keyword', merged.keyword);
+  }
+  if (merged.sort !== DEFAULT_ADMIN_MEMBER_SORT) {
+    params.set('sort', merged.sort);
+  }
+  if (merged.pageSize !== DEFAULT_ADMIN_MEMBER_PAGE_SIZE) {
+    params.set('pageSize', String(merged.pageSize));
+  }
+  if (merged.page !== DEFAULT_PAGE) {
+    params.set('page', String(merged.page));
+  }
+
+  const queryString = params.toString();
+  return queryString
+    ? `${ADMIN_MEMBERS_PATH}?${queryString}`
+    : ADMIN_MEMBERS_PATH;
+}
 
lib/domain/admin-member.ts (added)
+++ lib/domain/admin-member.ts
@@ -0,0 +1,141 @@
+/**
+ * 관리자 회원 도메인 타입 — 순수 데이터 표현, 외부 의존 없음.
+ *
+ * 데이터 출처는 백엔드(edupay-backend)의 `GET /api/v1/mngr/admin/pagination`이며,
+ * `lib/data/repositories/admin-member-repository.ts`가 응답을 이 타입으로 매핑한다.
+ *
+ * **`null`의 의미는 "백엔드가 아직 주지 않는 항목"이다.** 목록 응답 VO(MngrAdminVo)가 담는 값은
+ * 식별자·이름·역할·계정상태 계열뿐이라 시안(ADM_ADM_101)의 휴대전화번호·이메일·생성일은 전부
+ * `null`로 채워지고 화면에서 `-`로 표시된다. 화면 컬럼은 그대로 유지한다 — 백엔드가 필드를
+ * 추가하면 Repository의 매핑만 늘리면 값이 그대로 채워진다.
+ *
+ * 등록/수정/삭제 API가 아직 없어 그 경로는 mock으로 동작한다(`lib/data/mock/admin-member-store.ts`).
+ * mock이 만들어 낸 행만 휴대전화번호·이메일·메뉴 권한 값을 실제로 갖는다.
+ */
+
+/** 역할 코드 — 백엔드 `admRoleCd`. 코드 체계를 백엔드가 확정하지 않아 문자열로 다룬다. */
+export type AdminRoleCode = string;
+
+export type AdminMember = {
+  /** 내부 식별자 — 백엔드 `admUserId`. 목록 행의 key이자 수정/삭제의 입력값이다. */
+  id: string;
+  /** 백엔드 `admNm`. */
+  name: string;
+  /** 백엔드 `loginId`. 등록 후에는 변경할 수 없다(시안 ADM_ADM_103_p ①). */
+  loginId: string;
+  /** 백엔드 목록 응답에 없다 — mock으로 등록한 행에만 값이 있다. */
+  phoneNumber: string | null;
+  /** 백엔드 목록 응답에 없다 — mock으로 등록한 행에만 값이 있다. */
+  email: string | null;
+  /** 백엔드 `admRoleCd`. 표기용 이름은 `formatAdminRoleLabel`이 만든다. */
+  roleCode: AdminRoleCode;
+  /**
+   * 접근 가능 메뉴 코드 목록(시안 ADM_ADM_102_p ③ "메뉴 선택", 다중 선택).
+   * 백엔드 목록 응답에 없어 mock으로 등록/수정한 행에만 값이 있다 — 빈 배열은 "미지정"이다.
+   */
+  menuCodes: string[];
+  /** ISO 형식(YYYY-MM-DD) 문자열. 백엔드 목록 응답에 없다(정렬에만 쓰이고 select되지 않는다). */
+  createdAt: string | null;
+  /** 백엔드 `acctLockYn` — 계정 잠김 여부. 시안의 "잠김여부"는 이 값의 반대(활성/비활성)다. */
+  isLocked: boolean | null;
+  /** 백엔드 `useYn` — 사용 여부. */
+  isActive: boolean | null;
+  /** 백엔드 `loginFailCnt` — 로그인 연속 실패 횟수. 잠김 사유 판단용이라 목록에는 노출하지 않는다. */
+  loginFailCount: number | null;
+};
+
+/** 값이 없는 항목의 화면 표기. 표·팝업이 같은 문자를 쓰도록 여기 한 곳에 둔다. */
+export const EMPTY_FIELD_PLACEHOLDER = '-';
+
+/** 값이 없으면 `-`, 있으면 문자열로 표기한다. */
+export function formatOptionalValue(value: string | number | null): string {
+  return value === null ? EMPTY_FIELD_PLACEHOLDER : String(value);
+}
+
+/**
+ * 역할 코드 → 화면 표기 이름.
+ *
+ * **백엔드에 역할 코드 목록을 주는 API도, 코드 상수도 없다** — `TB_ADM_USER.ADM_ROLE_CD`를
+ * 그대로 내려줄 뿐이고 코드값의 정의는 어디에도 없다(백엔드 저장소 전체 검색으로 확인).
+ * 시안(ADM_ADM_101)이 요구하는 표기는 "최고관리자/일반관리자" 둘이고, 실제로 관측된 코드는
+ * 로그인 토큰의 `admRoleCd` 클레임에서 본 `ROLE_SYSTEM` 하나뿐이다(`lib/domain/admin-user.ts`).
+ *
+ * 그래서 아래 표는 **잠정 매핑**이며, 표에 없는 코드는 임의의 이름을 지어내지 않고 코드를 그대로
+ * 노출한다 — 잘못된 역할 이름을 보여주는 것보다 낯선 코드를 보여주는 편이 낫다(권한 화면이라
+ * 표기 오류의 대가가 크다). 시안의 "역할 선택" 목록은 원래 [시스템관리 > 역할관리]가 등록한
+ * 역할을 가져와야 하는데 그 화면도 API도 아직 없어, 지금은 이 표가 곧 선택지다.
+ *
+ * 백엔드가 역할 코드 API를 내면 이 표를 지우고 그 응답으로 대체한다.
+ */
+const ADMIN_ROLE_LABEL_BY_CODE: Record<string, string> = {
+  ROLE_SYSTEM: '최고관리자',
+  ROLE_ADMIN: '일반관리자',
+};
+
+export const ADMIN_ROLE_OPTIONS: ReadonlyArray<{
+  value: AdminRoleCode;
+  label: string;
+}> = Object.entries(ADMIN_ROLE_LABEL_BY_CODE).map(([value, label]) => ({
+  value,
+  label,
+}));
+
+/** 시안의 "역할 선택" 기본값 — 잠정 매핑의 첫 항목(최고관리자). */
+export const DEFAULT_ADMIN_ROLE_CODE: AdminRoleCode = 'ROLE_SYSTEM';
+
+/** 역할 코드를 화면 표기로 바꾼다. 모르는 코드는 코드 그대로 노출한다(위 주석 참조). */
+export function formatAdminRoleLabel(roleCode: AdminRoleCode): string {
+  return ADMIN_ROLE_LABEL_BY_CODE[roleCode] ?? roleCode;
+}
+
+/**
+ * 시안(ADM_ADM_103_p ③)의 "잠김여부" 표기. 항목은 활성/비활성이고, **잠기지 않은 계정이 활성**이다
+ * — 백엔드 필드가 `acctLockYn`(잠김 여부)이라 의미가 뒤집혀 있어 이 변환을 한 곳에 모아 둔다.
+ */
+export const ADMIN_LOCK_STATUS_OPTIONS: ReadonlyArray<{
+  value: string;
+  label: string;
+}> = [
+  { value: 'false', label: '활성' },
+  { value: 'true', label: '비활성' },
+];
+
+export function formatAdminLockStatusLabel(isLocked: boolean | null): string {
+  if (isLocked === null) {
+    return EMPTY_FIELD_PLACEHOLDER;
+  }
+  return isLocked ? '비활성' : '활성';
+}
+
+/**
+ * 접근 가능 메뉴 목록(시안 ADM_ADM_102_p ③ "메뉴 선택").
+ *
+ * **백엔드에 관리자 메뉴 API가 없어 mock 카탈로그다.** 값은 시안 사이드바의 대메뉴 구성을 그대로
+ * 옮겼다 — 실제 메뉴 API가 생기면 이 상수를 지우고 그 응답으로 대체한다.
+ */
+export const ADMIN_MENU_OPTIONS: ReadonlyArray<{
+  value: string;
+  label: string;
+}> = [
+  { value: 'MEMBER', label: '회원정보관리' },
+  { value: 'ADMIN', label: '관리자정보관리' },
+  { value: 'DECO_ITEM', label: '꾸미기아이템관리' },
+  { value: 'BOARD', label: '게시판관리(고객센터)' },
+  { value: 'POINT', label: '포인트관리' },
+  { value: 'CONTENTS', label: '콘텐츠관리' },
+  { value: 'SYSTEM', label: '시스템관리' },
+];
+
+/** 선택된 메뉴 코드를 화면 표기로 바꾼다. 하나도 없으면 `-`. */
+export function formatAdminMenuLabels(menuCodes: string[]): string {
+  if (menuCodes.length === 0) {
+    return EMPTY_FIELD_PLACEHOLDER;
+  }
+
+  return menuCodes
+    .map(
+      (code) =>
+        ADMIN_MENU_OPTIONS.find((option) => option.value === code)?.label ?? code
+    )
+    .join(', ');
+}
Add a comment
List