임동욱 임동욱 08-19
feat: 관리자 회원 관리를 최신 백엔드에 맞춰 완성 + 등록·수정 팝업 시안 구현
## 백엔드 최신화 반영 (edupay-backend develop 4d98756)

- **수정이 동작하지 않던 것을 고쳤다.** `PUT /mngr/admin`에 form으로 보내고 있었으나 백엔드는
  `PUT /mngr/admin/{admUserId}` + `@RequestBody`(JSON)다. 등록만 `@ParameterObject`(form)라
  같은 도메인인데 본문 형식이 갈린다 — 두 함수 주석에 그 이유를 적어 두었다.
- 삭제를 mock 오버레이에서 실 API(`DELETE /{admUserId}`, soft delete)로 옮기고
  `lib/data/mock/admin-member-store.ts`를 지웠다.
- 엑셀 다운로드 API가 생겨 `/admins/excel` 라우트 핸들러로 중계하고 버튼을 열었다.
- 목록 응답에 `admTelNo`·`admEmlAddr`·`frstRegDtStr`이 추가돼 생성일 매핑을 넣었다 —
  휴대전화번호·이메일·생성일 세 열의 `-`가 실값으로 바뀐다.
- 검색 분기가 `USER_TELNO`→`ADM_TEL_NO`로 고쳐져 휴대전화번호 검색을 되살렸다(시안 ①의 셋).
- 수정 시 비밀번호 필수 제약을 없앴다 — UPDATE의 `LOGIN_PW`가 빈 값이면 SET 절에서 빠지도록
  바뀌어 "바꾸지 않음"을 표현할 수 있게 됐다.
- `totalCount`는 여전히 `list.size()`라 전체를 받아 서버에서 세는 방식은 유지한다.

## 등록·수정 팝업 (시안 5227:2035 / 5227:3351)

@fox로 다시 그렸다 — modal·input·btn·form-label·form-phonenumber·select·chip-area·toggle이
Figma 인스턴스 이름과 그대로 대응한다. 확인한 값: 모달 400, 입력 40, 칩 28, 푸터 버튼 80×40,
필드 간격 32.

저장 버튼은 모달 actions 슬롯이라 폼 밖에 그려진다. FoxButton에 네이티브 `form` 속성이 없어
폼을 ref로 잡아 `requestSubmit()`을 부른다. @fox 제어 위젯(전화번호·역할·메뉴)은 값이
FormData에 실리지 않아 hidden input으로 함께 낸다.

이메일은 두 시안이 다르다 — 등록은 `[아이디] @ [도메인]`, 수정은 단일 입력이라 그대로 따랐다.
등록 쪽은 `FoxEmail`을 쓰지 않는다: 잠긴 '직접입력' 상자를 하나 더 그리고 칸마다 240px 최소폭이
박혀 320px 안에서 세 줄로 쌓인다(사용자 확정).

메뉴 선택은 시안대로 그리되 **필수 검증을 걸지 않는다**(사용자 확정) — 백엔드에 관리자별 메뉴
권한이 테이블·VO·SQL 어디에도 없어 고른 값이 저장되지 않는다.

삭제 확인 얼럿의 버튼도 무스타일 `components/ui/button`에서 FoxButton으로 옮겼다.

Co-Authored-By: Claude Opus 5 
@7e217e95b8a5c55627bf015f986593de2689a734
app/(protected)/(basic)/admins/_actions.ts
--- app/(protected)/(basic)/admins/_actions.ts
+++ app/(protected)/(basic)/admins/_actions.ts
@@ -11,7 +11,7 @@
 } from '@/lib/data/repositories/admin-member-repository';
 import { ADMIN_MENU_OPTIONS } from '@/lib/domain/admin-member';
 import {
-  joinPhoneNumber,
+  formatPhoneNumber,
   validateAdminLoginId,
   validateAdminMemberCreate,
   validateAdminMemberUpdate,
@@ -58,15 +58,11 @@
 }
 
 /**
- * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 시안대로 3칸으로 나뉘어 오므로 여기서
- * 하나의 값으로 합친다 — 형식이 어긋나면 빈 문자열이 되어 검증에서 걸린다.
+ * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 숫자만 담겨 오므로 여기서 저장 형식으로
+ * 바꾼다 — 자릿수가 어긋나면 빈 문자열이 되어 검증에서 걸린다.
  */
 function readEditableValues(formData: FormData): AdminMemberEditableValues {
-  const phoneNumber = joinPhoneNumber([
-    readString(formData, 'phoneNumber1'),
-    readString(formData, 'phoneNumber2'),
-    readString(formData, 'phoneNumber3'),
-  ]);
+  const phoneNumber = formatPhoneNumber(readString(formData, 'phoneNumber'));
 
   return {
     password: readString(formData, 'password'),
app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
@@ -1,11 +1,21 @@
 '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 {
+  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,
@@ -16,19 +26,27 @@
   createAdminMemberAction,
   type LoginIdCheckResult,
 } from '../_actions';
-import {
-  AdminMemberFormFields,
-  FieldError,
-} from './admin-member-form-fields';
+import { AdminMemberFormFields } from './admin-member-form-fields';
+import styles from './admin-member-modal.module.scss';
 
 interface AdminMemberCreateModalProps {
   onClose: () => void;
 }
 
-const FORM_ID = 'admin-member-create-form';
+/** 목록에 없는 주소를 위해 도메인 칸을 입력으로 바꾸는 값. 실제 도메인과 겹치지 않게 잡았다. */
+const CUSTOM_EMAIL_DOMAIN = '__custom__';
+
+/** 시안의 도메인 목록. 맨 아래 "직접입력"은 같은 칸을 입력으로 바꾼다(상자를 늘리지 않는다). */
+const EMAIL_DOMAIN_OPTIONS = [
+  'naver.com',
+  'gmail.com',
+  'daum.net',
+  'hanmail.net',
+  'nate.com',
+];
 
 /**
- * 관리자 등록 팝업(시안 ADM_ADM_102_p).
+ * 관리자 등록 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_102_p (5227:2035)
  *
  * **ID는 중복 확인을 통과해야 저장할 수 있다**(시안 ①). 확인은 등록 폼과 별개의 서버 호출인데,
  * 폼 안에 폼을 중첩할 수 없어 `useActionState` 대신 값을 직접 넘기는 Server Action
@@ -36,12 +54,14 @@
  * "확인에 성공한 ID"와 "지금 입력창의 값"이 같을 때만 통과로 본다 — 확인만 받아 두고 다른 ID로
  * 바꿔 저장하는 경로를 막기 위해서다. 그래도 최종 방어선은 Server Action의 재확인이다.
  *
- * 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다 — 네이티브 HTML의 `form`
- * 속성은 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 form을
- * 제출한다.
+ * 저장 버튼은 `FoxModal`의 actions 슬롯에 있어 폼 **바깥**에 그려진다. `FoxButton`에는 네이티브
+ * `form` 속성이 없으므로 폼을 ref로 잡아 `requestSubmit()`을 부른다 — `useActionState`의 폼
+ * 액션이 그대로 타는 정식 제출이라 검증·상태 흐름이 동일하다.
  *
- * 비밀번호는 형식만 검증하고 저장되지 않는다 — 백엔드에 등록 API가 없어 지금은 mock 저장소가
- * 받는데, 평문 비밀번호를 메모리에 들고 있지 않기로 했기 때문이다(Repository·mock 주석 참조).
+ * @fox의 제어 위젯(전화번호·이메일·역할·메뉴)은 값이 FormData에 실리지 않아 이 컴포넌트가 값을
+ * 들고 hidden input으로 제출한다(`AdminMemberFormFields` 주석 참조).
+ *
+ * 비밀번호는 형식만 검증하고 백엔드가 SHA-256으로 암호화해 저장한다(`MngrAdminServiceImpl`).
  */
 export function AdminMemberCreateModal({
   onClose,
@@ -51,12 +71,20 @@
     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') {
@@ -66,99 +94,166 @@
   }, [state, showToast, onClose]);
 
   const isLoginIdVerified =
-    checkResult.status === 'available' &&
-    checkResult.loginId === loginId.trim();
-
-  function handleCheckLoginId() {
-    startChecking(async () => {
-      setCheckResult(await checkAdminLoginId(loginId));
-    });
-  }
+    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 (
-    <Modal
-      title="관리자 등록"
+    <FoxModal
+      open
       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>
-        </>
+      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 id={FORM_ID} action={formAction} className="flex flex-col gap-4">
-        <p className="text-right text-body-sm text-danger">
-          * 는 필수 항목입니다.
-        </p>
+      <form ref={formRef} action={formAction} className={styles.formList}>
+        <FoxInput
+          size="md"
+          name="name"
+          label="이름"
+          requirement="required"
+          placeholder="이름을 입력하세요."
+          message={errors.name}
+          invalid={Boolean(errors.name)}
+        />
 
-        <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"
+        <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)
+            }
           />
-        </Field>
-        <p className="text-body-sm text-foreground-muted">
-          {ADMIN_PASSWORD_HELP_TEXT}
-        </p>
-        <FieldError message={errors.password} />
+          <FoxButton
+            type="secondary"
+            size="md"
+            fullWidth
+            label="중복 확인"
+            loading={isChecking}
+            disabled={loginId.trim().length === 0}
+            onAction={() =>
+              startChecking(async () => {
+                setCheckResult(await checkAdminLoginId(loginId));
+              })
+            }
+          />
+        </div>
 
-        <AdminMemberFormFields errors={errors} />
+        <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 className="text-body-sm text-danger">{state.message}</p>
+          <p role="alert">{state.message}</p>
         )}
       </form>
-    </Modal>
+    </FoxModal>
   );
 }
app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
@@ -1,49 +1,49 @@
 '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 { useActionState, useEffect, useRef, useState } 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 { FoxModal } from '@fox/core/components/fox-modal';
+import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
+import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
 import { useFeedback } from '@/app/_hooks/use-feedback';
-import {
-  ADMIN_LOCK_STATUS_OPTIONS,
-  type AdminMember,
-} from '@/lib/domain/admin-member';
+import { DEFAULT_ADMIN_ROLE_CODE, type AdminMember } from '@/lib/domain/admin-member';
 import {
   ADMIN_PASSWORD_HELP_TEXT,
   INITIAL_ADMIN_MEMBER_FORM_STATE,
+  toPhoneDigits,
 } from '@/lib/domain/admin-member-form';
 import { updateAdminMemberAction } from '../_actions';
-import {
-  AdminMemberFormFields,
-  FieldError,
-} from './admin-member-form-fields';
+import { AdminMemberFormFields } from './admin-member-form-fields';
+import styles from './admin-member-modal.module.scss';
 
 interface AdminMemberEditModalProps {
   member: AdminMember;
   onClose: () => void;
 }
 
-const FORM_ID = 'admin-member-edit-form';
-
 /**
- * 관리자 수정 팝업(시안 ADM_ADM_103_p).
+ * 관리자 수정 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_103_p (5227:3351)
  *
- * 이름·ID는 읽기 전용이다(시안 ①). 다만 화면의 readOnly는 표시일 뿐이라 Server Action은 두 값을
- * 아예 반영하지 않는다 — 직접 POST로 다른 값을 보내도 바뀌지 않는다.
+ * 이름·ID는 읽기 전용이다(시안 ①). 화면의 readOnly는 표시일 뿐이지만 Server Action도 두 값을 아예
+ * 읽지 않고, 백엔드 수정 API 역시 받지 않는다(`MngrAdminUpdateRequestVo`에 필드가 없다) — 세 겹이
+ * 같은 말을 한다.
  *
- * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있다**(시안 ②). 다만 비워 둔 채로는 수정이
- * 저장되지 않는다 — 백엔드 UPDATE가 비밀번호를 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인
- * 불가가 되기 때문이다(`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석). 비활성 input은 FormData에
- * 실리지 않으므로 버튼을 누르지 않고 제출하면 그 안내가 그대로 뜬다.
+ * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있고, 비워 두면 바꾸지 않는다**(시안 ②).
+ * 백엔드 UPDATE의 `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어
+ * 빈 값은 SET 절에서 빠진다. (예전에는 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 불가가
+ * 됐고, 그래서 수정 시 비밀번호를 필수로 막아 두었다 — 그 제약은 이제 없다.)
  *
- * 휴대전화번호·이메일은 항상 빈 칸으로 시작한다 — 백엔드가 두 값을 되돌려주지 않아 기존 값을
- * 채울 수단이 없다(Repository 주석). 입력한 값으로 덮어쓰는 동작이다.
+ * 이메일은 시안이 **단일 입력**이다(등록 팝업의 도메인 셀렉트가 없다). 두 시안의 차이를 그대로
+ * 따른다 — 수정은 이미 있는 주소를 고치는 자리라 도메인만 고르는 형태가 맞지 않는다.
  *
- * "잠김여부"는 활성/비활성으로 표기되지만 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가
- * 뒤집혀 있다. 그 변환은 도메인(`ADMIN_LOCK_STATUS_OPTIONS`)이 한 곳에서 책임진다.
+ * 휴대전화번호·이메일·역할은 이제 목록 응답에 담겨 오므로 **기존 값으로 채운다**(백엔드가
+ * `ADM_TEL_NO`·`ADM_EML_ADDR`을 select 목록에 넣었다).
+ *
+ * "잠김여부"는 시안이 활성/비활성 토글이고 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가
+ * 뒤집혀 있다 — 켜짐이 곧 "잠기지 않음"이다.
  */
 export function AdminMemberEditModal({
   member,
@@ -54,8 +54,18 @@
     updateAdminMemberAction,
     INITIAL_ADMIN_MEMBER_FORM_STATE
   );
+  const formRef = useRef<HTMLFormElement>(null);
 
   const [isPasswordEditable, setIsPasswordEditable] = useState(false);
+  const [phoneNumber, setPhoneNumber] = useState(
+    toPhoneDigits(member.phoneNumber)
+  );
+  const [email, setEmail] = useState(member.email ?? '');
+  const [roleCode, setRoleCode] = useState(
+    member.roleCode || DEFAULT_ADMIN_ROLE_CODE
+  );
+  const [menuCodes, setMenuCodes] = useState<string[]>(member.menuCodes);
+  const [isActive, setIsActive] = useState(!(member.isLocked ?? false));
 
   useEffect(() => {
     if (state.status === 'success') {
@@ -67,88 +77,112 @@
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
   return (
-    <Modal
-      title="관리자 수정"
+    <FoxModal
+      open
       onClose={onClose}
-      footer={
-        <>
-          <Button type="button" variant="ghost" onClick={onClose}>
-            취소
-          </Button>
-          <Button
-            type="submit"
-            form={FORM_ID}
-            variant="primary"
-            disabled={isPending}
-          >
-            {isPending ? '수정 중...' : '수정'}
-          </Button>
-        </>
+      size="sm"
+      title="관리자 수정"
+      actions={
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label="저장"
+            loading={isPending}
+            onAction={() => formRef.current?.requestSubmit()}
+          />
+        </FoxButtonGroup>
       }
     >
-      <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
-        {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다 —
-            Server Action도 그 둘을 읽지 않으므로 위조해 보내도 반영되지 않는다. */}
+      <form ref={formRef} action={formAction} className={styles.formList}>
+        {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다. */}
         <input type="hidden" name="id" value={member.id} />
 
-        <p className="text-right text-body-sm text-danger">
-          * 는 필수 항목입니다.
-        </p>
+        <FoxInput
+          size="md"
+          label="이름"
+          requirement="required"
+          value={member.name}
+          readOnly
+        />
 
-        <Field label="이름">
-          <Input type="text" value={member.name} readOnly />
-        </Field>
+        <FoxInput
+          size="md"
+          label="ID"
+          requirement="required"
+          value={member.loginId}
+          readOnly
+        />
 
-        <Field label="ID">
-          <Input type="text" value={member.loginId} readOnly />
-        </Field>
+        <div className={styles.fieldWithAction}>
+          <FoxInput
+            size="md"
+            type="password"
+            name="password"
+            label="비밀번호"
+            requirement="required"
+            placeholder="비밀번호를 입력하세요."
+            autoComplete="new-password"
+            disabled={!isPasswordEditable}
+            message={
+              errors.password ??
+              (isPasswordEditable ? ADMIN_PASSWORD_HELP_TEXT : undefined)
+            }
+            messageIcon={
+              isPasswordEditable ? <FoxChatCenteredDotsIcon /> : undefined
+            }
+            invalid={Boolean(errors.password)}
+          />
+          <FoxButton
+            type="secondary"
+            size="md"
+            fullWidth
+            label="비밀번호 변경"
+            disabled={isPasswordEditable}
+            onAction={() => setIsPasswordEditable(true)}
+          />
+        </div>
 
-        <Field label="비밀번호">
-          <div className="flex items-center gap-2">
-            <Input
-              type="password"
-              name="password"
-              placeholder="비밀번호를 입력하세요."
-              autoComplete="new-password"
-              disabled={!isPasswordEditable}
+        <AdminMemberFormFields
+          phoneNumber={phoneNumber}
+          onPhoneNumberChange={setPhoneNumber}
+          roleCode={roleCode}
+          onRoleCodeChange={setRoleCode}
+          menuCodes={menuCodes}
+          onMenuCodesChange={setMenuCodes}
+          errors={errors}
+          emailField={
+            <FoxInput
+              size="md"
+              type="email"
+              name="email"
+              label="이메일"
+              placeholder="이메일을 입력하세요."
+              value={email}
+              onChange={setEmail}
+              message={errors.email}
+              invalid={Boolean(errors.email)}
             />
-            <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>
+        <div className={styles.field}>
+          <FoxFormLabel as="span">잠김여부</FoxFormLabel>
+          <FoxToggleSwitch
+            size="md"
+            label={isActive ? '활성' : '비활성'}
+            checked={isActive}
+            onChange={setIsActive}
+          />
+        </div>
+        {/* 화면은 활성(잠기지 않음)을 보여주고 서버는 잠김 여부를 받는다 — 여기서 뒤집는다. */}
+        <input type="hidden" name="isLocked" value={String(!isActive)} />
 
         {state.status === 'error' && state.message && (
-          <p className="text-body-sm text-danger">{state.message}</p>
+          <p role="alert">{state.message}</p>
         )}
       </form>
-    </Modal>
+    </FoxModal>
   );
 }
app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
@@ -1,115 +1,135 @@
 'use client';
 
-import { Field } from '@/components/ui/field';
-import { Input } from '@/components/ui/input';
-import { Select } from '@/components/ui/select';
+import { useId, type ReactNode } from 'react';
+import { FoxChip } from '@fox/core/components/fox-chip';
+import { FoxChipArea } from '@fox/core/components/fox-chip-area';
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
+import { FoxPhoneNumber } from '@fox/core/components/fox-phone-number';
+import { FoxSelect } from '@fox/core/components/fox-select';
 import {
+  ADMIN_MENU_OPTIONS,
   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';
+import type { AdminMemberFormErrors } from '@/lib/domain/admin-member-form';
+import styles from './admin-member-modal.module.scss';
 
 interface AdminMemberFormFieldsProps {
-  /** 수정 팝업의 기존 값. 등록 팝업은 넘기지 않는다(빈 폼). */
-  member?: AdminMember;
+  /** 숫자만 담긴 휴대전화번호. 하이픈은 화면이 그리고 값에는 넣지 않는다. */
+  phoneNumber: string;
+  onPhoneNumberChange: (phoneNumber: string) => void;
+  roleCode: string;
+  onRoleCodeChange: (roleCode: string) => void;
+  menuCodes: string[];
+  onMenuCodesChange: (menuCodes: string[]) => void;
+  /**
+   * 휴대전화 번호와 역할 선택 **사이**에 들어가는 이메일 칸. 두 시안이 이 자리에 서로 다른 것을
+   * 놓아(등록은 도메인 셀렉트가 붙은 form-email, 수정은 단일 input) 슬롯으로 받는다 — 자리 순서는
+   * 두 시안이 같으므로 여기가 갖고, 무엇을 놓을지는 각 팝업이 정한다.
+   */
+  emailField: ReactNode;
   errors: AdminMemberFormErrors;
 }
 
 /**
- * 등록·수정 팝업이 공유하는 입력 항목 — 휴대전화 번호 / 이메일 / 역할 선택 / 메뉴 선택.
- * 두 시안(ADM_ADM_102_p ②③ / 103_p ②)에서 규칙이 같은 부분만 모았다. 서로 다른 부분
- * (이름·ID의 편집 가능 여부, 비밀번호 입력 방식, 잠김여부)은 각 팝업이 직접 그린다.
+ * 등록·수정 팝업이 공유하는 입력 항목 — 휴대전화 번호 / (이메일 슬롯) / 역할 선택 / 메뉴 선택.
+ * 두 시안(ADM_ADM_102_p 5227:2035 / ADM_ADM_103_p 5227:3351)에서 자리와 순서가 같은 부분이다.
+ * 이름·ID·비밀번호·잠김여부는 두 시안이 서로 달라 각 팝업이 직접 그린다.
  *
- * 휴대전화 번호는 시안대로 3칸으로 나눠 받고 Server Action이 하나로 합친다
- * (`joinPhoneNumber`) — 칸을 나누는 것은 화면 표현이고, 저장 형식은 도메인이 정한다.
+ * 세 칸 모두 @fox의 제어 위젯이라 값이 FormData에 실리지 않는다(`FoxSelect`는 네이티브 select가
+ * 아니라 버튼+리스트박스, `FoxPhoneNumber`·칩도 마찬가지다). 그래서 값은 호출부가 들고, 제출용
+ * hidden input을 여기서 함께 낸다 — 폼 제출 규약(Server Action + FormData)을 그대로 두기 위해서다.
  *
- * ⚠️ **메뉴 선택은 아직 저장되지 않는다.** 백엔드에 관리자별 메뉴 권한 개념이 없어 보낼 곳이
- * 없다(등록·수정 VO에 필드가 없고 `/api/v1/common/menu`는 개인 북마크용이다). 시안대로 자리와
- * 필수 검증은 유지하되 값은 Repository까지 가지 않는다 — 권한 API가 생기면 여기는 그대로 두고
- * Server Action이 값을 넘기기만 하면 된다.
+ * ⚠️ **메뉴 선택은 저장되지 않는다.** 백엔드에 관리자별 메뉴 권한이 테이블·VO·SQL 어디에도 없다
+ * (`/api/v1/common/menu`는 개인 북마크용이다). 시안대로 자리는 유지하되 **필수 검증은 걸지 않는다**
+ * (사용자 확정) — 저장되지도 않는 값 때문에 등록이 막히면 안 되기 때문이다. 권한 API가 생기면
+ * 여기는 그대로 두고 Server Action이 값을 넘기기만 하면 된다.
  */
 export function AdminMemberFormFields({
-  member,
+  phoneNumber,
+  onPhoneNumberChange,
+  roleCode,
+  onRoleCodeChange,
+  menuCodes,
+  onMenuCodesChange,
+  emailField,
   errors,
 }: AdminMemberFormFieldsProps) {
-  const phoneParts = splitPhoneNumber(member?.phoneNumber ?? null);
+  const phoneLabelId = useId();
+  const menuLabelId = useId();
+
+  const allMenuValues = ADMIN_MENU_OPTIONS.map((option) => option.value);
+  const isAllSelected = menuCodes.length === allMenuValues.length;
+
+  function toggleMenu(value: string) {
+    onMenuCodesChange(
+      menuCodes.includes(value)
+        ? menuCodes.filter((code) => code !== value)
+        : [...menuCodes, value]
+    );
+  }
 
   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="이메일을 입력하세요."
+      {/* 칸이 셋으로 나뉘어 있어 `<label htmlFor>`가 가리킬 대상이 하나가 아니다 — 이름 연결은
+          감싼 group이 aria-labelledby로 한다(FoxFormLabel의 `as="span"`이 그 용도다). */}
+      <div className={styles.field} role="group" aria-labelledby={phoneLabelId}>
+        <FoxFormLabel as="span" id={phoneLabelId} requirement="required">
+          휴대전화 번호
+        </FoxFormLabel>
+        <FoxPhoneNumber
+          type="unit"
+          value={phoneNumber}
+          onChange={onPhoneNumberChange}
+          message={errors.phoneNumber}
+          invalid={Boolean(errors.phoneNumber)}
         />
-      </Field>
-      <FieldError message={errors.email} />
+      </div>
+      <input type="hidden" name="phoneNumber" value={phoneNumber} />
 
-      <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>
+      {emailField}
+
+      <FoxSelect
+        size="md"
+        label="역할 선택"
+        options={ADMIN_ROLE_OPTIONS.map((option) => ({
+          value: option.value,
+          label: option.label,
+        }))}
+        value={roleCode}
+        onValueChange={onRoleCodeChange}
+        error={Boolean(errors.roleCode)}
+        hint={errors.roleCode}
+      />
+      <input type="hidden" name="roleCode" value={roleCode} />
+
+      <div className={styles.field} role="group" aria-labelledby={menuLabelId}>
+        <FoxFormLabel as="span" id={menuLabelId} requirement="required">
+          메뉴 선택
+        </FoxFormLabel>
+        <FoxChipArea size="md" wrap labelledBy={menuLabelId}>
+          {/* 시안의 첫 칩. 개별 메뉴가 아니라 나머지를 한 번에 켜고 끄는 조각이다. */}
+          <FoxChip
+            type="check"
+            label="전체"
+            checked={isAllSelected}
+            onCheckedChange={(checked) =>
+              onMenuCodesChange(checked ? allMenuValues : [])
+            }
+          />
+          {ADMIN_MENU_OPTIONS.map((option) => (
+            <FoxChip
+              key={option.value}
+              type="check"
+              label={option.label}
+              checked={menuCodes.includes(option.value)}
+              onCheckedChange={() => toggleMenu(option.value)}
+            />
           ))}
-        </Select>
-      </Field>
-      <FieldError message={errors.roleCode} />
-
-      <Field label="메뉴 선택 *">
-        <AdminMenuPicker defaultValue={member?.menuCodes ?? []} />
-      </Field>
-      <FieldError message={errors.menuCodes} />
+        </FoxChipArea>
+      </div>
+      {menuCodes.map((code) => (
+        <input key={code} type="hidden" name="menuCodes" value={code} />
+      ))}
     </>
   );
-}
-
-/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */
-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.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-list.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-list.tsx
@@ -21,6 +21,7 @@
   type AdminMember,
 } from '@/lib/domain/admin-member';
 import {
+  ADMIN_MEMBERS_EXCEL_PATH,
   ADMIN_MEMBER_PAGE_SIZE_OPTIONS,
   ADMIN_MEMBER_SEARCH_FIELD_OPTIONS,
   ADMIN_MEMBER_SORT_OPTIONS,
@@ -43,8 +44,6 @@
   /** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */
   currentAdminId: string;
 }
-
-const EXCEL_UNSUPPORTED_NOTE = '엑셀 다운로드는 추후 제공됩니다.';
 
 /** 폭이 고정되지 않은 세 열이 나눠 갖는 몫 — 시안 1552px 기준 (1552 - 고정열 720) / 3. */
 const FILL_COLUMN_WIDTH = 277;
@@ -70,9 +69,9 @@
  * 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간
  * `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다.
  *
- * **[엑셀 다운로드]는 자리만 잡고 비활성이다**(사용자 확정). 백엔드에 관리자 엑셀 API가 없다
- * — `/api/v1/mngr/admin/**`에는 목록 조회 하나뿐이라, 학생 목록처럼 라우트 핸들러로 중계할
- * 대상이 없다. 비활성 버튼은 스스로 hover 이벤트를 받지 못해 안내는 `<span title>`이 든다.
+ * [엑셀 다운로드]는 네이티브 GET 폼이다 — 응답이 첨부파일(Content-Disposition)이라 라우터
+ * 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다. 검색·페이징 값을
+ * 싣지 않는 것이 사양이다(파일은 항상 전체 데이터).
  */
 export function AdminMemberList({
   items,
@@ -200,15 +199,15 @@
         keyword={query.keyword}
         toolbarActions={
           <FoxButtonGroup size="md">
-            <span title={EXCEL_UNSUPPORTED_NOTE}>
+            <form action={ADMIN_MEMBERS_EXCEL_PATH} method="get">
               <FoxButton
                 type="secondary"
                 size="md"
-                disabled
+                htmlType="submit"
                 leadingIcon={<FoxDownloadSimpleIcon />}
                 label="엑셀 다운로드"
               />
-            </span>
+            </form>
             <FoxButton
               type="primary"
               size="md"
 
app/(protected)/(basic)/admins/_components/admin-member-modal.module.scss (added)
+++ app/(protected)/(basic)/admins/_components/admin-member-modal.module.scss
@@ -0,0 +1,49 @@
+// 관리자 등록·수정 팝업의 배치 — 시안(ADM_ADM_102_p / 103_p)의 form-list.
+// 값은 전부 @fox 토큰을 거치므로 없는 이름을 쓰면 빌드가 실패한다.
+
+@use "@fox/styles/abstracts" as fox;
+
+/// 필드 한 벌씩 세로로 쌓는다. 라벨·상자·헬퍼 사이 간격은 각 @fox 컴포넌트가 이미 갖는다.
+/// 모달 contents는 `align-items: flex-start`라 자식이 늘어나지 않는다 — 폭을 주지 않으면 폼이
+/// 내용만큼 넓어져(전화번호 칸이 가장 넓다) 모달 밖으로 터진다.
+.formList {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(8);
+  inline-size: 100%;
+  min-inline-size: 0;
+}
+
+/// 입력칸 바로 아래 전체폭 버튼이 붙는 칸([중복 확인]·[비밀번호 변경]).
+.fieldWithAction {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(3);
+}
+
+/// 시안(102_p)의 이메일 한 줄 — [아이디] @ [도메인]. @fox의 `FoxEmail`을 쓰지 않는다:
+/// 그쪽은 도메인 셀렉트 옆에 잠긴 '직접입력' 상자를 하나 더 그리고(시안은 상자 둘뿐),
+/// 칸마다 240px 최소폭이 박혀 있어 320px 안에서 세 줄로 쌓인다(사용자 확정).
+.emailRow {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(2);
+  inline-size: 100%;
+}
+
+/// 두 칸이 남는 폭을 나눠 갖는다. `min-inline-size: 0`이 없으면 내용만큼 넓어져 줄이 넘어간다.
+.emailPart {
+  flex: 1 1 0;
+  min-inline-size: 0;
+}
+
+/// 라벨 + 컨트롤 한 벌. @fox 입력 컴포넌트는 라벨을 스스로 갖지만, 여러 칸을 묶은 필드
+/// (전화번호·이메일·메뉴·잠김여부)는 라벨을 밖에서 얹으므로 그 간격을 여기서 준다.
+/// `FoxFormLabel`은 인라인이라 이 틀이 없으면 토글 같은 인라인 컨트롤과 한 줄에 붙는다.
+.field {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: fox.gap(3);
+  inline-size: 100%;
+}
app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
@@ -1,7 +1,7 @@
 'use client';
 
 import { useState, useTransition } from 'react';
-import { Button } from '@/components/ui/button';
+import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { FoxIconButton } from '@fox/core/components/fox-icon-button';
 import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
@@ -65,14 +65,10 @@
       title: '관리자를 삭제하시겠습니까?',
       message: `${member.name}(${member.loginId}) 계정을 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
       actions: (
-        <>
-          <Button type="button" variant="ghost" onClick={hideAlert}>
-            취소
-          </Button>
-          <Button type="button" variant="primary" onClick={runDelete}>
-            삭제
-          </Button>
-        </>
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton type="error" size="md" label="삭제" onAction={runDelete} />
+        </FoxButtonGroup>
       ),
     });
   }
 
app/(protected)/(basic)/admins/_components/admin-menu-picker.tsx (deleted)
--- app/(protected)/(basic)/admins/_components/admin-menu-picker.tsx
@@ -1,89 +0,0 @@
-'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/excel/route.ts (added)
+++ app/(protected)/(basic)/admins/excel/route.ts
@@ -0,0 +1,67 @@
+import { getSessionAccessToken, verifySession } from '@/lib/auth/dal';
+import { backendFetchStream } from '@/lib/http/backend-fetch';
+
+/**
+ * 관리자 회원 목록 엑셀 다운로드 — 백엔드가 만든 xlsx를 브라우저로 중계한다.
+ *
+ * 라우트 핸들러인 이유는 학생 목록(`students/excel/route.ts`)과 같다 — 브라우저는 백엔드를 직접
+ * 호출하지 않고, 백엔드 엑셀 API가 요구하는 ROLE_ADMIN 토큰은 httpOnly 세션 안에만 있다. 본문은
+ * 파싱하지 않고 업스트림 스트림을 그대로 흘려보낸다.
+ *
+ * 파일에 담기는 열은 백엔드가 정한다 — 번호·이름·아이디·휴대폰번호·이메일·생성일
+ * (`MngrAdminApiController.downloadExcel`). 화면의 "역할" 열은 파일에 없다.
+ */
+
+const ADMIN_MEMBER_EXCEL_PATH = '/api/v1/mngr/admin/excel/download';
+
+/**
+ * 파일은 화면의 검색·페이징과 무관하게 **항상 전체**를 담는다(학생 목록과 같은 사양).
+ *
+ * 백엔드 엑셀 API가 목록과 같은 `selectPagination`을 그대로 쓰므로 페이징이 걸린다 — 파라미터를
+ * 생략하면 기본값(`recordCountPerPage=10`)이 적용돼 10건만 담긴 파일이 나온다. "전체" 모드가 따로
+ * 없어 충분히 큰 상한을 명시해 1페이지로 전부 받는다. 이 상한을 넘으면 파일이 조용히 잘린다.
+ */
+const EXCEL_ROW_LIMIT = 100_000;
+
+/** 전체 행을 모아 워크북을 만드는 시간이 있어 일반 조회보다 넉넉히 잡는다. */
+const EXCEL_TIMEOUT_MS = 60_000;
+
+const DOWNLOAD_FAILED_MESSAGE =
+  '엑셀 파일을 내려받지 못했습니다. 잠시 후 다시 시도해 주세요.';
+
+const XLSX_CONTENT_TYPE =
+  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+
+export async function GET() {
+  // 라우트 핸들러는 UI를 거치지 않고 직접 호출될 수 있으므로 여기서 직접 인증을 확인한다
+  // (proxy의 쿠키 존재 확인은 낙관적 필터일 뿐이다 — 설계서 §8).
+  await verifySession();
+
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetchStream(ADMIN_MEMBER_EXCEL_PATH, {
+    query: { pageIndex: 1, recordCountPerPage: EXCEL_ROW_LIMIT },
+    accessToken: accessToken ?? undefined,
+    timeoutMs: EXCEL_TIMEOUT_MS,
+  });
+
+  if (!result.ok) {
+    // 실패 사유(코드·백엔드 메시지)는 backendFetchStream이 서버 콘솔에 남긴다.
+    return new Response(DOWNLOAD_FAILED_MESSAGE, {
+      status: 502,
+      headers: { 'Content-Type': 'text/plain; charset=utf-8' },
+    });
+  }
+
+  const upstream = result.data;
+
+  return new Response(upstream.body, {
+    status: 200,
+    headers: {
+      'Content-Type': upstream.headers.get('content-type') ?? XLSX_CONTENT_TYPE,
+      'Content-Disposition':
+        upstream.headers.get('content-disposition') ??
+        'attachment; filename="admins.xlsx"',
+    },
+  });
+}
 
lib/data/mock/admin-member-store.ts (deleted)
--- lib/data/mock/admin-member-store.ts
@@ -1,45 +0,0 @@
-import 'server-only';
-import type { AdminMember } from '@/lib/domain/admin-member';
-
-/**
- * 관리자 회원 **삭제**의 mock 오버레이 — 백엔드에 삭제 API가 없어 임시로 두는 계층이다.
- * 등록·수정·중복확인은 실제 API를 쓰므로(`admin-member-repository.ts`) 여기 남는 책임은 삭제뿐이다.
- *
- * 백엔드(edupay-backend develop 924db37) `MngrAdminApiController`에는 GET 3개(목록·단건·중복확인)와
- * POST·PUT만 있고 `@DeleteMapping`이 없다. 수정 API로 비활성화를 대신할 수도 없다 — UPDATE 문이
- * `USE_YN`을 건드리지 않아 사용여부를 끌 수단 자체가 없다(바꿀 수 있는 건 `ACCT_LOCK_YN`뿐).
- *
- * **한계** — 서버 프로세스 메모리에만 있다. 재시작하면 삭제한 행이 되살아나고, 인스턴스가 여럿이면
- * 공유되지 않는다. 삭제한 계정의 로그인 ID도 백엔드에는 그대로 남아 재등록 시 중복으로 걸린다.
- *
- * 백엔드에 삭제 API가 생기면 이 파일을 지우고 Repository의 `deleteAdminMember` 본문만 교체한다.
- */
-
-type MockStoreState = {
-  deletedIds: Set<string>;
-};
-
-/** HMR로 모듈이 다시 평가돼도 삭제 상태가 초기화되지 않도록 globalThis에 붙인다(mock 전용 편법). */
-const globalStore = globalThis as typeof globalThis & {
-  __adminMemberMockStore?: MockStoreState;
-};
-
-function getState(): MockStoreState {
-  globalStore.__adminMemberMockStore ??= { deletedIds: new Set() };
-  return globalStore.__adminMemberMockStore;
-}
-
-/** 백엔드 목록에서 mock 삭제된 행을 걷어낸다. 정렬·검색·페이징은 Repository가 한다. */
-export function applyMockDeletions(backendItems: AdminMember[]): AdminMember[] {
-  const { deletedIds } = getState();
-  return backendItems.filter((item) => !deletedIds.has(item.id));
-}
-
-export function isMockDeleted(id: string): boolean {
-  return getState().deletedIds.has(id);
-}
-
-/** 백엔드 행은 매 조회마다 다시 등장하므로 삭제 사실을 따로 기억해야 한다. */
-export function markMockDeleted(id: string): void {
-  getState().deletedIds.add(id);
-}
lib/data/repositories/admin-member-repository.ts
--- lib/data/repositories/admin-member-repository.ts
+++ lib/data/repositories/admin-member-repository.ts
@@ -6,29 +6,24 @@
   AdminMemberQuery,
   AdminMemberSearchField,
 } from '@/lib/domain/admin-member-query';
-import {
-  applyMockDeletions,
-  isMockDeleted,
-  markMockDeleted,
-} from '@/lib/data/mock/admin-member-store';
 
 /**
  * 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트·파라미터·
  * 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은
  * `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다.
  *
- *   GET  /api/v1/mngr/admin/pagination          목록
- *   GET  /api/v1/mngr/admin/{admUserId}         단건
- *   GET  /api/v1/mngr/admin/duplication/{id}    로그인 ID 중복 확인
- *   POST /api/v1/mngr/admin                     등록
- *   PUT  /api/v1/mngr/admin                     수정
+ *   GET    /api/v1/mngr/admin/pagination          목록
+ *   GET    /api/v1/mngr/admin/{admUserId}         단건
+ *   GET    /api/v1/mngr/admin/duplication/{id}    로그인 ID 중복 확인
+ *   POST   /api/v1/mngr/admin                     등록
+ *   PUT    /api/v1/mngr/admin/{admUserId}         수정
+ *   DELETE /api/v1/mngr/admin/{admUserId}         삭제
  *
- * 아래는 백엔드 저장소(edupay-backend, develop 924db37)의 실제 구현과 dev 서버 응답을 확인한 것이다.
+ * 아래는 백엔드 저장소(edupay-backend, develop 4d98756)의 실제 구현을 확인한 것이다.
  *
- * - **등록·수정은 JSON 본문을 받지 않는다.** 두 핸들러 모두 `@RequestBody` 없이
- *   `@ParameterObject MngrAdmin*RequestVo`를 받아 query/form으로만 바인딩된다. 그래서 `form`으로 보낸다.
- * - **이메일·휴대전화번호를 되읽을 수 없다.** 저장은 되지만 조회 SQL의 select 목록에 두 컬럼이 없어
- *   응답은 항상 `null`이다(실측). 매핑은 미리 해 두었으므로 백엔드가 컬럼을 추가하면 화면까지 그대로 흐른다.
+ * - **등록과 수정의 본문 형식이 서로 다르다.** 등록은 `@RequestBody` 없이 `@ParameterObject`라
+ *   query/form으로만 바인딩되고(→ `form`), 수정은 `@RequestBody`라 JSON이다(→ `body`).
+ *   같은 도메인인데 갈린 것이라 헷갈리기 쉽다 — 한쪽 방식으로 통일해 보내면 값이 조용히 비어 저장된다.
  * - **이름(`admNm`)은 수정 대상이 아니다** — `MngrAdminUpdateRequestVo`에 필드가 없다.
  * - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY RNUM DESC`가 하드코딩돼 있다. rnum이
  *   `ROW_NUMBER() OVER (ORDER BY frst_reg_dt, adm_nm DESC)`, 즉 최초등록일시 오름차순 순번이라
@@ -36,6 +31,8 @@
  *   그대로 쓴다"는 뜻이고, 이름순만 우리가 정렬한다.
  * - **`totalCount`가 전체 건수가 아니다.** count 쿼리가 없어 `PaginationUtil.execute`가
  *   `list.size()`(= 그 페이지의 행 수)를 총건수로 그대로 쓴다. 그래서 이 값은 신뢰하지 않는다.
+ * - **삭제는 soft delete다**(`DEL_YN='Y'`). 조회 SQL이 모두 `DEL_YN != 'Y'`로 거르므로 삭제한 행은
+ *   목록·단건에서 함께 사라진다.
  *
  * 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용이다. 세션에 보관된 백엔드 accessToken을 DAL에서
  * 꺼내 Bearer로 붙인다.
@@ -54,10 +51,8 @@
  *    페이지가 가득 찰 때마다 `totalPages`가 1로 계산돼 2페이지 이후에 영원히 닿을 수 없다.
  *    시안(ADM_ADM_101)은 "총 N명 | 현재페이지 1/1"과 번호 열(총건수에서 거꾸로 세는 순번)을
  *    요구하는데, 둘 다 정확한 전체 건수를 전제한다.
- * 2. **검색·정렬을 백엔드에 맡길 수 없다.** 이름순 정렬 파라미터가 없고, 휴대전화번호 검색
- *    분기는 관리자 테이블에 없는 컬럼을 참조한다(`admin-member-query.ts` 주석 참조).
- * 3. **삭제가 mock이다.** 삭제된 행을 걷어낸 뒤 세어야 총건수가 맞는데, 페이징이 백엔드에 있으면
- *    페이지마다 구멍이 생겨 건수와 순번이 어긋난다.
+ * 2. **이름순 정렬을 백엔드에 맡길 수 없다.** 정렬 파라미터가 없고 목록 SQL의 ORDER BY가
+ *    하드코딩돼 있다.
  *
  * 관리자 계정은 본래 수십 건 규모라 전체를 받아도 부담이 없다. 이 전제가 깨질 정도로 늘면
  * 백엔드에 count·정렬·검색 파라미터가 필요하다 — 상한 인상은 임시방편일 뿐이다.
@@ -115,12 +110,13 @@
     id: readRequiredString(raw, 'admUserId'),
     name: readRequiredString(raw, 'admNm'),
     loginId: readRequiredString(raw, 'loginId'),
-    // 지금은 항상 null이다 — 조회 SQL이 두 컬럼을 select하지 않는다(위 주석 참조).
     phoneNumber: readOptionalString(raw, 'admTelNo'),
     email: readOptionalString(raw, 'admEmlAddr'),
     roleCode: readOptionalString(raw, 'admRoleCd') ?? '',
+    // 백엔드에 관리자별 메뉴 권한 개념이 없다 — 저장도 조회도 되지 않는다(쓰기 경로 주석 참조).
     menuCodes: [],
-    createdAt: null,
+    // `DATE_FORMAT(FRST_REG_DT, '%Y-%m-%d')`라 이미 화면 표기 형식이다.
+    createdAt: readOptionalString(raw, 'frstRegDtStr'),
     isLocked: parseYesNo(raw.acctLockYn),
     isActive: parseYesNo(raw.useYn),
     loginFailCount: typeof loginFailCnt === 'number' ? loginFailCnt : null,
@@ -130,9 +126,9 @@
 /**
  * 백엔드 목록 전체를 한 번에 받아온다.
  *
- * 검색 파라미터(`searchCondition`/`searchKeyword`)를 **의도적으로 보내지 않는다** — 검색은
- * mock 행까지 포함해 일관되게 걸러야 하므로 아래 `filterByKeyword`가 전담한다. 백엔드에 검색을
- * 맡기면 백엔드 행만 걸러지고 mock 행은 그대로 남아 결과가 어긋난다.
+ * 검색 파라미터(`searchCondition`/`searchKeyword`)를 **의도적으로 보내지 않는다** — 백엔드에
+ * 맡기면 페이징도 함께 백엔드가 하게 되는데 그 총건수를 믿을 수 없다(위 상수 주석). 어차피 전체를
+ * 손에 쥐므로 `filterByKeyword`가 같은 조건으로 거른다.
  */
 async function fetchAllAdminMembers(): Promise<AdminMember[]> {
   const accessToken = await getSessionAccessToken();
@@ -165,6 +161,7 @@
 > = {
   name: (member) => member.name,
   loginId: (member) => member.loginId,
+  phoneNumber: (member) => member.phoneNumber ?? '',
 };
 
 function filterByKeyword(
@@ -181,9 +178,8 @@
 }
 
 /**
- * 정렬. 생성일순은 **정렬하지 않는다** — 백엔드가 이미 생성일 최신순으로 내려주고(고정
- * `ORDER BY rnum DESC`), mock 신규 등록 행은 오버레이가 맨 앞에 붙여 두기 때문이다. 응답에
- * `createdAt` 값 자체가 없어 우리가 다시 정렬할 수단도 없다.
+ * 정렬. 생성일순은 **정렬하지 않는다** — 백엔드의 고정 `ORDER BY rnum DESC`가 곧 생성일
+ * 최신순이라 받은 순서가 이미 답이다(rnum이 `frst_reg_dt` 오름차순 순번이다).
  */
 function sortItems(
   items: AdminMember[],
@@ -205,7 +201,7 @@
 export async function fetchAdminMembers(
   query: AdminMemberQuery
 ): Promise<AdminMemberPage> {
-  const all = applyMockDeletions(await fetchAllAdminMembers());
+  const all = await fetchAllAdminMembers();
   const matched = sortItems(filterByKeyword(all, query), query);
 
   const offset = (query.page - 1) * query.pageSize;
@@ -216,14 +212,10 @@
   };
 }
 
-/** 단건 조회 — 수정 팝업이 쓰는 진입점. mock 삭제된 행은 없는 것으로 취급한다. */
+/** 단건 조회 — 수정 팝업이 쓰는 진입점. */
 export async function findAdminMemberById(
   id: string
 ): Promise<AdminMember | null> {
-  if (isMockDeleted(id)) {
-    return null;
-  }
-
   const accessToken = await getSessionAccessToken();
   const result = await backendFetch<unknown>(
     `${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
@@ -272,10 +264,9 @@
 
 /*
  * ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
- * 등록·수정은 실제 API를 쓴다. 삭제만 백엔드에 엔드포인트가 없어 mock 오버레이에 남아 있다.
+ * 등록·수정·삭제 모두 실제 API를 쓴다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다.
  *
- * 두 요청 모두 `form`으로 보낸다 — 백엔드 핸들러가 `@RequestBody` 없이 VO를 받아 JSON 본문을
- * 바인딩하지 못하기 때문이다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다.
+ * **본문 형식이 등록과 수정에서 갈린다** — 등록은 form, 수정은 JSON이다(파일 상단 주석 참조).
  *
  * 메뉴 선택(`menuCodes`)은 **보내지 않는다** — 백엔드에 관리자별 메뉴 권한 개념이 없다
  * (`/api/v1/common/menu`는 개인 북마크용이고 등록·수정 VO에도 해당 필드가 없다).
@@ -292,8 +283,9 @@
 
 export type UpdateAdminMemberInput = {
   /**
-   * 백엔드 UPDATE 문이 `LOGIN_PW`를 무조건 덮어쓰므로 빈 값을 보내면 그 계정이 로그인 불가가 된다.
-   * 그래서 이 필드는 선택이 아니라 필수다 — 호출부가 빈 값을 걸러 여기까지 오지 않게 한다.
+   * 비우면 비밀번호를 바꾸지 않는다 — 백엔드 UPDATE의 `LOGIN_PW`가
+   * `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서 빠진다.
+   * (예전에는 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 됐다.)
    */
   password: string;
   phoneNumber: string;
@@ -338,26 +330,43 @@
 ): Promise<void> {
   const accessToken = await getSessionAccessToken();
 
-  const result = await backendFetch<unknown>(ADMIN_MEMBER_BASE_PATH, {
-    method: 'PUT',
-    form: {
-      admUserId: id,
-      loginPw: input.password,
-      admEmlAddr: input.email,
-      admTelNo: input.phoneNumber,
-      admRoleCd: input.roleCode,
-      acctLockYn: toYesNo(input.isLocked),
-    },
-    accessToken: accessToken ?? undefined,
-    canHaveNullData: true,
-  });
+  const result = await backendFetch<unknown>(
+    `${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
+    {
+      method: 'PUT',
+      // 수정만 `@RequestBody`라 JSON이다. form으로 보내면 모든 필드가 null로 들어가
+      // 이메일·전화번호가 지워지고 역할이 비워진다.
+      body: {
+        loginPw: input.password,
+        admEmlAddr: input.email,
+        admTelNo: input.phoneNumber,
+        admRoleCd: input.roleCode,
+        acctLockYn: toYesNo(input.isLocked),
+      },
+      accessToken: accessToken ?? undefined,
+      canHaveNullData: true,
+    }
+  );
 
   if (!result.ok) {
     throw new BackendRequestError(result);
   }
 }
 
-/** 삭제 API가 없어 화면에서만 감춘다 — 한계는 `admin-member-store.ts` 주석 참조. */
+/** 삭제(soft delete). 백엔드가 `DEL_YN='Y'`로 표시하고 조회 SQL이 그 행을 제외한다. */
 export async function deleteAdminMember(id: string): Promise<void> {
-  markMockDeleted(id);
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(
+    `${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
+    {
+      method: 'DELETE',
+      accessToken: accessToken ?? undefined,
+      canHaveNullData: true,
+    }
+  );
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
 }
lib/domain/admin-member-form.ts
--- lib/domain/admin-member-form.ts
+++ lib/domain/admin-member-form.ts
@@ -2,7 +2,7 @@
  * 관리자 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음).
  *
  * 규칙은 시안(ADM_ADM_102_p / ADM_ADM_103_p)의 안내 문구를 그대로 옮긴 것이다:
- *   - ID   : "영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다."
+ *   - ID   : "영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인하세요."
  *   - 비밀번호: "영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상"
  *
  * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를
@@ -26,20 +26,10 @@
 } from '@/lib/domain/admin-member';
 
 export const ADMIN_LOGIN_ID_HELP_TEXT =
-  '영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다.';
+  '영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인하세요.';
 export const ADMIN_PASSWORD_HELP_TEXT =
   '영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상';
 
-/**
- * 수정 시 비밀번호를 비울 수 없는 이유를 화면에 그대로 보여준다.
- *
- * 백엔드 UPDATE 문이 `LOGIN_PW`를 조건 없이 덮어쓴다(`MngrAdminMapper.xml`의 update). 빈 값을
- * 보내면 비밀번호가 빈 문자열이 되어 그 계정은 다시 로그인할 수 없다 — 로그인은 암호화된 값과
- * 비교하기 때문이다. 그래서 "바꾸지 않음"을 표현할 방법이 없고, 프론트에서 빈 값을 막는 것이
- * 유일한 안전장치다. 백엔드가 빈 값을 UPDATE에서 제외하도록 고치면 이 제약을 없앨 수 있다.
- */
-export const ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT =
-  '수정하려면 비밀번호를 다시 입력해 주세요. (빈 값으로 저장하면 로그인할 수 없게 됩니다)';
 
 const LOGIN_ID_MIN_LENGTH = 4;
 const LOGIN_ID_MAX_LENGTH = 20;
@@ -47,8 +37,8 @@
 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;
+/** 앞 3자리 + 가운데 3~4자리 + 끝 4자리. 화면은 숫자만 다루고 하이픈은 여기서 붙인다. */
+const PHONE_DIGITS_PATTERN = /^(\d{3})(\d{3,4})(\d{4})$/;
 
 /** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */
 export type AdminMemberEditableValues = {
@@ -124,26 +114,20 @@
 }
 
 /**
- * 휴대전화번호 3칸을 하나의 문자열(`010-1234-5678`)로 합친다. 비어 있는 칸이 하나라도 있거나
- * 형식이 맞지 않으면 null — 부분적으로 채워진 번호를 저장하지 않기 위해서다.
+ * 화면이 넘긴 숫자열(`01012345678`)을 저장 형식(`010-1234-5678`)으로 바꾼다. 자릿수가 맞지 않으면
+ * null — 부분적으로 채워진 번호를 저장하지 않기 위해서다.
+ *
+ * 화면(`FoxPhoneNumber`)은 하이픈을 그리기만 하고 값으로는 숫자만 내보낸다. 반대로 목록·엑셀은
+ * 하이픈이 있는 형태로 보여주므로, 두 표현 사이의 변환을 이 한 쌍이 책임진다.
  */
-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;
+export function formatPhoneNumber(digits: string): string | null {
+  const matched = PHONE_DIGITS_PATTERN.exec(digits.trim());
+  return matched === null ? null : `${matched[1]}-${matched[2]}-${matched[3]}`;
 }
 
-/** 저장된 번호를 다시 3칸으로 나눈다(수정 팝업의 초기값). 형식이 다르면 빈 칸들을 돌려준다. */
-export function splitPhoneNumber(phoneNumber: string | null): string[] {
-  const parts = (phoneNumber ?? '').split('-');
-  return parts.length === PHONE_PART_PATTERNS.length ? parts : ['', '', ''];
+/** 저장된 번호에서 숫자만 남긴다(수정 팝업의 초기값). */
+export function toPhoneDigits(phoneNumber: string | null): string {
+  return (phoneNumber ?? '').replace(/\D/g, '');
 }
 
 function isValidEmail(email: string): boolean {
@@ -157,21 +141,24 @@
 /**
  * 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다.
  *
- * 비밀번호는 등록·수정 모두 필수다. 빈 값일 때의 안내 문구만 호출부가 정한다 — 등록은 형식 안내,
- * 수정은 "비우면 로그인 불가"라는 백엔드 제약을 알려야 하기 때문이다
- * (`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석 참조).
+ * **비밀번호·이메일은 등록에서만 필수다.** 수정에서 비우면 "바꾸지 않음"이 된다 — 백엔드 UPDATE의
+ * `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서
+ * 빠진다(예전에는 조건 없이 덮어써서, 빈 값을 보내면 그 계정이 로그인 불가가 됐다). 값이 있으면
+ * 등록·수정 모두 같은 형식 규칙을 통과해야 한다.
  */
 function validateEditableValues(
   values: AdminMemberEditableValues,
   errors: AdminMemberFormErrors,
-  options: { emptyPasswordMessage: string }
+  options: { passwordRequired: boolean; emailRequired: boolean }
 ): AdminMemberEditableValues {
   const password = values.password;
   const phoneNumber = values.phoneNumber.trim();
   const email = values.email.trim();
 
   if (!password) {
-    errors.password = options.emptyPasswordMessage;
+    if (options.passwordRequired) {
+      errors.password = ADMIN_PASSWORD_HELP_TEXT;
+    }
   } else if (
     password.length < PASSWORD_MIN_LENGTH ||
     countCharacterKinds(password) < 2
@@ -183,8 +170,12 @@
     errors.phoneNumber = '휴대전화 번호를 정확히 입력해 주세요.';
   }
 
-  // 이메일은 선택 항목이라 비어 있는 것 자체는 오류가 아니다(시안에 필수 표시가 없다).
-  if (email && !isValidEmail(email)) {
+  // 이메일 필수 여부는 두 시안이 다르다 — 등록(102_p)에는 `*`가 있고 수정(103_p)에는 없다.
+  if (!email) {
+    if (options.emailRequired) {
+      errors.email = '이메일을 입력해 주세요.';
+    }
+  } else if (!isValidEmail(email)) {
     errors.email = '이메일 형식이 올바르지 않습니다.';
   }
 
@@ -192,12 +183,12 @@
     errors.roleCode = '역할을 선택해 주세요.';
   }
 
+  // 메뉴 선택은 **필수가 아니다**(사용자 확정) — 백엔드에 관리자별 메뉴 권한이 없어 고른 값이
+  // 저장되지 않는데, 저장되지도 않는 값 때문에 등록이 막히면 안 된다. 허용 목록 밖의 코드만
+  // 걸러 둔다(권한 API가 생기면 여기에 필수 규칙을 되살린다).
   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 };
 }
@@ -221,7 +212,8 @@
   }
 
   const editable = validateEditableValues(values, errors, {
-    emptyPasswordMessage: ADMIN_PASSWORD_HELP_TEXT,
+    passwordRequired: true,
+    emailRequired: true,
   });
 
   if (Object.keys(errors).length > 0) {
@@ -241,7 +233,8 @@
   const errors: AdminMemberFormErrors = {};
 
   const editable = validateEditableValues(values, errors, {
-    emptyPasswordMessage: ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT,
+    passwordRequired: false,
+    emailRequired: false,
   });
 
   if (Object.keys(errors).length > 0) {
lib/domain/admin-member-query.ts
--- lib/domain/admin-member-query.ts
+++ lib/domain/admin-member-query.ts
@@ -14,21 +14,19 @@
 /** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */
 export const ADMIN_MEMBERS_PATH = '/admins';
 
+/** 엑셀 다운로드 라우트 핸들러의 경로 — 목록 툴바의 폼이 이 주소로 GET 제출한다. */
+export const ADMIN_MEMBERS_EXCEL_PATH = `${ADMIN_MEMBERS_PATH}/excel`;
+
 /**
- * 검색 대상 — 시안(ADM_ADM_101 ①)은 회원명/ID/휴대전화번호 셋을 요구하지만 **휴대전화번호는 뺐다.**
+ * 검색 대상 — 시안(ADM_ADM_101 ①)의 회원명/ID/휴대전화번호 셋.
  *
- * 두 가지 이유가 겹친다:
- * 1. 목록 응답에 휴대전화번호가 없다(`MngrAdminMapper.xml`의 select 목록에도, `MngrAdminVo`에도
- *    없다). 화면에 보이지도 않는 값으로 걸러낸 결과는 사용자가 검증할 수 없다.
- * 2. 백엔드의 휴대전화번호 검색 분기(`searchCondition == "3"`)는 `USER_TELNO` 컬럼을 참조하는데,
- *    이는 학생 목록 쿼리(TB_COM_USER 대상)에서 그대로 복사된 것으로 보인다 — 관리자 테이블
- *    (TB_ADM_USER)의 select 목록에는 그런 컬럼이 없다. 컬럼이 실제로 없다면 이 검색은 SQL 오류로
- *    떨어져 화면 전체가 에러 경계로 넘어간다.
+ * 휴대전화번호는 한동안 빠져 있었다. 목록 응답에 그 값이 없어 결과를 눈으로 검증할 수 없었고,
+ * 백엔드의 해당 분기가 관리자 테이블에 없는 `USER_TELNO`를 참조해 SQL 오류가 날 상태였다.
+ * 백엔드가 `ADM_TEL_NO`를 select 목록과 검색 분기 양쪽에 넣으면서 두 이유가 모두 사라졌다.
  *
- * 백엔드가 휴대전화번호를 응답에 포함하면 여기와 `admin-member-repository.ts`의 필터에 함께
- * 추가한다.
+ * 실제 필터링은 Repository가 전체를 손에 쥐고 수행한다 — 이 값은 "어느 열로 거를지"만 정한다.
  */
-export type AdminMemberSearchField = 'name' | 'loginId';
+export type AdminMemberSearchField = 'name' | 'loginId' | 'phoneNumber';
 
 export const ADMIN_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
   value: AdminMemberSearchField;
@@ -36,14 +34,15 @@
 }> = [
   { value: 'name', label: '회원명' },
   { value: 'loginId', label: 'ID' },
+  { value: 'phoneNumber', label: '휴대전화번호' },
 ];
 
 /**
  * 정렬 기준 — 시안의 select는 "가입일순"이지만 관리자 회원의 해당 값은 생성일이라 이름을 맞췄다.
  *
- * `createdAt`은 백엔드가 응답에 담아 주지 않는데도 정렬이 가능한 이유는, 목록 SQL의 고정
- * `ORDER BY rnum DESC`가 곧 생성일 최신순이기 때문이다(rnum은 `frst_reg_dt` 오름차순 행번호).
- * 즉 이 정렬은 "백엔드가 준 순서를 그대로 쓴다"는 뜻이다 — 자세한 근거는 Repository 주석 참조.
+ * 생성일순이 곧 "백엔드가 준 순서를 그대로 쓴다"는 뜻이다 — 목록 SQL의 고정 `ORDER BY rnum DESC`가
+ * 생성일 최신순이기 때문이다(rnum은 `frst_reg_dt` 오름차순 행번호). 정렬 파라미터가 없어 이름순만
+ * 우리가 정렬한다.
  */
 export type AdminMemberSortOption = 'createdAt' | 'name';
 
Add a comment
List