민혜린 민혜린 08-19
Merge branch 'develop' of http://git.dataeum.kr/ldu0009/edupay-admin into develop
# Conflicts:
#	@fox/core/components/index.ts
#	@fox/styles/components.scss
@0fa903363aae453f73837d60b32a4d547f4f89d5
@fox/README.md
--- @fox/README.md
+++ @fox/README.md
@@ -16,6 +16,7 @@
     _mixins.scss         fox.pc / fox.mobile
   core/
     components/          React 래퍼 (선택 — 아래 "React 없이 쓰기" 참고)
+    validation/          입력 검증기 (프레임워크 무관 — 아래 "입력 검증" 참고)
     utils/               cx() 등 의존성 없는 유틸
   dev-test/              개발 전용 테스트·감사 화면
   tools/build-tokens.py  Figma JSON → SCSS 변환기
@@ -64,6 +65,45 @@
 6. **폰트를 공급합니다** — 아래 "호스트 앱과의 계약" 참고. 빠뜨리면 컴포넌트가 OS 기본
    서체로 그려져 글자 폭이 시안과 어긋납니다.
 
+## 입력 검증
+
+`@fox/core/validation`은 Angular의 `Validators` 계약을 그대로 옮긴 것입니다. **검증기는 값을 받아
+통과면 `null`, 아니면 오류 객체를 돌려주는 순수 함수**이고, 오류 객체의 키가 오류의 이름입니다.
+
+```ts
+import { foxValidators, foxPasswordValidator, foxValidationMessage } from "@fox/core/validation";
+
+foxValidators.minLength(10)("abc");
+// → { minlength: { requiredLength: 10, actualLength: 3 } }
+```
+
+문구는 검증기가 정하지 않습니다(Angular와 같은 이유입니다) — 같은 규칙도 자리에 따라 다른 말로
+안내해야 하기 때문입니다. `foxValidationMessage(errors, overrides)`가 오류를 문구 하나로 옮기고,
+`overrides`로 자리마다 덮어씁니다.
+
+`FoxInput`은 이것을 `validators`·`validationMessages`·`validateOn`·`onValidationChange`로 받습니다.
+`type="password"`처럼 규칙이 여러 개인 칸은 `foxPasswordValidator`가 한 벌로 묶어 줍니다.
+
+```tsx
+<FoxInput
+  type="password"
+  label="비밀번호"
+  message="영문·숫자·특수문자 중 2종류 이상, 10자리 이상"   // 평소엔 헬퍼
+  validators={[foxPasswordValidator({ minLength: 10, kinds: 2, required: true })]}
+/>
+```
+
+- `message`는 평소 **헬퍼**이고, 검증이 걸리면 그 자리에 사유가 대신 들어갑니다. 호출부가
+  `invalid`를 직접 켠 경우에는 `message`가 이깁니다 — 서버가 돌려준 사유(중복·권한 등)는 화면
+  규칙이 알 수 없는 것이라 덮이면 안 됩니다.
+- 오류는 **한 번 포커스를 벗어난 뒤부터** 보입니다(`validateOn="blur"`, 기본값). 한 글자 쳤을 때
+  "10자 이상"이 뜨면 안내가 아니라 방해가 되기 때문입니다. 벗어난 뒤로는 입력할 때마다 갱신됩니다
+  (Angular의 `touched`와 같은 판단입니다 — 다만 Angular `updateOn`의 기본값은 `change`입니다).
+- **화면 검증은 안내일 뿐 신뢰 경계가 아닙니다.** 최종 판정은 저장 직전의 서버 검증입니다.
+
+검증기는 React에 의존하지 않으므로 다른 프레임워크·서버 코드에서도 그대로 씁니다 — 실제로 이
+저장소는 같은 규칙 값을 화면과 Server Action이 함께 봅니다.
+
 ## React 없이 쓰기
 
 **디자인 레이어는 프레임워크에 의존하지 않습니다.** 토큰도 컴포넌트 스타일도 순수 SCSS이고,
 
@fox/core/components/fox-accordion/fox-accordion.tsx (added)
+++ @fox/core/components/fox-accordion/fox-accordion.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { useId, useState, type ReactNode } from "react";
+import { cx } from "../../utils";
+import { FoxCaretDownIcon, FoxCaretUpIcon } from "../../icons";
+
+/** `box`는 색을 깐 상자, `line`은 위쪽 선 하나로 나뉘는 목록형이다. */
+export type FoxAccordionType = "box" | "line";
+export type FoxAccordionSize = "lg" | "md";
+
+export interface FoxAccordionProps {
+  title: ReactNode;
+  children?: ReactNode;
+  type?: FoxAccordionType;
+  size?: FoxAccordionSize;
+  /** 제목 앞자리. 시안은 배지 묶음이다. */
+  badge?: ReactNode;
+  /** 주면 호출부가 열림을 소유한다. 없으면 컴포넌트가 스스로 여닫는다. */
+  open?: boolean;
+  defaultOpen?: boolean;
+  onOpenChange?: (open: boolean) => void;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+}
+
+/**
+ * @fox 아코디언. 제목을 누르면 아래 내용이 열린다.
+ *
+ * 열림을 밖에서 쥐면 여러 개 중 하나만 열리게 하는 것도 호출부가 정할 수 있다. 넘기지
+ * 않으면 스스로 여닫는다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxAccordion({
+  title,
+  children,
+  type = "box",
+  size = "lg",
+  badge,
+  open,
+  defaultOpen = false,
+  onOpenChange,
+  hidden = false,
+  className,
+}: FoxAccordionProps) {
+  const autoId = useId();
+  const [inner, setInner] = useState(defaultOpen);
+
+  if (hidden) {
+    return null;
+  }
+
+  const expanded = open ?? inner;
+  const panelId = `${autoId}-panel`;
+
+  const toggle = () => {
+    if (open === undefined) {
+      setInner(!expanded);
+    }
+    onOpenChange?.(!expanded);
+  };
+
+  return (
+    <div
+      className={cx(
+        "fox-accordion",
+        `fox-accordion--${type}`,
+        `fox-accordion--${size}`,
+        expanded && "fox-accordion--open",
+        className
+      )}
+    >
+      <button
+        className="fox-accordion__tit"
+        type="button"
+        aria-expanded={expanded}
+        aria-controls={panelId}
+        onClick={toggle}
+      >
+        {badge && <span className="fox-accordion__badge">{badge}</span>}
+        <span className="fox-accordion__title">{title}</span>
+        <span className="fox-accordion__icon" aria-hidden="true">
+          {expanded ? <FoxCaretUpIcon /> : <FoxCaretDownIcon />}
+        </span>
+      </button>
+
+      {/* 닫혔을 때는 DOM에서 뺀다 — 안의 초점 가능한 것들이 탭 순서에 남으면 안 된다. */}
+      {expanded && (
+        <div className="fox-accordion__contents" id={panelId}>
+          {children}
+        </div>
+      )}
+    </div>
+  );
+}
 
@fox/core/components/fox-accordion/index.ts (added)
+++ @fox/core/components/fox-accordion/index.ts
@@ -0,0 +1,6 @@
+export {
+  FoxAccordion,
+  type FoxAccordionProps,
+  type FoxAccordionType,
+  type FoxAccordionSize,
+} from "./fox-accordion";
 
@fox/core/components/fox-card/fox-card.tsx (added)
+++ @fox/core/components/fox-card/fox-card.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { cx } from "../../utils";
+
+export type FoxCardSize = "xlg" | "lg" | "md" | "sm";
+
+export interface FoxCardProps {
+  size?: FoxCardSize;
+  /** 위쪽 그림. 시안은 16:9다. */
+  image?: ReactNode;
+  title?: ReactNode;
+  /** 제목 아래 한두 줄 설명. */
+  description?: ReactNode;
+  /** 제목·설명 대신 넣을 것. 주면 `title`·`description`은 쓰이지 않는다. */
+  children?: ReactNode;
+  /** 아래쪽 줄. 시안은 글자 버튼이다. */
+  actions?: ReactNode;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+}
+
+/**
+ * @fox 카드. 그림·글·동작이 위에서 아래로 쌓인다.
+ *
+ * 크기는 여백과 모서리만 바꾼다 — 폭은 놓이는 자리가 정한다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxCard({
+  size = "md",
+  image,
+  title,
+  description,
+  children,
+  actions,
+  hidden = false,
+  className,
+}: FoxCardProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div className={cx("fox-card", `fox-card--${size}`, className)}>
+      {image && <div className="fox-card__image">{image}</div>}
+      <div className="fox-card__contents">
+        {children ?? (
+          <>
+            {title && <p className="fox-card__title">{title}</p>}
+            {description && <p className="fox-card__description">{description}</p>}
+          </>
+        )}
+      </div>
+      {actions && <div className="fox-card__actions">{actions}</div>}
+    </div>
+  );
+}
 
@fox/core/components/fox-card/index.ts (added)
+++ @fox/core/components/fox-card/index.ts
@@ -0,0 +1,1 @@
+export { FoxCard, type FoxCardProps, type FoxCardSize } from "./fox-card";
@fox/core/components/fox-chip-area/fox-chip-area.tsx
--- @fox/core/components/fox-chip-area/fox-chip-area.tsx
+++ @fox/core/components/fox-chip-area/fox-chip-area.tsx
@@ -24,6 +24,8 @@
   label?: string;
   /** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */
   labelledBy?: string;
+  /** 칩이 한 줄에 다 들어가지 않으면 다음 줄로 흘린다(`FoxButtonGroup`과 같은 규약). */
+  wrap?: boolean;
   /** `FoxChip`들. */
   children?: ReactNode;
   /** 배치 조정용. */
@@ -77,6 +79,7 @@
   size,
   label,
   labelledBy,
+  wrap = false,
   children,
   className,
   ref,
@@ -93,7 +96,7 @@
       role="group"
       aria-label={labelledBy ? undefined : label}
       aria-labelledby={labelledBy}
-      className={cx("fox-chip-area", className)}
+      className={cx("fox-chip-area", wrap && "fox-chip-area--wrap", className)}
     >
       {overrideChildren(children, overrides)}
     </div>
 
@fox/core/components/fox-description-list/fox-description-list.tsx (added)
+++ @fox/core/components/fox-description-list/fox-description-list.tsx
@@ -0,0 +1,68 @@
+import type { ReactNode } from "react";
+import { cx } from "../../utils";
+import { FoxFormLabel } from "../fox-form-label";
+
+export interface FoxDescriptionItem {
+  /** 목록 안에서 고유해야 한다. */
+  key: string;
+  /** 항목 이름. */
+  term: ReactNode;
+  /** 값. 글자든 토글이든 무엇이든 온다. */
+  description: ReactNode;
+  /** 참이면 이 줄만 렌더하지 않는다. */
+  hidden?: boolean;
+}
+
+export interface FoxDescriptionListProps {
+  items: FoxDescriptionItem[];
+  hidden?: boolean;
+  className?: string;
+}
+
+/**
+ * @fox 조회 목록 — 이름과 값을 한 쌍씩 세로로 쌓는다. 조회 팝업·상세 화면이 쓰는 모양이다.
+ *
+ * 마크업 계약 (React 밖 소비자용):
+ *   <dl class="fox-description-list">
+ *     <div class="fox-description-list__item">
+ *       <dt class="fox-description-list__term"><span class="fox-form-label">이름</span></dt>
+ *       <dd class="fox-description-list__description">홍길동</dd>
+ *     </div>
+ *   </dl>
+ *
+ * `<dl>`로 그린다 — 이름·값 쌍이라는 관계가 보조기술에 그대로 전달된다. 이름 쪽은 `<dt>` 안에
+ * `FoxFormLabel`을 `span`으로 넣어 서식을 디자인시스템과 맞춘다(`<dt>` 자체가 라벨 요소라
+ * `<label>`을 쓰면 가리킬 대상이 없다).
+ *
+ * 짝을 `<div>`로 감싸는 것은 `<dl>`이 허용하는 유일한 묶음 방법이다 — 줄마다 여백과 구분선을
+ * 주려면 감싸는 상자가 필요하다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxDescriptionList({
+  items,
+  hidden = false,
+  className,
+}: FoxDescriptionListProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <dl className={cx("fox-description-list", className)}>
+      {items
+        .filter((item) => !item.hidden)
+        .map((item) => (
+          <div key={item.key} className="fox-description-list__item">
+            <dt className="fox-description-list__term">
+              <FoxFormLabel as="span">{item.term}</FoxFormLabel>
+            </dt>
+            <dd className="fox-description-list__description">
+              {item.description}
+            </dd>
+          </div>
+        ))}
+    </dl>
+  );
+}
 
@fox/core/components/fox-description-list/index.ts (added)
+++ @fox/core/components/fox-description-list/index.ts
@@ -0,0 +1,1 @@
+export * from "./fox-description-list";
 
@fox/core/components/fox-disclosure/fox-disclosure.tsx (added)
+++ @fox/core/components/fox-disclosure/fox-disclosure.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { useId, useState, type ReactNode } from "react";
+import { cx } from "../../utils";
+import { FoxCaretCircleRightIcon, FoxCaretCircleUpIcon } from "../../icons";
+
+export interface FoxDisclosureProps {
+  title: ReactNode;
+  children?: ReactNode;
+  /** 주면 호출부가 열림을 소유한다. 없으면 컴포넌트가 스스로 여닫는다. */
+  open?: boolean;
+  defaultOpen?: boolean;
+  onOpenChange?: (open: boolean) => void;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+}
+
+/**
+ * @fox 디스클로저. 제목 한 줄을 누르면 아래 내용이 열린다.
+ *
+ * FoxAccordion과 겹쳐 보이지만 시안이 따로 둔 별개 컴포넌트다 — 바탕·테두리·크기 축이
+ * 없고 캐럿이 제목 앞에 온다. 목록을 나누는 아코디언과 달리 글 사이에 끼우는 접기다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxDisclosure({
+  title,
+  children,
+  open,
+  defaultOpen = false,
+  onOpenChange,
+  hidden = false,
+  className,
+}: FoxDisclosureProps) {
+  const autoId = useId();
+  const [inner, setInner] = useState(defaultOpen);
+
+  if (hidden) {
+    return null;
+  }
+
+  const expanded = open ?? inner;
+  const panelId = `${autoId}-panel`;
+
+  const toggle = () => {
+    if (open === undefined) {
+      setInner(!expanded);
+    }
+    onOpenChange?.(!expanded);
+  };
+
+  return (
+    <div
+      className={cx(
+        "fox-disclosure",
+        expanded && "fox-disclosure--open",
+        className
+      )}
+    >
+      <button
+        className="fox-disclosure__tit"
+        type="button"
+        aria-expanded={expanded}
+        aria-controls={panelId}
+        onClick={toggle}
+      >
+        <span className="fox-disclosure__icon" aria-hidden="true">
+          {expanded ? <FoxCaretCircleUpIcon /> : <FoxCaretCircleRightIcon />}
+        </span>
+        <span className="fox-disclosure__title">{title}</span>
+      </button>
+
+      {/* 닫혔을 때는 DOM에서 뺀다 — 안의 초점 가능한 것들이 탭 순서에 남으면 안 된다. */}
+      {expanded && (
+        <div className="fox-disclosure__contents" id={panelId}>
+          {children}
+        </div>
+      )}
+    </div>
+  );
+}
 
@fox/core/components/fox-disclosure/index.ts (added)
+++ @fox/core/components/fox-disclosure/index.ts
@@ -0,0 +1,1 @@
+export { FoxDisclosure, type FoxDisclosureProps } from "./fox-disclosure";
@fox/core/components/fox-file-upload/fox-file-upload.tsx
--- @fox/core/components/fox-file-upload/fox-file-upload.tsx
+++ @fox/core/components/fox-file-upload/fox-file-upload.tsx
@@ -9,7 +9,7 @@
   type CSSProperties,
   type DragEvent,
 } from "react";
-import { FoxFormLabel } from "../fox-form-label";
+import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
 import { cx } from "../../utils";
 import { FoxButton } from "../fox-button";
 import { FoxIconButton } from "../fox-icon-button";
@@ -47,6 +47,11 @@
   accept?: string;
   multiple?: boolean;
   /**
+   * 네이티브 파일 입력의 `name`. 주면 고른 파일이 감싼 `<form>`의 제출에 그대로 실린다 —
+   * 값을 따로 들고 있지 않아도 서버로 넘어간다.
+   */
+  name?: string;
+  /**
    * `default`·`area`에서 목록을 보여준다. `image`에는 목록이 없다.
    * 넘기지 않으면 `area`이거나 여러 개를 받을 때만 켜진다 — 파일 하나는 칸이 곧 목록이다.
    */
@@ -55,6 +60,8 @@
   droppable?: boolean;
 
   label?: string;
+  /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxInput`·`FoxSelect`와 같은 규약). */
+  requirement?: FoxFormLabelRequirement;
   description?: string;
   placeholder?: string;
   areaText?: string;
@@ -188,9 +195,11 @@
   onRemove,
   accept,
   multiple = false,
+  name,
   showList,
   droppable = false,
   label,
+  requirement,
   description,
   placeholder = "파일을 선택해 주세요.",
   areaText = DEFAULT_AREA_TEXT,
@@ -318,7 +327,7 @@
       onDrop={handleDrop}
     >
       {label && (
-        <FoxFormLabel as="span" id={`${autoId}-label`}>
+        <FoxFormLabel as="span" id={`${autoId}-label`} requirement={requirement}>
           {label}
         </FoxFormLabel>
       )}
@@ -327,6 +336,7 @@
       <input
         ref={pickerRef}
         type="file"
+        name={name}
         accept={accept}
         multiple={multiple}
         disabled={disabled}
@fox/core/components/fox-input/fox-input.tsx
--- @fox/core/components/fox-input/fox-input.tsx
+++ @fox/core/components/fox-input/fox-input.tsx
@@ -1,16 +1,25 @@
 "use client";
 
 import {
+  useEffect,
   useId,
   useRef,
   useState,
   type ChangeEvent,
   type ComponentPropsWithRef,
+  type FocusEvent,
   type FormEvent,
   type ReactNode,
 } from "react";
 import { cx } from "../../utils";
-import { FoxFormLabel } from "../fox-form-label";
+import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
+import {
+  foxValidationMessage,
+  foxValidators,
+  type FoxValidationErrors,
+  type FoxValidationMessages,
+  type FoxValidator,
+} from "../../validation";
 
 export type FoxInputSize = "lg" | "md" | "sm";
 export type FoxInputState = "default" | "focused" | "completed" | "error" | "disabled" | "view";
@@ -31,12 +40,16 @@
   onChange?: (value: string, event?: ChangeEvent<HTMLInputElement>) => void;
   onInput?: (value: string, event: FormEvent<HTMLInputElement>) => void;
   label?: string;
+  /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxSelect`와 같은 규약). */
+  requirement?: FoxFormLabelRequirement;
   /** 헬퍼 메시지. 색은 상태를 따라간다. */
   message?: string;
   /** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */
   messageIcon?: ReactNode;
   /** 입력 오른쪽 아이콘. `currentColor`로 그린 SVG여야 색이 적용된다. */
   icon?: ReactNode;
+  /** 입력 오른쪽에 붙는 글자. 아이콘 슬롯과 달리 크기가 글자에 맞춰진다. */
+  suffix?: ReactNode;
   /** 포커스 중이고 값이 있으면 지우기 버튼을 보여준다. */
   clearable?: boolean;
   clearLabel?: string;
@@ -44,6 +57,24 @@
   invalid?: boolean;
   /** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */
   state?: FoxInputState;
+
+  /**
+   * 값 검증기. 여럿이면 오류가 합쳐지고(`foxValidators.compose`와 같다) 그중 하나가 문구가 된다.
+   * `type="password"`에는 `foxPasswordValidator(...)`를 그대로 얹으면 된다.
+   *
+   * 화면 검증은 **안내일 뿐 신뢰 경계가 아니다** — 저장 직전의 서버 검증이 최종 판정이다.
+   */
+  validators?: FoxValidator[];
+  /** 오류 키별 문구 덮어쓰기. 주지 않으면 @fox의 기본 문구가 나온다. */
+  validationMessages?: FoxValidationMessages;
+  /**
+   * 언제부터 오류를 보여줄지. 기본 `blur` — 한 글자 쳤을 때 "10자 이상"이 뜨면 안내가 아니라
+   * 방해가 된다. 한 번 벗어난 뒤로는 두 모드 모두 입력할 때마다 갱신된다(Angular의 `touched`와
+   * 같은 판단이다. 다만 Angular의 `updateOn` 기본값은 `change`라 그 점만 다르다).
+   */
+  validateOn?: "blur" | "change";
+  /** 검증 결과가 바뀔 때. 호출부가 저장 버튼을 잠그는 데 쓴다. */
+  onValidationChange?: (errors: FoxValidationErrors | null) => void;
   /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
   hidden?: boolean;
   /** 배치 조정용. */
@@ -65,18 +96,29 @@
  * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
  * (또는 개별 파티셜)로 한 번 불러와야 한다.
  */
+/** 오류 객체를 비교 가능한 문자열로. 매 렌더 새 객체가 나와 참조로는 견줄 수 없다. */
+function errorKey(errors: FoxValidationErrors | null): string {
+  return errors ? Object.keys(errors).sort().join("|") : "";
+}
+
 export function FoxInput({
   size = "md",
   onChange,
   onInput,
   label,
+  requirement,
   message,
   messageIcon,
   icon,
+  suffix,
   clearable = false,
   clearLabel = "입력 지우기",
   invalid = false,
   state,
+  validators,
+  validationMessages,
+  validateOn = "blur",
+  onValidationChange,
   hidden = false,
   className,
   id,
@@ -91,6 +133,28 @@
   const [typedIn, setTypedIn] = useState(() => String(defaultValue ?? "").length > 0);
   const fieldRef = useRef<HTMLInputElement>(null);
 
+  // 값을 밖에서 들지 않는(비제어) 호출부도 있어 검증할 값을 직접 따라간다.
+  const [draft, setDraft] = useState(() => String(defaultValue ?? ""));
+  const [touched, setTouched] = useState(false);
+
+  const validationErrors =
+    validators && validators.length > 0
+      ? foxValidators.compose(validators)(
+          value !== undefined ? String(value) : draft
+        )
+      : null;
+
+  // 알림은 렌더가 끝난 뒤에 낸다 — 렌더 도중 부모 상태를 바꾸면 React가 경고한다.
+  // 매 렌더 새 객체가 나오므로 의존은 오류 **키 목록**으로 잡는다(값 자체는 ref로 읽는다).
+  const errorsKey = errorKey(validationErrors);
+  const latestRef = useRef({ errors: validationErrors, notify: onValidationChange });
+  useEffect(() => {
+    latestRef.current = { errors: validationErrors, notify: onValidationChange };
+  });
+  useEffect(() => {
+    latestRef.current.notify?.(latestRef.current.errors);
+  }, [errorsKey]);
+
   if (hidden) {
     return null;
   }
@@ -98,7 +162,19 @@
   const fieldId = id ?? `${autoId}-field`;
   const messageId = `${autoId}-message`;
   const hasValue = value !== undefined ? String(value).length > 0 : typedIn;
-  const errored = invalid || state === "error";
+
+  const showValidation = validateOn === "change" || touched;
+  const validationText = showValidation
+    ? foxValidationMessage(validationErrors, validationMessages)
+    : undefined;
+
+  // `message`는 평소엔 **헬퍼**이고 검증이 걸리면 그 문구에 자리를 내준다(시안이 그 자리에
+  // 규칙 안내를 두고, 규칙을 어기면 같은 자리가 사유로 바뀐다).
+  // 다만 호출부가 `invalid`를 직접 켰으면 그쪽이 이긴다 — 서버가 돌려준 사유는 화면 규칙이
+  // 알 수 없는 것이라(중복·권한 등) 덮이면 안 된다.
+  const callerErrored = invalid || state === "error";
+  const shownMessage = callerErrored ? message : (validationText ?? message);
+  const errored = callerErrored || validationText !== undefined;
 
   // 호출부 ref와 내부 ref를 함께 채운다 — 지우기가 비제어 입력의 DOM 값을 비워야 한다.
   const attachField = (node: HTMLInputElement | null) => {
@@ -114,7 +190,13 @@
     if (value === undefined) {
       setTypedIn(event.target.value.length > 0);
     }
+    setDraft(event.target.value);
     onChange?.(event.target.value, event);
+  };
+
+  const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
+    setTouched(true);
+    rest.onBlur?.(event);
   };
 
   const handleInput = (event: FormEvent<HTMLInputElement>) => {
@@ -129,6 +211,7 @@
       field.focus();
     }
     setTypedIn(false);
+    setDraft("");
     onChange?.("");
   };
 
@@ -138,7 +221,9 @@
       data-state={state}
     >
       {label && (
-        <FoxFormLabel htmlFor={fieldId}>{label}</FoxFormLabel>
+        <FoxFormLabel htmlFor={fieldId} requirement={requirement}>
+          {label}
+        </FoxFormLabel>
       )}
 
       <div className="fox-input__box">
@@ -150,9 +235,10 @@
           value={value}
           defaultValue={defaultValue}
           aria-invalid={errored || undefined}
-          aria-describedby={message ? messageId : undefined}
+          aria-describedby={shownMessage ? messageId : undefined}
           onChange={handleChange}
           onInput={handleInput}
+          onBlur={handleBlur}
         />
 
         {clearable && hasValue && (
@@ -170,6 +256,8 @@
           </button>
         )}
 
+        {suffix && <span className="fox-input__suffix">{suffix}</span>}
+
         {icon && (
           <span className="fox-input__icon" aria-hidden="true">
             {icon}
@@ -177,14 +265,14 @@
         )}
       </div>
 
-      {message && (
+      {shownMessage && (
         <p className="fox-input__message" id={messageId}>
           {messageIcon && (
             <span className="fox-input__message-icon" aria-hidden="true">
               {messageIcon}
             </span>
           )}
-          {message}
+          {shownMessage}
         </p>
       )}
     </div>
 
@fox/core/components/fox-list-container/fox-list-container.tsx (added)
+++ @fox/core/components/fox-list-container/fox-list-container.tsx
@@ -0,0 +1,289 @@
+"use client";
+
+import { useState, type ReactNode } from "react";
+import { cx } from "../../utils";
+import { FoxPageHeader } from "../fox-page-header";
+import type { FoxBreadcrumbItem } from "../fox-breadcrumb";
+import { FoxTable } from "../fox-table";
+import { FoxTableRow } from "../fox-table-row";
+import { FoxTableCell } from "../fox-table-cell";
+import type { FoxTableCellAlign } from "../fox-table-cell";
+import { FoxPagination } from "../fox-pagination";
+import { FoxListCount, FoxListToolbar } from "../fox-list-toolbar";
+import { FoxListSearch } from "../fox-list-search";
+import type { FoxSelectItem } from "../fox-select";
+
+/** 열 하나의 정의. `render`가 있으면 그 결과가 셀 내용이 된다. */
+export interface FoxListColumn<T> {
+  /** 열 식별자. `render`가 없으면 `row[key]`를 그대로 그린다. */
+  key: string;
+  header: ReactNode;
+  /** 셀을 직접 그린다 — 배지·버튼 무엇이든 넣을 수 있다. */
+  render?: (row: T, index: number) => ReactNode;
+  width?: string | number;
+  align?: FoxTableCellAlign;
+  /** 참이면 그 열의 본문 글자가 진해진다(시안의 강조 열). */
+  emphasis?: boolean;
+}
+
+/**
+ * 목록 상태의 변경분. 컨테이너는 **바뀐 것만** 담아 넘긴다 — 호출부가 기존 값과 합쳐
+ * 쓰라는 뜻이다. 화면마다 필요한 정렬·필터 키가 달라 열려 있는 모양이다.
+ */
+export interface FoxListQueryPatch {
+  page?: number;
+  keyword?: string;
+  searchField?: string;
+  [key: string]: unknown;
+}
+
+export interface FoxListContainerProps<T> {
+  // ── 머리말 ────────────────────────────────────────────────────────────────
+  title?: ReactNode;
+  breadcrumb?: FoxBreadcrumbItem[];
+  headerActions?: ReactNode;
+  /**
+   * 머리말과 목록 사이. 시안에서 이 자리에 오는 것은 `FoxTab`이다 — 목록 전체를 갈아 끼우는
+   * 전환이라 도구 줄(정렬·검색)보다 위에 선다.
+   */
+  tabs?: ReactNode;
+
+  // ── 표 ────────────────────────────────────────────────────────────────────
+  columns: FoxListColumn<T>[];
+  rows: T[];
+  /** 행의 안정적인 키. 인덱스를 쓰면 정렬·페이지 이동에서 어긋난다. */
+  rowKey: (row: T, index: number) => string;
+  caption?: string;
+  /** 행이 없을 때 표 안에 보여줄 말. */
+  emptyMessage?: ReactNode;
+  /** 주면 행 전체가 눌린다 — 관리 열 없이 행을 눌러 상세를 여는 시안에 쓴다. */
+  onRowSelect?: (row: T, index: number) => void;
+
+  // ── 도구 줄 ───────────────────────────────────────────────────────────────
+  /** 주면 "총 N건"이 나온다. 없으면 그 자리를 비운다. */
+  totalCount?: number;
+  toolbarLeading?: ReactNode;
+  sorts?: ReactNode;
+  toolbarActions?: ReactNode;
+  filter?: ReactNode;
+
+  /** 검색 대상 목록. 주면 검색 상자가 나온다. 대상이 하나뿐이면 빈 배열로 두면 된다. */
+  searchFields?: FoxSelectItem[];
+  /** 지금 **적용된** 검색 조건이다(입력 중인 값이 아니다). 초안은 컨테이너가 따로 든다. */
+  searchField?: string;
+  keyword?: string;
+  searchPlaceholder?: string;
+  /** 검색 상자를 아예 숨긴다. */
+  searchHidden?: boolean;
+
+  // ── 페이지 ────────────────────────────────────────────────────────────────
+  page?: number;
+  totalPages?: number;
+
+  // ── 이벤트 ────────────────────────────────────────────────────────────────
+  /**
+   * 주면 페이지 이동이 **링크**가 된다 — 목록이 Server Component로 남고 새로고침·뒤로가기·
+   * URL 공유가 그대로 동작한다. 이 저장소의 목록 화면들이 쓰는 방식이다.
+   */
+  buildHref?: (patch: FoxListQueryPatch) => string;
+  /** `buildHref`가 없을 때(모달 안 목록 등) 쓰는 콜백. 둘 다 주면 링크가 이긴다. */
+  onQueryChange?: (patch: FoxListQueryPatch) => void;
+
+  hidden?: boolean;
+  className?: string;
+}
+
+/**
+ * @fox 목록 화면 뼈대 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container (3019:8608)
+ *
+ * 머리말 · 도구 줄 · 표 · 페이지네이션을 한 벌로 묶는다. **데이터를 가져오지도, 상태를 갖지도
+ * 않는다** — 받은 것을 그리고 바뀐 것을 알릴 뿐이라 어떤 목록에도 그대로 쓸 수 있다.
+ *
+ * 표는 `columns` 정의로 그린다. `render`가 ReactNode를 돌려주므로 셀 안에 배지든 버튼 묶음이든
+ * 들어간다(시안이 그렇다) — 값의 형식을 컨테이너가 알 필요가 없다.
+ *
+ * 이벤트는 두 갈래다. `buildHref`를 주면 링크로 그려 URL이 상태가 되고, `onQueryChange`만
+ * 주면 콜백으로 알린다. FoxPagination이 이미 같은 방식이라 그 관례를 따랐다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxListContainer<T>({
+  title,
+  breadcrumb,
+  headerActions,
+  tabs,
+  columns,
+  rows,
+  rowKey,
+  caption,
+  emptyMessage = "조회된 내용이 없습니다.",
+  onRowSelect,
+  totalCount,
+  toolbarLeading,
+  sorts,
+  toolbarActions,
+  filter,
+  searchFields,
+  searchField,
+  keyword,
+  searchPlaceholder,
+  searchHidden = false,
+  page = 1,
+  totalPages = 1,
+  buildHref,
+  onQueryChange,
+  hidden = false,
+  className,
+}: FoxListContainerProps<T>) {
+  // 검색 상자는 **누르기 전까지의 입력 초안**을 여기서 든다. `keyword`·`searchField`는 이미
+  // 검색이 반영된 결과(주소·서버 응답)라, 그대로 FoxListSearch에 제어 값으로 내려보내면
+  // 변경 핸들러가 없어 글자도 검색 대상도 바뀌지 않는다.
+  const applied = { keyword: keyword ?? "", field: searchField };
+  const [draft, setDraft] = useState(applied);
+  const [lastApplied, setLastApplied] = useState(applied);
+
+  // 검색이 끝나 밖의 값이 바뀌면 초안을 그 값으로 되돌린다 — 뒤로가기·초기화처럼 화면이 아니라
+  // 주소가 검색어를 바꾸는 경로에서도 입력창이 따라간다. effect가 아니라 렌더 중에 맞추는 것은
+  // React가 권하는 방식이다(한 번 더 그리지 않고 이 렌더에 반영된다).
+  if (
+    lastApplied.keyword !== applied.keyword ||
+    lastApplied.field !== applied.field
+  ) {
+    setLastApplied(applied);
+    setDraft(applied);
+  }
+
+  if (hidden) {
+    return null;
+  }
+
+  const hasToolbar =
+    totalCount !== undefined ||
+    toolbarLeading !== undefined ||
+    sorts !== undefined ||
+    toolbarActions !== undefined ||
+    filter !== undefined ||
+    !searchHidden;
+
+  return (
+    <div className={cx("fox-list-container", className)}>
+      {(title !== undefined || breadcrumb !== undefined) && (
+        <FoxPageHeader
+          title={title}
+          breadcrumb={breadcrumb}
+          actions={headerActions}
+        />
+      )}
+
+      {tabs}
+
+      <section className="fox-list-container__section">
+        {hasToolbar && (
+          <FoxListToolbar
+            count={
+              totalCount !== undefined ? (
+                <FoxListCount total={totalCount} />
+              ) : undefined
+            }
+            leading={toolbarLeading}
+            sorts={sorts}
+            search={
+              searchHidden ? undefined : (
+                <FoxListSearch
+                  fields={searchFields}
+                  field={draft.field}
+                  onFieldChange={(next) =>
+                    setDraft((current) => ({ ...current, field: next }))
+                  }
+                  keyword={draft.keyword}
+                  onKeywordChange={(next) =>
+                    setDraft((current) => ({ ...current, keyword: next }))
+                  }
+                  placeholder={searchPlaceholder}
+                  onSearch={(nextKeyword, nextField) =>
+                    onQueryChange?.({
+                      keyword: nextKeyword,
+                      searchField: nextField,
+                      // 검색 결과는 항상 첫 장부터 본다 — 남아 있던 페이지 번호를 지운다.
+                      page: 1,
+                    })
+                  }
+                />
+              )
+            }
+            actions={toolbarActions}
+            filter={filter}
+          />
+        )}
+
+        {/* 열이 많으면 표가 화면보다 넓어진다. 눌러 담지 않고 이 래퍼가 가로로 스크롤한다. */}
+        <div className="fox-list-container__table">
+        {/* `thead`/`tbody`로 감싸지 않는다 — FoxTable이 스스로 만들고, 머리행은 `head`만 보고
+            제자리로 보낸다. 감싸면 표 안에 표 구조가 이중으로 생긴다. */}
+        <FoxTable type="list" caption={caption}>
+          <FoxTableRow head>
+            {columns.map((column) => (
+              <FoxTableCell
+                key={column.key}
+                head
+                width={column.width}
+                align={column.align}
+              >
+                {column.header}
+              </FoxTableCell>
+            ))}
+          </FoxTableRow>
+
+          {rows.length === 0 ? (
+            <FoxTableRow>
+              <FoxTableCell colSpan={columns.length} align="center">
+                {emptyMessage}
+              </FoxTableCell>
+            </FoxTableRow>
+          ) : (
+            rows.map((row, index) => (
+              <FoxTableRow
+                key={rowKey(row, index)}
+                onSelect={
+                  onRowSelect ? () => onRowSelect(row, index) : undefined
+                }
+              >
+                {columns.map((column) => (
+                  <FoxTableCell
+                    key={column.key}
+                    align={column.align}
+                    emphasis={column.emphasis}
+                    label={
+                      typeof column.header === "string"
+                        ? column.header
+                        : undefined
+                    }
+                  >
+                    {column.render
+                      ? column.render(row, index)
+                      : String(
+                          (row as Record<string, unknown>)[column.key] ?? ""
+                        )}
+                  </FoxTableCell>
+                ))}
+              </FoxTableRow>
+            ))
+          )}
+          </FoxTable>
+        </div>
+
+        {totalPages > 1 && (
+          <FoxPagination
+            currentPage={page}
+            totalPages={totalPages}
+            buildHref={buildHref ? (next) => buildHref({ page: next }) : undefined}
+            onPageChange={
+              buildHref ? undefined : (next) => onQueryChange?.({ page: next })
+            }
+          />
+        )}
+      </section>
+    </div>
+  );
+}
 
@fox/core/components/fox-list-container/index.ts (added)
+++ @fox/core/components/fox-list-container/index.ts
@@ -0,0 +1,1 @@
+export * from "./fox-list-container";
 
@fox/core/components/fox-list-search/fox-list-search.tsx (added)
+++ @fox/core/components/fox-list-search/fox-list-search.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import { useState, type FormEvent } from "react";
+import { cx } from "../../utils";
+import { FoxSelectText } from "../fox-select-text";
+import type { FoxSelectItem } from "../fox-select";
+import { FoxMagnifyingGlassIcon } from "../../icons";
+
+export interface FoxListSearchProps {
+  /** 앞쪽 셀렉트에 넣을 검색 대상. 비우면 셀렉트 자체가 나오지 않는다. */
+  fields?: FoxSelectItem[];
+  /** 선택된 검색 대상(제어). */
+  field?: string;
+  onFieldChange?: (field: string) => void;
+  /** 입력값(제어). 주지 않으면 컴포넌트가 스스로 들고 있다. */
+  keyword?: string;
+  onKeywordChange?: (keyword: string) => void;
+  /** 돋보기를 누르거나 Enter를 쳤을 때. 현재 검색어와 대상을 함께 넘긴다. */
+  onSearch?: (keyword: string, field?: string) => void;
+  placeholder?: string;
+  /** 글자가 없으므로 이 값이 검색 버튼의 접근성 이름이 된다. */
+  label?: string;
+  disabled?: boolean;
+  hidden?: boolean;
+  className?: string;
+}
+
+/**
+ * @fox 목록 검색 상자 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) list-toolbar의 search
+ *
+ * 마크업 계약 (React 밖 소비자용):
+ *   <form class="fox-list-search">
+ *     <div class="fox-list-search__box">
+ *       <span class="fox-list-search__field">…select-txt…</span>   <!-- 선택 -->
+ *       <input class="fox-list-search__input">
+ *       <button class="fox-list-search__submit">…svg…</button>
+ *     </div>
+ *   </form>
+ *
+ * `FoxInput`을 쓰지 않는다 — 시안은 입력칸 **앞쪽**에 검색 대상 셀렉트가 들어가는데 FoxInput에
+ * 그 슬롯이 없다. 크기·radius·테두리는 시안이 지정한 값이 FoxInput md와 같지만, 좌우 여백만
+ * `form/padding/sm`으로 더 좁다(안에 셀렉트와 버튼이 들어가기 때문이다).
+ *
+ * `<form>`이라 Enter가 그대로 제출이 된다 — 키 이벤트를 따로 듣지 않는다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxListSearch({
+  fields,
+  field,
+  onFieldChange,
+  keyword,
+  onKeywordChange,
+  onSearch,
+  placeholder = "검색어를 입력하세요.",
+  label = "검색",
+  disabled = false,
+  hidden = false,
+  className,
+}: FoxListSearchProps) {
+  const [innerKeyword, setInnerKeyword] = useState(keyword ?? "");
+  const controlled = keyword !== undefined;
+  const value = controlled ? keyword : innerKeyword;
+
+  if (hidden) {
+    return null;
+  }
+
+  function handleChange(next: string) {
+    if (!controlled) {
+      setInnerKeyword(next);
+    }
+    onKeywordChange?.(next);
+  }
+
+  function handleSubmit(event: FormEvent<HTMLFormElement>) {
+    event.preventDefault();
+    onSearch?.(value, field);
+  }
+
+  return (
+    <form
+      className={cx("fox-list-search", className)}
+      role="search"
+      onSubmit={handleSubmit}
+    >
+      <div className="fox-list-search__box">
+        {fields && fields.length > 0 && (
+          <span className="fox-list-search__field">
+            <FoxSelectText
+              size="sm"
+              options={fields}
+              value={field}
+              onValueChange={onFieldChange}
+              ariaLabel="검색 대상"
+              disabled={disabled}
+            />
+          </span>
+        )}
+        <input
+          className="fox-list-search__input"
+          type="search"
+          value={value}
+          placeholder={placeholder}
+          disabled={disabled}
+          aria-label={placeholder}
+          onChange={(event) => handleChange(event.target.value)}
+        />
+        {/* 시안의 이 버튼은 btn-ico 인스턴스가 아니라 로컬 프레임(32×32)이라 FoxIconButton의
+            크기 체계(24·28·40·56)와 맞지 않는다. 그래서 직접 그린다. */}
+        <button
+          className="fox-list-search__submit"
+          type="submit"
+          aria-label={label}
+          disabled={disabled}
+        >
+          <FoxMagnifyingGlassIcon />
+        </button>
+      </div>
+    </form>
+  );
+}
 
@fox/core/components/fox-list-search/index.ts (added)
+++ @fox/core/components/fox-list-search/index.ts
@@ -0,0 +1,1 @@
+export * from "./fox-list-search";
 
@fox/core/components/fox-list-toolbar/fox-list-toolbar.tsx (added)
+++ @fox/core/components/fox-list-toolbar/fox-list-toolbar.tsx
@@ -0,0 +1,98 @@
+import type { ReactNode } from "react";
+import { cx } from "../../utils";
+
+export interface FoxListToolbarProps {
+  /** 좌측 첫 자리. 보통 "총 N건"이다 — `FoxListCount`가 시안 서식을 그린다. */
+  count?: ReactNode;
+  /** 총 건수 옆 액션(전체 선택·선택 삭제 등). */
+  leading?: ReactNode;
+  /** 우측 정렬·조건 셀렉트 묶음. */
+  sorts?: ReactNode;
+  /** 우측 검색 상자. */
+  search?: ReactNode;
+  /** 우측 끝 액션(등록·엑셀 등). */
+  actions?: ReactNode;
+  /** 아래 줄의 필터. 주지 않으면 그 줄 자체가 렌더되지 않는다(구분선도 함께 사라진다). */
+  filter?: ReactNode;
+  hidden?: boolean;
+  className?: string;
+}
+
+/**
+ * @fox 목록 도구 줄 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) list-toolbar (3002:8151)
+ *
+ * 마크업 계약 (React 밖 소비자용):
+ *   <div class="fox-list-toolbar">
+ *     <div class="fox-list-toolbar__base">
+ *       <div class="fox-list-toolbar__left">…총 N건 · 액션…</div>
+ *       <div class="fox-list-toolbar__right">…셀렉트 · 검색 · 액션…</div>
+ *     </div>
+ *     <div class="fox-list-toolbar__filter">…필터…</div>   <!-- 선택 -->
+ *   </div>
+ *
+ * **자리만 잡는다.** 무엇이 들어갈지는 화면마다 다르므로 전부 슬롯으로 받는다 — 셀렉트가
+ * 몇 개인지, 액션이 버튼인지 아이콘인지 이 컴포넌트는 알지 못한다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxListToolbar({
+  count,
+  leading,
+  sorts,
+  search,
+  actions,
+  filter,
+  hidden = false,
+  className,
+}: FoxListToolbarProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div className={cx("fox-list-toolbar", className)}>
+      <div className="fox-list-toolbar__base">
+        <div className="fox-list-toolbar__left">
+          {count}
+          {leading}
+        </div>
+        <div className="fox-list-toolbar__right">
+          {sorts && <div className="fox-list-toolbar__sorts">{sorts}</div>}
+          {search}
+          {actions}
+        </div>
+      </div>
+
+      {filter && <div className="fox-list-toolbar__filter">{filter}</div>}
+    </div>
+  );
+}
+
+export interface FoxListCountProps {
+  /** 전체 건수. */
+  total: number;
+  /** 숫자 앞뒤 문구. 기본은 시안의 "총 N건". */
+  prefix?: ReactNode;
+  suffix?: ReactNode;
+  className?: string;
+}
+
+/**
+ * 총 건수 표시 — 시안이 숫자만 굵고 다른 색이라 별도 조각으로 둔다. 천 단위 구분은
+ * 호출부의 로케일을 타지 않도록 `toLocaleString("ko-KR")`로 고정한다.
+ */
+export function FoxListCount({
+  total,
+  prefix = "총 ",
+  suffix = "건",
+  className,
+}: FoxListCountProps) {
+  return (
+    <p className={cx("fox-list-count", className)}>
+      {prefix}
+      <b className="fox-list-count__value">{total.toLocaleString("ko-KR")}</b>
+      {suffix}
+    </p>
+  );
+}
 
@fox/core/components/fox-list-toolbar/index.ts (added)
+++ @fox/core/components/fox-list-toolbar/index.ts
@@ -0,0 +1,1 @@
+export * from "./fox-list-toolbar";
 
@fox/core/components/fox-modal/fox-modal.tsx (added)
+++ @fox/core/components/fox-modal/fox-modal.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { useEffect, useRef, type ReactNode } from "react";
+import { cx } from "../../utils";
+import { FoxXIcon } from "../../icons";
+
+export type FoxModalSize = "sm" | "md" | "lg" | "full";
+
+export interface FoxModalProps {
+  /** 열림 여부. 호출부가 소유한다 — 모달이 스스로 닫지 않는다. */
+  open: boolean;
+  /** 닫기 버튼·Esc·바깥 클릭에서 부른다. */
+  onClose: () => void;
+  children?: ReactNode;
+  size?: FoxModalSize;
+  /** 넘기지 않으면 제목 줄을 그리지 않는다. */
+  title?: ReactNode;
+  /** 아래쪽 오른편. 보통 버튼 묶음이다. */
+  actions?: ReactNode;
+  /** 아래쪽 왼편. 시안은 "오늘 하루 보지 않기" 같은 체크박스 자리다. */
+  utility?: ReactNode;
+  /** 닫기 버튼을 감춘다. */
+  closable?: boolean;
+  closeLabel?: string;
+  /** 바깥을 눌러도 닫히지 않게 한다. */
+  dismissible?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+}
+
+/**
+ * @fox 모달. 네이티브 `<dialog>`를 쓴다 — 초점 가두기·Esc 닫기·뒷배경·맨 위 레이어를
+ * 브라우저가 처리하므로 그걸 직접 구현하지 않는다.
+ *
+ * 열림 여부는 호출부가 소유한다. 사용자가 Esc를 누르거나 바깥을 눌러도 모달이 스스로
+ * 닫지 않고 `onClose`만 부른다 — 닫을지 말지는 호출부가 정한다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxModal({
+  open,
+  onClose,
+  children,
+  size = "md",
+  title,
+  actions,
+  utility,
+  closable = true,
+  closeLabel = "닫기",
+  dismissible = true,
+  className,
+}: FoxModalProps) {
+  const ref = useRef<HTMLDialogElement>(null);
+
+  // `open` 속성만 세우면 뒷배경도 초점 가두기도 없는 그냥 상자가 된다. 모달로 띄우려면
+  // `showModal()`을 불러야 한다.
+  useEffect(() => {
+    const dialog = ref.current;
+    if (!dialog) {
+      return;
+    }
+    if (open && !dialog.open) {
+      dialog.showModal();
+    } else if (!open && dialog.open) {
+      dialog.close();
+    }
+  }, [open]);
+
+  return (
+    <dialog
+      ref={ref}
+      className={cx("fox-modal", `fox-modal--${size}`, className)}
+      // Esc는 브라우저가 닫아 버리므로 막고, 닫을지는 호출부가 정하게 한다.
+      onCancel={(event) => {
+        event.preventDefault();
+        onClose();
+      }}
+      onClick={(event) => {
+        // `<dialog>`는 뒷배경을 눌러도 자기 자신이 대상이 된다 — 상자 밖을 눌렀는지는
+        // 좌표로 가린다.
+        if (!dismissible || event.target !== event.currentTarget) {
+          return;
+        }
+        const box = event.currentTarget.getBoundingClientRect();
+        const outside =
+          event.clientX < box.left ||
+          event.clientX > box.right ||
+          event.clientY < box.top ||
+          event.clientY > box.bottom;
+        if (outside) {
+          onClose();
+        }
+      }}
+    >
+      <div className="fox-modal__body">
+        {/* 제목이 없어도 줄은 남긴다 — 그래야 닫기 버튼과 내용이 겹치지 않는다.
+            빈 제목을 심으면 이름 없는 heading이 되므로 `<h2>`는 있을 때만 낸다. */}
+        <div className="fox-modal__tit">{title && <h2 className="fox-modal__title">{title}</h2>}</div>
+        <div className="fox-modal__contents">{children}</div>
+      </div>
+
+      {(utility || actions) && (
+        <div className="fox-modal__foot">
+          {utility && <div className="fox-modal__utility">{utility}</div>}
+          {actions && <div className="fox-modal__actions">{actions}</div>}
+        </div>
+      )}
+
+      {closable && (
+        <button className="fox-modal__close" type="button" aria-label={closeLabel} onClick={onClose}>
+          <span className="fox-modal__close-icon" aria-hidden="true">
+            <FoxXIcon />
+          </span>
+        </button>
+      )}
+    </dialog>
+  );
+}
 
@fox/core/components/fox-modal/index.ts (added)
+++ @fox/core/components/fox-modal/index.ts
@@ -0,0 +1,1 @@
+export { FoxModal, type FoxModalProps, type FoxModalSize } from "./fox-modal";
 
@fox/core/components/fox-page-header/fox-page-header.tsx (added)
+++ @fox/core/components/fox-page-header/fox-page-header.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { cx } from "../../utils";
+import { FoxBreadcrumb, type FoxBreadcrumbItem } from "../fox-breadcrumb";
+
+export interface FoxPageHeaderProps {
+  /** 화면 제목. 페이지에 하나뿐인 이름이라 `<h1>`으로 나간다. */
+  title: ReactNode;
+  /** 제목 위에 붙는 현재 위치. 넘기지 않으면 영역을 그리지 않는다. */
+  breadcrumb?: FoxBreadcrumbItem[];
+  /**
+   * 제목 오른쪽 자리. 시안에는 들어간 것이 없지만 배치가 자리를 비워 두고 있어
+   * (제목이 남는 폭을 먹고 24px 간격이 있다) 화면이 버튼 같은 것을 얹을 수 있게 연다.
+   */
+  actions?: ReactNode;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+}
+
+/**
+ * @fox 화면 머리말. 현재 위치와 제목을 얹고 본문과의 간격까지 갖는다.
+ *
+ * 현재 위치는 `FoxBreadcrumb`를 그대로 쓴다 — 시안에서도 breadcrumb 인스턴스다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxPageHeader({
+  title,
+  breadcrumb,
+  actions,
+  hidden = false,
+  className,
+}: FoxPageHeaderProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div className={cx("fox-page-header", className)}>
+      <div className="fox-page-header__main">
+        {breadcrumb && breadcrumb.length > 0 && <FoxBreadcrumb items={breadcrumb} />}
+        <h1 className="fox-page-header__title">{title}</h1>
+      </div>
+      {actions && <div className="fox-page-header__actions">{actions}</div>}
+    </div>
+  );
+}
 
@fox/core/components/fox-page-header/index.ts (added)
+++ @fox/core/components/fox-page-header/index.ts
@@ -0,0 +1,1 @@
+export { FoxPageHeader, type FoxPageHeaderProps } from "./fox-page-header";
@fox/core/components/fox-select-text/fox-select-text.tsx
--- @fox/core/components/fox-select-text/fox-select-text.tsx
+++ @fox/core/components/fox-select-text/fox-select-text.tsx
@@ -36,6 +36,11 @@
    * ("2026년 8월")이라 무엇을 고르는 자리인지는 소리로 전달되지 않는다 — `"조회 기간"`처럼
    * 적어 준다.
    */
+  /**
+   * 오른쪽 표시를 바꾼다. 기본은 펼침을 뜻하는 캐럿이고, 시안이 다른 뜻을 담을 때만 준다
+   * (예: 정렬 셀렉트의 위아래 화살표). 장식이라 `aria-hidden`인 자리다.
+   */
+  icon?: ReactNode;
   ariaLabel?: string;
   /** 이름 역할을 하는 요소의 id. `ariaLabel`보다 우선한다. */
   labelledBy?: string;
@@ -76,6 +81,7 @@
   onValueChange,
   placeholder = "선택해주세요",
   disabled = false,
+  icon,
   ariaLabel,
   labelledBy,
   id,
@@ -219,7 +225,7 @@
       >
         <span className="fox-select-text__value">{selectedOption?.label ?? placeholder}</span>
         <span className="fox-select-text__icon" aria-hidden="true">
-          <ChevronMark />
+          {icon ?? <ChevronMark />}
         </span>
       </button>
 
@fox/core/components/fox-table-row/fox-table-row.tsx
--- @fox/core/components/fox-table-row/fox-table-row.tsx
+++ @fox/core/components/fox-table-row/fox-table-row.tsx
@@ -16,6 +16,14 @@
   head?: boolean;
   /** 참이면 고른 줄이 된다 — 바탕과 글자가 파랗게 바뀐다(시안 list 표). */
   selected?: boolean;
+  /**
+   * 주면 행 전체가 눌린다 — 시안이 관리 열 없이 행을 눌러 상세를 여는 경우다.
+   *
+   * `<tr>`에 `role="button"`을 씌우지 않는다 — 그러면 보조기술에서 행이 표의 일부가 아니게
+   * 되어 열 이름 읽기가 끊긴다. 대신 초점을 받게만 하고 Enter·Space를 직접 듣는다. 행의
+   * 이름은 셀 내용이 대신하므로 눌러야 할 행이 무엇인지 알 수 있다.
+   */
+  onSelect?: () => void;
   /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
   hidden?: boolean;
   /** 배치 조정용. */
@@ -52,6 +60,7 @@
   children,
   head = false,
   selected = false,
+  onSelect,
   hidden = false,
   className,
 }: FoxTableRowProps) {
@@ -59,15 +68,31 @@
     return null;
   }
 
+  const clickable = Boolean(onSelect) && !head;
+
   return (
     <tr
       className={cx(
         "fox-table-row",
         head && "fox-table-row--head",
         selected && "fox-table-row--selected",
+        clickable && "fox-table-row--clickable",
         className
       )}
       aria-selected={selected || undefined}
+      tabIndex={clickable ? 0 : undefined}
+      onClick={clickable ? onSelect : undefined}
+      onKeyDown={
+        clickable
+          ? (event) => {
+              // Space는 기본 동작이 스크롤이라 막는다.
+              if (event.key === "Enter" || event.key === " ") {
+                event.preventDefault();
+                onSelect?.();
+              }
+            }
+          : undefined
+      }
     >
       {head ? markCells(children) : children}
     </tr>
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -1,3 +1,4 @@
+export * from "./fox-accordion";
 export * from "./fox-address";
 export * from "./fox-alert";
 export * from "./fox-badge";
@@ -9,6 +10,7 @@
 export * from "./fox-button";
 export * from "./fox-button-group";
 export * from "./fox-button-panel";
+export * from "./fox-card";
 export * from "./fox-carousel-area";
 export * from "./fox-carousel-button";
 export * from "./fox-carousel-indicator";
@@ -21,6 +23,7 @@
 export * from "./fox-chip-select-option";
 export * from "./fox-conditional-option";
 export * from "./fox-contextual-trigger";
+export * from "./fox-disclosure";
 export * from "./fox-email";
 export * from "./fox-file-upload";
 export * from "./fox-form-label";
@@ -28,6 +31,8 @@
 export * from "./fox-icon-button";
 export * from "./fox-input";
 export * from "./fox-link-button";
+export * from "./fox-modal";
+export * from "./fox-page-header";
 export * from "./fox-pagination";
 export * from "./fox-phone-number";
 export * from "./fox-popover";
@@ -60,4 +65,7 @@
 export * from "./fox-tooltip-rich";
 export * from "./fox-toggle-quantity";
 export * from "./fox-toggle-switch";
-
+export * from "./fox-list-search";
+export * from "./fox-list-toolbar";
+export * from "./fox-list-container";
+export * from "./fox-description-list";
 
@fox/core/validation/fox-validation-messages.ts (added)
+++ @fox/core/validation/fox-validation-messages.ts
@@ -0,0 +1,97 @@
+/**
+ * 오류 객체 → 화면 문구.
+ *
+ * 검증기와 분리한 이유는 Angular가 `Validators`에 문구를 두지 않은 이유와 같다 — 같은 규칙도
+ * 자리에 따라 다른 말로 안내해야 한다("10자 이상"과 "비밀번호는 10자 이상"). 검증기는 무엇이
+ * 틀렸는지만 말하고, 그것을 무슨 말로 옮길지는 여기서(또는 호출부의 `overrides`로) 정한다.
+ *
+ * 한 번에 **한 문구만** 낸다 — 입력 칸 아래 헬퍼 자리가 한 줄이고, 오류를 쌓아 보여주면 어느
+ * 것부터 고쳐야 할지 알기 어렵다. 순서는 `MESSAGE_ORDER`가 정한다(비어 있음 → 길이 → 조합).
+ */
+
+import type { FoxValidationErrors } from './fox-validators';
+
+/** 오류 키별 기본 문구. 값이 필요한 것은 오류 객체의 맥락을 받아 만든다. */
+type MessageFactory = (detail: unknown) => string;
+
+function detailOf<T>(detail: unknown): T {
+  return detail as T;
+}
+
+const DEFAULT_MESSAGES: Record<string, MessageFactory> = {
+  required: () => '필수 입력 항목입니다.',
+  minlength: (detail) =>
+    `${detailOf<{ requiredLength: number }>(detail).requiredLength}자 이상 입력해 주세요.`,
+  maxlength: (detail) =>
+    `${detailOf<{ requiredLength: number }>(detail).requiredLength}자 이내로 입력해 주세요.`,
+  pattern: () => '형식이 올바르지 않습니다.',
+  email: () => '이메일 형식이 올바르지 않습니다.',
+  characterKinds: (detail) =>
+    `영문, 숫자, 특수문자 중 ${
+      detailOf<{ requiredKinds: number }>(detail).requiredKinds
+    }종류 이상을 섞어 주세요.`,
+  repeatedCharacters: (detail) =>
+    `같은 문자를 ${detailOf<{ max: number }>(detail).max}번까지만 연달아 쓸 수 있습니다.`,
+  sequentialCharacters: (detail) =>
+    `연속된 문자나 숫자를 ${
+      detailOf<{ max: number }>(detail).max
+    }자를 넘겨 쓸 수 없습니다.`,
+  notContaining: (detail) =>
+    `${detailOf<{ forbidden: string }>(detail).forbidden}은(는) 포함할 수 없습니다.`,
+  equalTo: () => '값이 일치하지 않습니다.',
+};
+
+/** 먼저 고쳐야 하는 것부터. 여기 없는 키는 뒤에 남은 순서대로 본다. */
+const MESSAGE_ORDER = [
+  'required',
+  'minlength',
+  'maxlength',
+  'pattern',
+  'email',
+  'characterKinds',
+  'repeatedCharacters',
+  'sequentialCharacters',
+  'notContaining',
+  'equalTo',
+];
+
+/** 오류 키 → 문구 덮어쓰기. 문자열이면 그대로, 함수면 맥락을 받아 만든다. */
+export type FoxValidationMessages = Record<
+  string,
+  string | ((detail: unknown) => string)
+>;
+
+/**
+ * 오류 객체에서 보여줄 문구 하나를 고른다. 오류가 없으면 `undefined`.
+ * 덮어쓰기에 없는 키는 기본 문구로 떨어지고, 기본 문구에도 없으면 키를 그대로 쓰지 않고
+ * `undefined`를 돌려준다 — 정체불명의 영문 키를 사용자에게 보이지 않기 위해서다.
+ */
+export function foxValidationMessage(
+  errors: FoxValidationErrors | null,
+  overrides: FoxValidationMessages = {}
+): string | undefined {
+  if (!errors) {
+    return undefined;
+  }
+
+  const keys = Object.keys(errors);
+  const ordered = [
+    ...MESSAGE_ORDER.filter((key) => keys.includes(key)),
+    ...keys.filter((key) => !MESSAGE_ORDER.includes(key)),
+  ];
+
+  for (const key of ordered) {
+    const override = overrides[key];
+    if (typeof override === 'string') {
+      return override;
+    }
+    if (typeof override === 'function') {
+      return override(errors[key]);
+    }
+    const fallback = DEFAULT_MESSAGES[key];
+    if (fallback) {
+      return fallback(errors[key]);
+    }
+  }
+  return undefined;
+}
 
@fox/core/validation/fox-validators.ts (added)
+++ @fox/core/validation/fox-validators.ts
@@ -0,0 +1,235 @@
+/**
+ * @fox 입력 검증기 — Angular `Validators`의 계약을 그대로 옮겼다.
+ *
+ * 검증기는 **값을 받아 통과면 `null`, 아니면 오류 객체**를 돌려주는 순수 함수다. 오류 객체의
+ * 키가 곧 오류의 이름이고(`{ minlength: … }`), 값에는 문구를 만드는 데 필요한 맥락을 담는다
+ * (`{ requiredLength, actualLength }`). Angular가 이 모양을 쓰는 이유가 여기서도 그대로다 —
+ * **문구를 검증기가 정하지 않기 때문에** 같은 규칙을 화면마다 다른 말로 안내할 수 있다.
+ * 기본 문구는 `fox-validation-messages.ts`가 따로 갖는다.
+ *
+ * Angular와 다른 점은 하나다: 인자가 `AbstractControl`이 아니라 문자열이다. @fox에는 폼 모델이
+ * 없고 검증 대상이 입력 칸의 값 하나뿐이라, 컨트롤 객체를 두면 감싸는 비용만 늘어난다.
+ *
+ * `required`를 뺀 모든 검증기는 **빈 값을 통과시킨다**(Angular와 같다) — "비어 있음"의 판정은
+ * `required` 하나가 맡아야 빈 칸에 오류가 두 개씩 뜨지 않는다.
+ */
+
+export type FoxValidationErrors = Record<string, unknown>;
+
+/** 통과면 `null`, 아니면 오류 객체. */
+export type FoxValidator = (value: string) => FoxValidationErrors | null;
+
+function isEmpty(value: string): boolean {
+  return value.trim().length === 0;
+}
+
+/** 값이 비어 있으면 `{ required: true }`. */
+function required(value: string): FoxValidationErrors | null {
+  return isEmpty(value) ? { required: true } : null;
+}
+
+function minLength(requiredLength: number): FoxValidator {
+  return (value) => {
+    if (isEmpty(value) || value.length >= requiredLength) {
+      return null;
+    }
+    return { minlength: { requiredLength, actualLength: value.length } };
+  };
+}
+
+function maxLength(requiredLength: number): FoxValidator {
+  return (value) => {
+    if (value.length <= requiredLength) {
+      return null;
+    }
+    return { maxlength: { requiredLength, actualLength: value.length } };
+  };
+}
+
+function pattern(requiredPattern: RegExp): FoxValidator {
+  return (value) => {
+    if (isEmpty(value) || requiredPattern.test(value)) {
+      return null;
+    }
+    return { pattern: { requiredPattern: requiredPattern.source, actualValue: value } };
+  };
+}
+
+/** 오탈자를 걸러내는 수준만 본다 — 이메일의 완전한 문법은 정규식으로 판정할 수 없다. */
+function email(value: string): FoxValidationErrors | null {
+  if (isEmpty(value) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
+    return null;
+  }
+  return { email: true };
+}
+
+/** 비밀번호 조합에 쓰는 문자 종류. 대문자는 소문자와 한 종류로 센다(시안이 그렇게 묻는다). */
+const CHARACTER_KIND_PATTERNS = {
+  letter: /[a-zA-Z]/,
+  digit: /\d/,
+  symbol: /[^a-zA-Z0-9]/,
+} as const;
+
+export type FoxCharacterKind = keyof typeof CHARACTER_KIND_PATTERNS;
+
+/** 영문·숫자·특수문자 중 몇 종류가 섞였는지 센다. */
+function countCharacterKinds(value: string): FoxCharacterKind[] {
+  return (Object.keys(CHARACTER_KIND_PATTERNS) as FoxCharacterKind[]).filter(
+    (kind) => CHARACTER_KIND_PATTERNS[kind].test(value)
+  );
+}
+
+/** "영문·숫자·특수문자 중 N종류 이상" — 비밀번호 규칙의 가장 흔한 형태다. */
+function characterKinds(requiredKinds: number): FoxValidator {
+  return (value) => {
+    if (isEmpty(value)) {
+      return null;
+    }
+    const kinds = countCharacterKinds(value);
+    if (kinds.length >= requiredKinds) {
+      return null;
+    }
+    return { characterKinds: { requiredKinds, actualKinds: kinds.length, kinds } };
+  };
+}
+
+/** 같은 문자가 `max`회를 넘겨 연달아 오면 오류(`aaa`). */
+function noRepeatedCharacters(max: number): FoxValidator {
+  return (value) => {
+    if (isEmpty(value)) {
+      return null;
+    }
+    let run = 1;
+    for (let i = 1; i < value.length; i += 1) {
+      run = value[i] === value[i - 1] ? run + 1 : 1;
+      if (run > max) {
+        return { repeatedCharacters: { max, character: value[i] } };
+      }
+    }
+    return null;
+  };
+}
+
+/** 코드값이 잇따르는 문자가 `max`회를 넘기면 오류(`abcd`·`4321`). 오름·내림 둘 다 본다. */
+function noSequentialCharacters(max: number): FoxValidator {
+  return (value) => {
+    if (isEmpty(value)) {
+      return null;
+    }
+    let ascending = 1;
+    let descending = 1;
+    for (let i = 1; i < value.length; i += 1) {
+      const step = value.charCodeAt(i) - value.charCodeAt(i - 1);
+      ascending = step === 1 ? ascending + 1 : 1;
+      descending = step === -1 ? descending + 1 : 1;
+      if (ascending > max || descending > max) {
+        return { sequentialCharacters: { max, at: i - max } };
+      }
+    }
+    return null;
+  };
+}
+
+/**
+ * 지정한 문자열을 품고 있으면 오류. 비밀번호에 ID·이름을 넣지 못하게 하는 규칙이다.
+ *
+ * 값을 함수로도 받는 이유는 그 대상이 **다른 칸의 지금 값**이기 때문이다 — 배열로 굳혀 두면
+ * ID를 고친 뒤에도 옛 ID로 검사한다.
+ */
+function notContaining(
+  forbidden: string[] | (() => string[]),
+  options: { caseSensitive?: boolean } = {}
+): FoxValidator {
+  return (value) => {
+    if (isEmpty(value)) {
+      return null;
+    }
+    const list = typeof forbidden === 'function' ? forbidden() : forbidden;
+    const haystack = options.caseSensitive ? value : value.toLowerCase();
+
+    for (const raw of list) {
+      const needle = options.caseSensitive ? raw : raw.toLowerCase();
+      if (needle.length > 0 && haystack.includes(needle)) {
+        return { notContaining: { forbidden: raw } };
+      }
+    }
+    return null;
+  };
+}
+
+/** 다른 칸과 값이 같아야 한다(비밀번호 확인). 대상이 바뀌므로 함수로 받는다. */
+function equalTo(other: () => string): FoxValidator {
+  return (value) => (isEmpty(value) || value === other() ? null : { equalTo: true });
+}
+
+/**
+ * 여러 검증기를 하나로 합친다. **통과하지 못한 것들의 오류 객체를 전부 병합해** 돌려준다
+ * (Angular의 `Validators.compose`와 같다) — 첫 오류에서 멈추지 않아야 "10자 이상"과
+ * "2종류 이상"을 함께 안내할 수 있다.
+ */
+function compose(validators: FoxValidator[]): FoxValidator {
+  return (value) => {
+    const merged = validators.reduce<FoxValidationErrors>((acc, validate) => {
+      const errors = validate(value);
+      return errors ? { ...acc, ...errors } : acc;
+    }, {});
+    return Object.keys(merged).length > 0 ? merged : null;
+  };
+}
+
+export const foxValidators = {
+  required,
+  minLength,
+  maxLength,
+  pattern,
+  email,
+  characterKinds,
+  noRepeatedCharacters,
+  noSequentialCharacters,
+  notContaining,
+  equalTo,
+  compose,
+};
+
+export interface FoxPasswordPolicy {
+  /** 기본 8. 시안이 더 길게 요구하면 그 값을 준다. */
+  minLength?: number;
+  maxLength?: number;
+  /** 영문·숫자·특수문자 중 최소 몇 종류를 섞을지. 기본 2. */
+  kinds?: number;
+  /** 같은 문자 연속 허용 횟수. 주지 않으면 검사하지 않는다. */
+  maxRepeated?: number;
+  /** 잇따르는 문자 허용 길이(`abc`). 주지 않으면 검사하지 않는다. */
+  maxSequential?: number;
+  /** 비밀번호에 들어가면 안 되는 값(보통 ID·이름). 지금 값을 읽도록 함수로 줄 수 있다. */
+  forbidden?: string[] | (() => string[]);
+  /** 참이면 비어 있는 것도 오류다. 수정 화면처럼 "비우면 유지"인 자리에서는 끈다. */
+  required?: boolean;
+}
+
+/**
+ * 비밀번호 칸의 규칙 한 벌. `type="password"` 입력에 그대로 얹으라고 둔 조합이다 —
+ * 규칙 자체는 위 검증기들이고, 이 함수는 자주 쓰는 묶음에 이름을 붙인 것뿐이다.
+ */
+export function foxPasswordValidator(policy: FoxPasswordPolicy = {}): FoxValidator {
+  const {
+    minLength: min = 8,
+    maxLength: max,
+    kinds = 2,
+    maxRepeated,
+    maxSequential,
+    forbidden,
+    required: isRequired = false,
+  } = policy;
+
+  const validators: FoxValidator[] = [];
+  if (isRequired) validators.push(required);
+  validators.push(minLength(min));
+  if (max !== undefined) validators.push(maxLength(max));
+  validators.push(characterKinds(kinds));
+  if (maxRepeated !== undefined) validators.push(noRepeatedCharacters(maxRepeated));
+  if (maxSequential !== undefined) validators.push(noSequentialCharacters(maxSequential));
+  if (forbidden !== undefined) validators.push(notContaining(forbidden));
+
+  return compose(validators);
+}
 
@fox/core/validation/index.ts (added)
+++ @fox/core/validation/index.ts
@@ -0,0 +1,12 @@
+export {
+  foxValidators,
+  foxPasswordValidator,
+  type FoxValidator,
+  type FoxValidationErrors,
+  type FoxCharacterKind,
+  type FoxPasswordPolicy,
+} from "./fox-validators";
+export {
+  foxValidationMessage,
+  type FoxValidationMessages,
+} from "./fox-validation-messages";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -69,6 +69,11 @@
 import { FoxTooltip } from "../core/components/fox-tooltip";
 import { FoxTooltipRich } from "../core/components/fox-tooltip-rich";
 import { FoxToggleQuantity } from "../core/components/fox-toggle-quantity";
+import { FoxPageHeader } from "../core/components/fox-page-header";
+import { FoxModal } from "../core/components/fox-modal";
+import { FoxAccordion } from "../core/components/fox-accordion";
+import { FoxCard } from "../core/components/fox-card";
+import { FoxDisclosure } from "../core/components/fox-disclosure";
 import styles from "./dev-test.module.scss";
 
 export interface ComponentExample {
@@ -609,10 +614,72 @@
   );
 }
 
-/**
- * 토스트는 상태를 갖지 않는다 — 언제 뜨고 언제 사라질지, 화면 어디에 놓일지가 전부 호출부
- * 몫이라는 것을 보여 주는 예제다. 여기서는 3초 뒤에 스스로 지우고, 자리는 감싼 div가 잡는다.
- */
+function ModalDemo({
+  size,
+  title,
+  withUtility = false,
+  withActions = true,
+  closable = true,
+  withImage = false,
+  openLabel,
+}: {
+  size: "sm" | "md" | "lg" | "full";
+  title?: string;
+  withUtility?: boolean;
+  withActions?: boolean;
+  closable?: boolean;
+  withImage?: boolean;
+  openLabel?: string;
+}) {
+  const [open, setOpen] = useState(false);
+  return (
+    <>
+      <FoxButton size="md" type="secondary" label={openLabel ?? `${size} 열기`} onAction={() => setOpen(true)} />
+      <FoxModal
+        open={open}
+        onClose={() => setOpen(false)}
+        size={size}
+        title={title}
+        closable={closable}
+        utility={
+          withUtility ? (
+            <FoxCheckbox size="md" label="오늘 하루 보지 않기" />
+          ) : undefined
+        }
+        actions={
+          withActions ? (
+            <>
+              <FoxButton size="md" label="취소" onAction={() => setOpen(false)} />
+              <FoxButton size="md" type="primary" label="확인" onAction={() => setOpen(false)} />
+            </>
+          ) : undefined
+        }
+      >
+        {/* 시안의 contents 슬롯에는 그림이 들어간 예가 있다 — 슬롯이라 무엇이든 놓을 수 있다. */}
+        {withImage && <DemoThumb />}
+        <p>
+          대화 상자는 사용자에게 작업에 대해 알리고 중요한 정보를 포함하거나 결정이 필요하거나
+          여러 작업을 포함할 수 있습니다.
+        </p>
+      </FoxModal>
+    </>
+  );
+}
+
+function DemoThumb() {
+  return (
+    <svg viewBox="0 0 320 180" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
+      <rect width="320" height="180" fill="var(--fox-color-surface-neutral-gray-strong)" />
+      <circle cx="232" cy="52" r="26" fill="var(--fox-color-element-theme-primary)" opacity="0.35" />
+      <path
+        d="M0 148l72-52 54 38 60-46 134 92v0H0z"
+        fill="var(--fox-color-element-neutral-default)"
+        opacity="0.45"
+      />
+    </svg>
+  );
+}
+
 function ToastDemo() {
   const [open, setOpen] = useState(false);
 
@@ -3418,6 +3485,229 @@
     ],
   },
   {
+    id: "fox-page-header",
+    name: "FoxPageHeader",
+    description:
+      "Figma 시안(page-tit-area 3002:6823, 관리자페이지 파일)을 옮겼습니다. 현재 위치와 제목을 얹고 본문과의 간격(아래 여백)까지 갖습니다 — 시안이 그렇게 묶어 놓아 화면마다 따로 띄우지 않아도 됩니다. 제목은 화면에 하나뿐인 이름이라 <h1>으로 나갑니다.",
+    variants: [
+      {
+        label: "시안 그대로 — 현재 위치 + 제목",
+        node: <FoxPageHeader breadcrumb={BREADCRUMB_ITEMS} title="리스트 페이지" />,
+      },
+      {
+        label: "현재 위치 없이 제목만",
+        node: <FoxPageHeader title="리스트 페이지" />,
+      },
+      {
+        label: "오른쪽 자리 (actions) — 시안엔 없지만 배치가 자리를 비워 둡니다",
+        node: (
+          <FoxPageHeader
+            breadcrumb={BREADCRUMB_ITEMS}
+            title="리스트 페이지"
+            actions={
+              <FoxButtonGroup size="md">
+                <FoxButton size="md" type="secondary" label="엑셀 다운로드" />
+                <FoxButton size="md" type="primary" label="등록" />
+              </FoxButtonGroup>
+            }
+          />
+        ),
+      },
+      {
+        label: "제목이 길어질 때 — 오른쪽 자리를 침범하지 않고 접힙니다",
+        node: (
+          <FoxPageHeader
+            breadcrumb={BREADCRUMB_ITEMS}
+            title="아주 긴 화면 제목이 들어가면 이렇게 여러 줄로 접힙니다 아주 긴 화면 제목"
+            actions={<FoxButton size="md" type="primary" label="등록" />}
+          />
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-modal",
+    name: "FoxModal",
+    description:
+      "Figma 시안(modal 190:1136)을 옮겼습니다. 네이티브 <dialog>를 showModal()로 띄워 초점 가두기·Esc 닫기·뒷배경·맨 위 레이어를 브라우저가 처리하게 합니다. 열림 여부는 호출부가 소유하고, Esc나 바깥 클릭에도 모달이 스스로 닫지 않고 onClose만 부릅니다. 창을 768px 아래로 좁히면 네 크기가 같은 폭으로 모이고 여백·모서리·닫기 버튼도 함께 줄어듭니다.",
+    variants: [
+      {
+        label: "크기 — sm 400 · md 560 · lg 760 · full",
+        node: (
+          <div className={styles.demoRow}>
+            <ModalDemo size="sm" title="타이틀" />
+            <ModalDemo size="md" title="타이틀" />
+            <ModalDemo size="lg" title="타이틀" />
+            <ModalDemo size="full" title="타이틀" />
+          </div>
+        ),
+      },
+      {
+        label: "그림이 들어갈 때 — contents는 슬롯이라 무엇이든 놓입니다",
+        node: (
+          <div className={styles.demoRow}>
+            <ModalDemo size="md" title="타이틀" withImage openLabel="그림 있는 모달 열기" />
+            <ModalDemo size="lg" title="타이틀" withImage openLabel="lg 그림 모달 열기" />
+          </div>
+        ),
+      },
+      {
+        label: "아래쪽 왼편 (utility) — 시안의 체크박스 자리",
+        node: (
+          <div className={styles.demoRow}>
+            <ModalDemo size="md" title="타이틀" withUtility />
+          </div>
+        ),
+      },
+      {
+        label: "제목 없이 / 버튼 없이 / 닫기 없이",
+        node: (
+          <div className={styles.demoRow}>
+            <ModalDemo size="sm" />
+            <ModalDemo size="sm" title="타이틀" withActions={false} />
+            <ModalDemo size="sm" title="타이틀" closable={false} />
+          </div>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-accordion",
+    name: "FoxAccordion",
+    description:
+      "Figma 시안(accordion 194:3563 box · 194:3680 line)을 옮겼습니다. box는 색을 깐 상자로 제목이 강조 색이고, line은 위쪽 선 하나로 나뉘는 목록형입니다. 열림을 밖에서 쥐면 여러 개 중 하나만 열리게 하는 것도 호출부가 정할 수 있고, 안 넘기면 스스로 여닫습니다. 닫히면 내용이 DOM에서 빠져 탭 순서에 남지 않습니다.",
+    variants: [
+      {
+        label: "box — lg · md",
+        node: (
+          <div className={styles.demoStack}>
+            <FoxAccordion size="lg" title="아코디언 타이틀 영역" defaultOpen
+              badge={<FoxBadgeGroup size="md"><FoxBadge size="md">레이블</FoxBadge></FoxBadgeGroup>}>
+              <p>펼쳐진 내용이 들어가는 자리입니다.</p>
+            </FoxAccordion>
+            <FoxAccordion size="md" title="아코디언 타이틀 영역"
+              badge={<FoxBadgeGroup size="sm"><FoxBadge size="sm">레이블</FoxBadge></FoxBadgeGroup>}>
+              <p>펼쳐진 내용이 들어가는 자리입니다.</p>
+            </FoxAccordion>
+          </div>
+        ),
+      },
+      {
+        label: "line — 이어 붙이면 위쪽 선이 칸막이가 됩니다",
+        node: (
+          <div className={styles.demoField}>
+            <FoxAccordion type="line" size="lg" title="아코디언 타이틀 영역" defaultOpen>
+              <p>펼쳐진 내용이 들어가는 자리입니다.</p>
+            </FoxAccordion>
+            <FoxAccordion type="line" size="lg" title="아코디언 타이틀 영역" />
+            <FoxAccordion type="line" size="md" title="아코디언 타이틀 영역" />
+          </div>
+        ),
+      },
+      {
+        label: "배지 없이",
+        node: (
+          <div className={styles.demoStack}>
+            <FoxAccordion title="아코디언 타이틀 영역">
+              <p>펼쳐진 내용이 들어가는 자리입니다.</p>
+            </FoxAccordion>
+          </div>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-disclosure",
+    name: "FoxDisclosure",
+    description:
+      "Figma 시안(disclosure 391:4082 닫힘 · 391:4083 열림)을 옮겼습니다. 캐럿이 제목 앞에 오고 바탕도 테두리도 없어, 글 사이에 끼우는 가벼운 접기입니다 — 목록을 나누는 FoxAccordion과는 다른 컴포넌트입니다. 시안에 크기 축도 계열 축도 없어 변형이 없고 상태는 닫힘·열림 둘뿐입니다(캐럿이 오른쪽 → 위로 바뀝니다). 열림을 밖에서 쥘 수 있고 안 넘기면 스스로 여닫습니다. 닫히면 내용이 DOM에서 빠져 탭 순서에 남지 않습니다.",
+    variants: [
+      {
+        label: "시안 그대로 — 닫힘 · 열림 (눌러서 여닫아 보세요)",
+        node: (
+          <div className={styles.demoStack}>
+            <FoxDisclosure title="디스클로저 레이블">
+              <p>펼쳐진 내용이 들어가는 자리입니다.</p>
+            </FoxDisclosure>
+            <FoxDisclosure title="디스클로저 레이블" defaultOpen>
+              <p>펼쳐진 내용이 들어가는 자리입니다.</p>
+            </FoxDisclosure>
+          </div>
+        ),
+      },
+      {
+        label: "내용은 슬롯이라 무엇이든 놓입니다",
+        node: (
+          <div className={styles.demoStack}>
+            <FoxDisclosure title="첨부된 그림 보기" defaultOpen>
+              <DemoThumb />
+            </FoxDisclosure>
+          </div>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-card",
+    name: "FoxCard",
+    description:
+      "Figma 시안(card 441:6006)을 옮겼습니다. 크기 넷은 여백과 모서리만 바꿉니다 — 폭은 놓이는 자리가 정합니다(시안은 320px 자리에 뒀습니다). 그림은 16:9로 잘리고 상자가 모서리를 넘지 않게 자릅니다.",
+    variants: [
+      {
+        label: "그림이 있을 때 — 16:9로 잘리고 위쪽 모서리를 넘지 않습니다",
+        node: (
+          <div className={styles.demoRow}>
+            {(["xlg", "md"] as const).map((size) => (
+              <div key={size} style={{ inlineSize: 320 }}>
+                <FoxCard
+                  size={size}
+                  image={<DemoThumb />}
+                  title="타이틀 영역"
+                  description="간단한 설명이 들어가는 영역입니다."
+                  actions={<FoxTextButton size="md" label="액션1" />}
+                />
+              </div>
+            ))}
+          </div>
+        ),
+      },
+      {
+        label: "크기 — xlg · lg · md · sm (여백과 모서리가 달라집니다)",
+        node: (
+          <div className={styles.demoRow}>
+            {(["xlg", "lg", "md", "sm"] as const).map((size) => (
+              <div key={size} style={{ inlineSize: 320 }}>
+                <FoxCard
+                  size={size}
+                  title="타이틀 영역"
+                  description="간단한 설명이 들어가는 영역입니다."
+                  actions={<FoxTextButton size="md" label="액션1" />}
+                />
+              </div>
+            ))}
+          </div>
+        ),
+      },
+      {
+        label: "그림 없이 / 동작 없이 / 설명 없이",
+        node: (
+          <div className={styles.demoRow}>
+            <div style={{ inlineSize: 320 }}>
+              <FoxCard title="타이틀 영역" description="간단한 설명이 들어가는 영역입니다."
+                actions={<FoxTextButton size="md" label="액션1" />} />
+            </div>
+            <div style={{ inlineSize: 320 }}>
+              <FoxCard title="타이틀 영역" description="간단한 설명이 들어가는 영역입니다." />
+            </div>
+            <div style={{ inlineSize: 320 }}>
+              <FoxCard title="타이틀 영역" actions={<FoxTextButton size="md" label="액션1" />} />
+            </div>
+          </div>
+        ),
+      },
+    ],
+  },
+  {
     id: "fox-toast",
     name: "FoxToast",
     description:
 
@fox/styles/_fox-accordion.scss (added)
+++ @fox/styles/_fox-accordion.scss
@@ -0,0 +1,155 @@
+// FoxAccordion — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
+//                      accordion (194:3563 box · 194:3680 line · 섹션 392:5034)
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-accordion fox-accordion--box fox-accordion--lg fox-accordion--open">
+//     <button class="fox-accordion__tit" type="button" aria-expanded="true" aria-controls="…">
+//       <span class="fox-accordion__badge">…fox-badge-group…</span>   <!-- 선택 -->
+//       <span class="fox-accordion__title">제목</span>
+//       <span class="fox-accordion__icon">…svg…</span>
+//     </button>
+//     <div class="fox-accordion__contents" id="…">…</div>            <!-- 열렸을 때만 -->
+//   </div>
+//
+// 계열 둘이 성격이 다르다.
+//   - box:  색을 깐 상자. 여백·모서리를 상자가 갖고 제목이 강조 색이다.
+//   - line: 위쪽 선 하나로 나뉘는 목록형. 바탕이 없고 제목 줄이 세로 여백을 갖는다.
+//
+// 크기는 여백·글자·모서리를 함께 바꾼다(시안 lg·md).
+//
+// 토큰이 아닌 값:
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
+//     export하지 않는다.
+//   - 버튼 초기화(appearance/cursor/색 상속): 구조 규칙.
+
+@use "abstracts" as fox;
+
+.fox-accordion {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  inline-size: 100%;
+
+  // 제목 줄은 통째로 누르는 자리다.
+  &__tit {
+    display: flex;
+    align-items: center;
+    gap: fox.gap(3);
+    inline-size: 100%;
+    margin: 0;
+    padding: 0;
+    border: none;
+    background: none;
+    font-family: fox.font-family(title);
+    font-weight: fox.font-weight(bold);
+    line-height: 1.4;
+    letter-spacing: -0.025em;
+    text-align: start;
+    cursor: pointer;
+  }
+
+  &__badge {
+    display: inline-flex;
+    flex-shrink: 0;
+  }
+
+  // 제목이 남는 폭을 먹고 아이콘은 오른쪽 끝에 붙는다.
+  &__title {
+    flex: 1 0 0;
+    min-inline-size: 0;
+    word-break: break-word;
+  }
+
+  &__icon {
+    display: inline-flex;
+    flex-shrink: 0;
+    color: fox.color(icon-neutral-default);
+
+    > * {
+      inline-size: fox.icon(4);
+      block-size: fox.icon(4);
+    }
+  }
+
+  &__contents {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    inline-size: 100%;
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-md);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  // ── box 계열 ──────────────────────────────────────────────────────────────
+  &--box {
+    background: fox.color(surface-theme-secondary);
+  }
+
+  &--box &__title {
+    color: fox.color(font-theme-secondary);
+  }
+
+  &--box#{&}--lg {
+    gap: fox.gap(7);
+    padding-inline: fox.padding(9);
+    padding-block: fox.padding(8);
+    border-radius: fox.radius(5);
+  }
+
+  &--box#{&}--md {
+    gap: fox.gap(5);
+    padding-inline: fox.padding(7);
+    padding-block: fox.padding(6);
+    border-radius: fox.radius(4);
+  }
+
+  &--box#{&}--lg &__contents {
+    gap: fox.gap(7);
+  }
+
+  &--box#{&}--md &__contents {
+    gap: fox.gap(5);
+  }
+
+  // ── line 계열 ─────────────────────────────────────────────────────────────
+  // 여러 개를 이어 붙이면 위쪽 선이 칸막이가 된다.
+  &--line {
+    border-block-start: fox.border(1) solid fox.color(border-neutral-subtler);
+    background: fox.color(action-secondary);
+  }
+
+  &--line &__title {
+    color: fox.color(font-neutral-strong);
+  }
+
+  &--line#{&}--lg &__tit {
+    padding-block: fox.padding(8);
+  }
+
+  &--line#{&}--md &__tit {
+    padding-block: fox.padding(6);
+  }
+
+  &--line#{&}--lg &__contents {
+    gap: fox.gap(7);
+    padding-block-end: fox.padding(8);
+  }
+
+  &--line#{&}--md &__contents {
+    gap: fox.gap(5);
+    padding-block-end: fox.padding(6);
+  }
+
+  // ── 크기 (글자) ───────────────────────────────────────────────────────────
+  &--lg &__title {
+    font-size: fox.font-size(heading-sm);
+  }
+
+  &--md &__title {
+    font-size: fox.font-size(heading-xsm);
+  }
+}
 
@fox/styles/_fox-card.scss (added)
+++ @fox/styles/_fox-card.scss
@@ -0,0 +1,104 @@
+// FoxCard — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
+//                  card (441:6006 · 섹션 463:5649)
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-card fox-card--md">
+//     <div class="fox-card__image">…img…</div>          <!-- 선택 -->
+//     <div class="fox-card__contents">
+//       <p class="fox-card__title">타이틀</p>
+//       <p class="fox-card__description">설명</p>
+//     </div>
+//     <div class="fox-card__actions">…fox-text-button…</div>   <!-- 선택 -->
+//   </div>
+//
+// 크기 넷은 여백과 모서리만 바꾼다 — 폭은 놓이는 자리가 정한다(시안은 320px 자리에 뒀다).
+//
+// 그림은 16:9로 잘린다. 상자가 `overflow: clip`이라 위쪽 모서리를 그림이 넘지 않는다.
+//
+// 아래쪽 줄은 좌우·아래 여백만 갖는다 — 위쪽은 글 영역의 아래 여백이 이미 띄워 준다.
+//
+// 토큰이 아닌 값:
+//   - 그림 비율 16/9: 시안 값이나 Figma가 변수로 export하지 않는다(사용자 승인).
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
+//     export하지 않는다.
+//   - object-fit / overflow: 구조 규칙.
+
+@use "abstracts" as fox;
+
+.fox-card {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  inline-size: 100%;
+  overflow: clip;
+  border: fox.border(1) solid fox.color(border-neutral-subtle);
+  background: fox.color(surface-neutral-default);
+
+  &__image {
+    inline-size: 100%;
+    // 시안 값이나 변수로 export되지 않는다 — 파일 상단 참고.
+    aspect-ratio: 16 / 9;
+
+    > * {
+      inline-size: 100%;
+      block-size: 100%;
+      object-fit: cover;
+    }
+  }
+
+  &__contents {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: fox.gap(2);
+    inline-size: 100%;
+    word-break: break-word;
+  }
+
+  &__title {
+    inline-size: 100%;
+    margin: 0;
+    color: fox.color(font-neutral-strong);
+    font-family: fox.font-family(title);
+    font-size: fox.font-size(heading-sm);
+    font-weight: fox.font-weight(bold);
+    line-height: 1.4;
+    letter-spacing: -0.025em;
+  }
+
+  &__description {
+    inline-size: 100%;
+    margin: 0;
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-md);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  // 위쪽은 글 영역의 아래 여백이 이미 띄워 준다.
+  &__actions {
+    display: flex;
+    align-items: flex-start;
+    gap: fox.gap(5);
+    inline-size: 100%;
+  }
+
+  // ── 크기 ──────────────────────────────────────────────────────────────────
+  @each $size in (xlg, lg, md, sm) {
+    &--#{$size} {
+      border-radius: fox.card(radius-#{$size});
+    }
+
+    &--#{$size} > .fox-card__contents {
+      padding: fox.card(padding-#{$size});
+    }
+
+    &--#{$size} > .fox-card__actions {
+      padding-inline: fox.card(padding-#{$size});
+      padding-block-end: fox.card(padding-#{$size});
+    }
+  }
+}
@fox/styles/_fox-chip-area.scss
--- @fox/styles/_fox-chip-area.scss
+++ @fox/styles/_fox-chip-area.scss
@@ -15,8 +15,8 @@
 // `align-items: center`가 필요한 이유는 칩의 크기(높이)가 섞일 수 있어서다. `FoxChipArea`의
 // `size`로 통일하면 섞이지 않지만, 통일하지 않고 쓰는 것도 막지 않는다.
 //
-// ⚠️ 줄바꿈이 없다(`flex-wrap`을 주지 않는다) — 시안이 한 줄만 그린다. 폭이 모자라면 칩이
-// 눌리는 게 아니라 넘친다. 칩이 여러 줄로 흘러야 하는 자리가 나오면 시안에 먼저 추가한다.
+// 기본은 한 줄이다 — 폭이 모자라면 칩이 눌리는 게 아니라 넘친다. 여러 줄로 흘러야 하는 자리는
+// `--wrap`을 건다(시안 ADM_ADM_102_p의 "메뉴 선택"이 칩을 두 줄로 감는다).
 
 @use "@fox/styles/abstracts" as fox;
 
@@ -24,4 +24,8 @@
   display: inline-flex;
   align-items: center;
   gap: fox.gap(2);
+
+  &--wrap {
+    flex-wrap: wrap;
+  }
 }
 
@fox/styles/_fox-description-list.scss (added)
+++ @fox/styles/_fox-description-list.scss
@@ -0,0 +1,52 @@
+// FoxDescriptionList — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) modal 5227:809의 form-list
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <dl class="fox-description-list">
+//     <div class="fox-description-list__item">
+//       <dt class="fox-description-list__term"><span class="fox-form-label">이름</span></dt>
+//       <dd class="fox-description-list__description">홍길동</dd>
+//     </div>
+//   </dl>
+//
+// 줄마다 아래에 구분선이 있다 — 시안이 마지막 줄에도 그어 두었으므로 걷어내지 않는다.
+//
+// 토큰이 아닌 값:
+//   - line-height / letter-spacing: 시안 텍스트 스타일(body/md)의 값이나 Figma가 변수로
+//     export하지 않는다.
+
+@use "abstracts" as fox;
+
+.fox-description-list {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  margin: 0;
+  // flex 부모가 `align-items: flex-start`여도 가로로 꽉 차게 한다 — 이 목록은 줄마다 구분선을
+  // 긋기 때문에 폭이 내용에 맞춰 줄면 선이 중간에서 끊긴 것처럼 보인다.
+  align-self: stretch;
+
+  &__item {
+    display: flex;
+    inline-size: 100%;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: fox.gap(3);
+    padding-block: fox.padding(6);
+    border-block-end: fox.border(1) solid fox.color(border-neutral-subtler);
+  }
+
+  &__term {
+    margin: 0;
+  }
+
+  &__description {
+    inline-size: 100%;
+    margin: 0;
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-md);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+}
 
@fox/styles/_fox-disclosure.scss (added)
+++ @fox/styles/_fox-disclosure.scss
@@ -0,0 +1,66 @@
+// FoxDisclosure — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
+//                       disclosure (391:4082 닫힘 · 391:4083 열림 · 섹션 391:4102)
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-disclosure fox-disclosure--open">
+//     <button class="fox-disclosure__tit" type="button" aria-expanded="true" aria-controls="…">
+//       <span class="fox-disclosure__icon">…svg…</span>
+//       <span class="fox-disclosure__title">제목</span>
+//     </button>
+//     <div class="fox-disclosure__contents" id="…">…</div>          <!-- 열렸을 때만 -->
+//   </div>
+//
+// 캐럿이 제목 앞에 오고 바탕·테두리가 없다. 시안에 크기 축도 계열 축도 없어 변형이 없다.
+//
+// 토큰이 아닌 값:
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
+//     export하지 않는다.
+//   - 버튼 초기화(appearance/cursor/색 상속): 구조 규칙.
+
+@use "abstracts" as fox;
+
+.fox-disclosure {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: fox.gap(3);
+  inline-size: 100%;
+
+  // 제목 줄은 통째로 누르는 자리다. 글자 폭만 차지한다 — 시안이 hug다.
+  &__tit {
+    display: flex;
+    align-items: center;
+    gap: fox.gap(2);
+    margin: 0;
+    padding: 0;
+    border: none;
+    background: none;
+    text-align: start;
+    cursor: pointer;
+  }
+
+  &__icon {
+    display: inline-flex;
+    flex-shrink: 0;
+    inline-size: fox.icon(4);
+    block-size: fox.icon(4);
+    color: fox.color(element-neutral-strong);
+  }
+
+  &__title {
+    color: fox.color(font-neutral-strong);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-md);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+    word-break: break-word;
+  }
+
+  &__contents {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    inline-size: 100%;
+  }
+}
@fox/styles/_fox-file-upload.scss
--- @fox/styles/_fox-file-upload.scss
+++ @fox/styles/_fox-file-upload.scss
@@ -127,10 +127,12 @@
     block-size: fox.icon(6);
   }
 
+  // 잘라내지 않고 상자 안에 다 보이게 담는다 — 고른 그림이 어떻게 생겼는지 확인하는
+  // 자리라 일부만 보이면 확인이 되지 않는다.
   &__thumb-image {
     inline-size: 100%;
     block-size: 100%;
-    object-fit: cover;
+    object-fit: contain;
   }
 
   &__thumb-remove {
@fox/styles/_fox-input.scss
--- @fox/styles/_fox-input.scss
+++ @fox/styles/_fox-input.scss
@@ -7,6 +7,7 @@
 //     <div class="fox-input__box">
 //       <input class="fox-input__field" id="…" placeholder="…">
 //       <button class="fox-input__clear" type="button">…svg…</button>   <!-- 선택 -->
+//       <span class="fox-input__suffix">/ 총 39개</span>                  <!-- 선택 -->
 //       <span class="fox-input__icon">…svg…</span>                      <!-- 선택 -->
 //     </div>
 //     <p class="fox-input__message">…</p>                               <!-- 선택 -->
@@ -69,6 +70,13 @@
     cursor: pointer;
   }
 
+  // 아이콘 슬롯과 달리 정사각형으로 못박지 않는다 — 글자만큼 차지하고 줄어들지 않는다.
+  &__suffix {
+    flex-shrink: 0;
+    color: fox.color(font-neutral-default);
+    white-space: nowrap;
+  }
+
   &__message {
     @include field.message;
   }
@@ -84,7 +92,8 @@
     border-radius: fox.form(radius-lg);
   }
 
-  &--lg &__field {
+  &--lg &__field,
+  &--lg &__suffix {
     font-size: fox.font-size(label-lg);
   }
 
@@ -100,7 +109,8 @@
     border-radius: fox.form(radius-md);
   }
 
-  &--md &__field {
+  &--md &__field,
+  &--md &__suffix {
     font-size: fox.font-size(label-md);
   }
 
@@ -116,7 +126,8 @@
     border-radius: fox.form(radius-sm);
   }
 
-  &--sm &__field {
+  &--sm &__field,
+  &--sm &__suffix {
     font-size: fox.font-size(label-sm);
   }
 
 
@fox/styles/_fox-list-container.scss (added)
+++ @fox/styles/_fox-list-container.scss
@@ -0,0 +1,41 @@
+// FoxListContainer — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container (3019:8608)
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-list-container">
+//     …fox-page-header…
+//     <section class="fox-list-container__section">
+//       …fox-list-toolbar… …fox-table… …fox-pagination…
+//     </section>
+//   </div>
+//
+// 세로로만 쌓는다 — 도구 줄과 페이지네이션이 각자 자기 여백을 갖고 있어 여기서 간격을 더하지
+// 않는다(더하면 시안보다 벌어진다).
+
+@use "abstracts" as fox;
+
+.fox-list-container {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+
+  &__section {
+    display: flex;
+    inline-size: 100%;
+    flex-direction: column;
+  }
+
+  // 열이 많은 표는 화면보다 넓다. 눌러 담으면 시안 폭이 무너지므로 여기서 가로로 스크롤한다.
+  &__table {
+    inline-size: 100%;
+    overflow-x: auto;
+  }
+}
+
+// 표가 지정된 열 폭 아래로 눌리지 않게 한다 — 넓을 때만 넘치고, 좁으면 100%로 앉는다.
+// 좁은 화면에서는 목록표가 카드로 바뀌어(FoxTable의 mobile 규칙) 스크롤이 필요 없으므로
+// PC에서만 건다.
+@include fox.pc {
+  .fox-list-container__table > .fox-table {
+    min-inline-size: max-content;
+  }
+}
 
@fox/styles/_fox-list-search.scss (added)
+++ @fox/styles/_fox-list-search.scss
@@ -0,0 +1,92 @@
+// FoxListSearch — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) list-toolbar의 search (5111:4099)
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <form class="fox-list-search" role="search">
+//     <div class="fox-list-search__box">
+//       <span class="fox-list-search__field">…select-txt…</span>   <!-- 선택 -->
+//       <input class="fox-list-search__input">
+//       <button class="fox-list-search__submit">…svg…</button>
+//     </div>
+//   </form>
+//
+// 상자 규격은 FoxInput md와 같지만(높이 40 · radius 8 · 테두리 input-border) 좌우 여백만
+// `form/padding/sm`으로 좁다 — 안에 셀렉트와 버튼이 들어가기 때문이다.
+//
+// 토큰이 아닌 값:
+//   - 폭 320, 제출 버튼 32: 시안 값이나 Figma가 변수로 export하지 않는다.
+
+@use "abstracts" as fox;
+@use "form-field" as field;
+
+.fox-list-search {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  gap: fox.gap(3);
+  // 시안 값이나 변수로 export되지 않는다.
+  inline-size: 32rem;
+
+  &__box {
+    display: flex;
+    inline-size: 100%;
+    align-items: center;
+    gap: fox.gap(2);
+    block-size: fox.form(height-md);
+    padding-inline: fox.form(padding-sm);
+    border: fox.border(1) solid fox.color(input-border);
+    border-radius: fox.form(radius-md);
+    background: fox.color(input-surface);
+  }
+
+  &__field {
+    display: inline-flex;
+    flex-shrink: 0;
+    align-items: center;
+  }
+
+  &__input {
+    @include field.field(label-md);
+
+    // 네이티브 `search`가 붙이는 기본 장식을 지운다 — 지우기 X는 제출 버튼과 겹친다.
+    &::-webkit-search-cancel-button,
+    &::-webkit-search-decoration {
+      appearance: none;
+    }
+  }
+
+  &__submit {
+    display: inline-flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: center;
+    // 시안 값이나 변수로 export되지 않는다.
+    inline-size: 3.2rem;
+    block-size: 3.2rem;
+    padding: 0;
+    border: none;
+    // 시안은 secondary 계열 버튼이다 — 옆의 "엑셀 다운로드"와 같은 색을 쓴다.
+    border-radius: fox.form(radius-md);
+    background: fox.color(button-secondary-surface);
+    color: fox.color(button-secondary-font);
+    cursor: pointer;
+
+    &:hover:not(:disabled) {
+      background: fox.color(button-secondary-surface-hover);
+    }
+
+    &:active:not(:disabled) {
+      background: fox.color(button-secondary-surface-pressed);
+    }
+
+    &:disabled {
+      background: fox.color(button-disabled-surface);
+      color: fox.color(font-neutral-disabled-strong);
+      cursor: default;
+    }
+  }
+
+  &__submit > svg {
+    inline-size: fox.form(icon-md);
+    block-size: fox.form(icon-md);
+  }
+}
 
@fox/styles/_fox-list-toolbar.scss (added)
+++ @fox/styles/_fox-list-toolbar.scss
@@ -0,0 +1,82 @@
+// FoxListToolbar — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) list-toolbar (3002:8151)
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-list-toolbar">
+//     <div class="fox-list-toolbar__base">
+//       <div class="fox-list-toolbar__left">…</div>
+//       <div class="fox-list-toolbar__right">…</div>
+//     </div>
+//     <div class="fox-list-toolbar__filter">…</div>   <!-- 선택 -->
+//   </div>
+//
+// 좌우가 각각 `flex: 1`이라 반씩 나눠 갖고, 우측 내용은 오른쪽 끝으로 몰린다.
+//
+// 토큰이 아닌 값:
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 export하지 않는다.
+
+@use "abstracts" as fox;
+
+.fox-list-toolbar {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  align-items: flex-start;
+  padding-block-end: fox.spacing(bottom-md);
+
+  &__base {
+    display: flex;
+    inline-size: 100%;
+    flex-shrink: 0;
+    align-items: center;
+  }
+
+  &__left,
+  &__right {
+    display: flex;
+    flex: 1 0 0;
+    min-inline-size: 0;
+    align-items: center;
+    gap: fox.gap(4);
+  }
+
+  &__right {
+    justify-content: flex-end;
+  }
+
+  &__sorts {
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    gap: fox.gap(3);
+  }
+
+  // 아래 줄 — 위 줄과 가로 구분선으로 나뉜다.
+  &__filter {
+    display: flex;
+    inline-size: 100%;
+    flex-shrink: 0;
+    align-items: center;
+    gap: fox.gap(3);
+    margin-block-start: fox.padding(5);
+    padding-block-start: fox.padding(5);
+    border-block-start: fox.border(1) solid fox.color(border-neutral-subtler);
+  }
+}
+
+// 총 건수 — 숫자만 굵고 색이 다르다.
+.fox-list-count {
+  flex-shrink: 0;
+  margin: 0;
+  color: fox.color(font-neutral-default);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(label-md);
+  font-weight: fox.font-weight(regular);
+  line-height: 1;
+  letter-spacing: -0.025em;
+  white-space: nowrap;
+
+  &__value {
+    color: fox.color(font-theme-primary);
+    font-weight: fox.font-weight(bold);
+  }
+}
 
@fox/styles/_fox-modal.scss (added)
+++ @fox/styles/_fox-modal.scss
@@ -0,0 +1,210 @@
+// FoxModal — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
+//                   modal (190:1136 · 섹션 391:4640)
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <dialog class="fox-modal fox-modal--md">
+//     <div class="fox-modal__body">
+//       <div class="fox-modal__tit"><h2 class="fox-modal__title">타이틀</h2></div>  <!-- 선택 -->
+//       <div class="fox-modal__contents">…</div>
+//     </div>
+//     <div class="fox-modal__foot">                                                <!-- 선택 -->
+//       <div class="fox-modal__utility">…</div>                                    <!-- 선택 -->
+//       <div class="fox-modal__actions">…</div>                                    <!-- 선택 -->
+//     </div>
+//     <button class="fox-modal__close" type="button" aria-label="닫기">…svg…</button>
+//   </dialog>
+//   `<dialog>`는 `showModal()`로 열어야 뒷배경과 초점 가두기가 생긴다.
+//
+// 크기 넷(sm·md·lg·full)이 모두 폭 토큰이다. 모바일에서는 넷 다 같은 폭(348px)으로
+// 모이고 여백·모서리·닫기 버튼도 함께 줄어든다 — 반응형 토큰이 그렇게 정의돼 있다.
+//
+// 제목 줄에만 오른쪽 여백을 준다 — 닫기 버튼이 그 자리에 겹쳐 앉기 때문이다. 본문과
+// 아래쪽은 그 여백이 필요 없다.
+//
+// 닫기 버튼은 64px 칸의 아래쪽에 아이콘을 붙인다(시안 items-end). 그래야 아이콘이 제목
+// 글줄과 눈높이가 맞는다. 상자 테두리 위에 얹히도록 1px 밖으로 내민다.
+//
+// 토큰이 아닌 값:
+//   - 아래쪽 버튼의 최소 폭 8rem: 시안 값이나 Figma가 변수로 export하지 않는다(사용자 승인).
+//   - 그림자 색: 시안이 테마 토큰이 아니라 `alpha/black10` 원시값에 직접 물려 놓았다
+//     (fox-carousel-button과 같은 사정).
+//   - 뒷배경을 덮는 방식(`::backdrop`)과 가운데 정렬: 구조 규칙.
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
+//     export하지 않는다.
+
+@use "abstracts" as fox;
+
+.fox-modal {
+  box-sizing: border-box;
+  // `<dialog>`는 닫혀 있을 때 브라우저가 숨기는데, `display`를 주면 그 기본값을 덮어
+  // 닫힌 모달이 화면에 그대로 남는다. 열렸을 때만 배치를 준다.
+  display: none;
+  flex-direction: column;
+  align-items: flex-start;
+  // `<dialog>`의 브라우저 기본 여백·테두리를 지우고 가운데에 놓는다.
+  max-inline-size: calc(100% - #{fox.padding(7)} * 2);
+  max-block-size: calc(100% - #{fox.padding(7)} * 2);
+  margin: auto;
+  padding: fox.modal(padding-default);
+  // 세로만 스크롤한다. `overflow: auto`로 두면 닫기 버튼이 테두리를 덮으려고 일부러 1px
+  // 밖으로 나간 것(`__close`의 `inset-inline-end: -1px`)까지 넘침으로 세어 가로 스크롤바가
+  // 생긴다 — 그 버튼은 배경도 테두리도 없고 아이콘이 8px 안쪽이라 x축을 잘라도 보이는 변화가
+  // 없다.
+  overflow-x: hidden;
+  overflow-y: auto;
+  border: fox.border(1) solid fox.color(border-neutral-subtle);
+  border-radius: fox.card(radius-xlg);
+  background: fox.color(surface-neutral-default);
+  color: fox.color(font-neutral-default);
+  // 시안 neutral/lv3 — 그림자 둘이 겹친다.
+  box-shadow:
+    fox.shadow(location4) fox.shadow(location4) fox.shadow(blur4) 0
+      fox.primitive(alpha-black10),
+    fox.shadow(location2) fox.shadow(location2) fox.shadow(blur3) 0
+      fox.primitive(alpha-black10);
+
+  &::backdrop {
+    background: fox.color(background-dim);
+  }
+
+  // 닫기 버튼이 상자 모서리에 얹히므로 기준점이 필요하다.
+  position: relative;
+
+  &[open] {
+    display: flex;
+  }
+
+  // ── 크기 ──────────────────────────────────────────────────────────────────
+  &--sm {
+    inline-size: fox.modal(width-sm);
+  }
+
+  &--md {
+    inline-size: fox.modal(width-md);
+  }
+
+  &--lg {
+    inline-size: fox.modal(width-lg);
+  }
+
+  &--full {
+    inline-size: fox.modal(width-full);
+  }
+
+  // ── 본문 ──────────────────────────────────────────────────────────────────
+  &__body {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: fox.gap(5);
+    inline-size: 100%;
+  }
+
+  // 닫기 버튼이 오른쪽 위에 앉으므로 제목만 그만큼 비켜 준다.
+  //
+  // 제목이 없어도 한 줄 높이를 남긴다 — 시안의 tit 프레임이 제목 없이도 28px(제목 글자
+  // 크기 × 줄높이)을 차지한다. 이 자리를 없애면 닫기 버튼이 내용 위로 내려앉는다.
+  &__tit {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: fox.gap(3);
+    inline-size: 100%;
+    min-block-size: calc(#{fox.font-size(heading-md)} * 1.4);
+    padding-inline-end: fox.padding(8);
+  }
+
+  &__title {
+    inline-size: 100%;
+    margin: 0;
+    color: fox.color(font-neutral-strong);
+    font-family: fox.font-family(title);
+    font-size: fox.font-size(heading-md);
+    font-weight: fox.font-weight(bold);
+    line-height: 1.4;
+    letter-spacing: -0.025em;
+    word-break: break-word;
+  }
+
+  &__contents {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: fox.gap(5);
+    inline-size: 100%;
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-md);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+
+    // 그림이 들어오면 폭을 채운다 — 시안의 contents 슬롯에 든 것도 폭 100%다.
+    > img,
+    > svg,
+    > picture {
+      inline-size: 100%;
+      block-size: auto;
+    }
+  }
+
+  // ── 아래쪽 ────────────────────────────────────────────────────────────────
+  &__foot {
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    gap: fox.gap(3);
+    inline-size: 100%;
+    padding-block-start: fox.padding(8);
+  }
+
+  // 왼편은 남는 폭을 먹어 버튼을 오른쪽 끝으로 민다.
+  &__utility {
+    display: flex;
+    flex: 1 0 0;
+    align-items: flex-start;
+    gap: fox.gap(3);
+    min-inline-size: 0;
+  }
+
+  &__actions {
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: flex-end;
+    gap: fox.gap(3);
+    margin-inline-start: auto;
+  }
+
+  // 시안에서 모달 버튼은 최소 8rem이다 — 글자가 짧아도 두 버튼의 폭이 같아 보이게 한다.
+  // 버튼 자체의 성질이 아니라 이 자리의 규칙이라 여기서 준다(btn 컴포넌트 세트에는
+  // 최소 폭 표기가 없다).
+  &__actions .fox-button {
+    min-inline-size: 8rem;
+  }
+
+  // ── 닫기 ──────────────────────────────────────────────────────────────────
+  // 시안대로 아이콘을 칸 아래쪽에 붙여 제목 글줄과 눈높이를 맞춘다.
+  &__close {
+    position: absolute;
+    inset-block-start: calc(-1 * #{fox.border(1)});
+    inset-inline-end: calc(-1 * #{fox.border(1)});
+    display: flex;
+    align-items: flex-end;
+    inline-size: fox.modal(btn-close);
+    block-size: fox.modal(btn-close);
+    padding: fox.padding(3);
+    border: none;
+    background: none;
+    color: fox.color(icon-neutral-default);
+    cursor: pointer;
+  }
+
+  &__close-icon {
+    display: inline-flex;
+
+    > * {
+      inline-size: fox.icon(5);
+      block-size: fox.icon(5);
+    }
+  }
+}
 
@fox/styles/_fox-page-header.scss (added)
+++ @fox/styles/_fox-page-header.scss
@@ -0,0 +1,68 @@
+// FoxPageHeader — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) page-tit-area (3002:6823)
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-page-header">
+//     <div class="fox-page-header__main">
+//       …fox-breadcrumb…                          <!-- 선택 -->
+//       <h1 class="fox-page-header__title">제목</h1>
+//     </div>
+//     <div class="fox-page-header__actions">…</div>  <!-- 선택 -->
+//   </div>
+//
+// 제목이 남는 폭을 먹고 오른쪽에 24px 간격이 남는다. 시안에는 그 자리에 들어간 것이 없지만
+// 배치가 자리를 비워 두고 있어 `__actions`로 열어 둔다.
+//
+// 본문과의 간격(아래 여백)을 머리말이 갖는다 — 시안이 그렇게 묶어 놓았다. 화면마다 따로
+// 띄우지 않아도 된다.
+//
+// 토큰이 아닌 값:
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
+//     export하지 않는다.
+//   - `<h1>`의 브라우저 기본 여백 0: UA 기본값 지우기.
+
+@use "abstracts" as fox;
+
+.fox-page-header {
+  display: flex;
+  align-items: flex-start;
+  gap: fox.gap(7);
+  inline-size: 100%;
+  padding-block-end: fox.spacing(bottom-xlg);
+
+  // 현재 위치와 제목이 세로로 쌓이고 남는 폭을 먹는다.
+  &__main {
+    display: flex;
+    flex: 1 0 0;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: fox.gap(3);
+    min-inline-size: 0;
+  }
+
+  &__title {
+    inline-size: 100%;
+    margin: 0;
+    color: fox.color(font-neutral-strong);
+    font-family: fox.font-family(title);
+    font-size: fox.font-size(display-md);
+    font-weight: fox.font-weight(bold);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+    word-break: break-word;
+  }
+
+  // 제목 옆에 서고 줄어들지 않는다. 오른쪽 끝에 붙는 것을 선언으로 못박는다 — 제목 칸이
+  // 남는 폭을 먹어 밀려나는 것에 기대면, 제목 칸이 없거나 좁아질 때 자리가 흔들린다.
+  &__actions {
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: flex-end;
+    margin-inline-start: auto;
+  }
+
+  // 여러 개가 들어와 줄이 넘어가도 오른쪽에 맞춘다.
+  &__actions > * {
+    justify-content: flex-end;
+  }
+}
@fox/styles/_fox-tab-item.scss
--- @fox/styles/_fox-tab-item.scss
+++ @fox/styles/_fox-tab-item.scss
@@ -10,8 +10,9 @@
 //   - line 고름: 흰 바탕 + 4px 밑줄 + 파란 글자
 //   - fill 고름: 남색 바탕 + 흰 글자
 //
-// line 계열의 기본 상태도 밑줄 자리를 1px 비워 둔다 — 고름으로 바뀔 때 글자가 밀리지
-// 않게 하려면 자리를 미리 잡아야 한다(시안도 투명 1px을 둔다).
+// line 계열의 밑줄은 `border`가 아니라 안쪽 그림자로 그린다. Figma는 stroke를 상자 **안쪽**에
+// 그려 4px 밑줄이 높이를 늘리지 않지만(시안의 tab-item은 고름 여부와 무관하게 60px), CSS
+// `border`는 상자를 키운다 — border로 그리면 고른 탭만 4px 커지고 옆 탭과 3px 어긋난다.
 //
 // 토큰이 아닌 값:
 //   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
@@ -69,13 +70,9 @@
 }
 
 // ── line 계열 ───────────────────────────────────────────────────────────────
-// 고름으로 바뀔 때 글자가 밀리지 않게 밑줄 자리를 미리 잡는다.
-.fox-tab--line .fox-tab-item {
-  border-block-end: fox.border(1) solid fox.color(action-secondary);
-}
-
+// 밑줄은 상자 안쪽에 그린다(위 주석 참조) — 높이가 60px로 고정돼 고름 여부에 글자가 밀리지 않는다.
 .fox-tab--line .fox-tab-item--active {
-  border-block-end: fox.border(3) solid fox.color(action-secondary-active);
+  box-shadow: inset 0 calc(-1 * #{fox.border(3)}) 0 0 fox.color(action-secondary-active);
   background: fox.color(surface-neutral-default);
   color: fox.color(font-theme-secondary);
 }
@fox/styles/_fox-table-row.scss
--- @fox/styles/_fox-table-row.scss
+++ @fox/styles/_fox-table-row.scss
@@ -18,4 +18,18 @@
     background: fox.color(action-primary-hover);
     color: fox.color(font-theme-primary);
   }
+
+  // 행 전체가 눌리는 경우 — 누를 수 있다는 것이 보이고, 키보드 초점도 드러나야 한다.
+  &--clickable {
+    cursor: pointer;
+
+    &:hover {
+      background: fox.color(action-default-hover);
+    }
+
+    &:focus-visible {
+      outline: fox.border(2) solid fox.color(border-theme-primary);
+      outline-offset: calc(-1 * #{fox.border(2)});
+    }
+  }
 }
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -62,4 +62,13 @@
 @use "fox-toast";
 @use "fox-tooltip";
 @use "fox-tooltip-rich";
-@use "fox-toggle-quantity";
(파일 끝에 줄바꿈 문자 없음)
+@use "fox-toggle-quantity";
+@use "fox-page-header";
+@use "fox-modal";
+@use "fox-accordion";
+@use "fox-card";
+@use "fox-list-search";
+@use "fox-list-toolbar";
+@use "fox-list-container";
+@use "fox-disclosure";
+@use "fox-description-list";
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,
@@ -41,6 +41,8 @@
 const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
 const SELF_DELETE_MESSAGE = '현재 로그인한 본인 계정은 삭제할 수 없습니다.';
 const DUPLICATE_LOGIN_ID_MESSAGE = '이미 사용 중인 ID입니다.';
+const LOGIN_ID_CHECK_FAILED_MESSAGE =
+  '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.';
 
 function readString(formData: FormData, key: string): string {
   const value = formData.get(key);
@@ -58,15 +60,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'),
@@ -205,7 +203,10 @@
 export type LoginIdCheckResult =
   | { status: 'idle' }
   | { status: 'available'; loginId: string }
-  | { status: 'unavailable'; message: string };
+  /** 쓸 수 없는 ID — 형식 오류이거나 이미 선점됐다. */
+  | { status: 'unavailable'; message: string }
+  /** 확인 자체를 못 했다(통신·권한 등). 값의 판정이 아니라 **확인 실패**다. */
+  | { status: 'failed'; message: string };
 
 /**
  * 시안 ADM_ADM_102_p ① — ID 중복 확인.
@@ -230,8 +231,17 @@
     return { status: 'unavailable', message: formatError };
   }
 
-  if (await isAdminLoginIdTaken(normalized)) {
-    return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
+  // 확인 호출이 실패하면 그 사유를 화면에 그대로 돌려준다 — 예외로 두면 클라이언트의
+  // transition에서 처리되지 않은 rejection이 되어 버튼만 원복되고 아무 말도 남지 않는다.
+  try {
+    if (await isAdminLoginIdTaken(normalized)) {
+      return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
+    }
+  } catch (error) {
+    if (error instanceof BackendRequestError) {
+      return { status: 'failed', message: error.message };
+    }
+    return { status: 'failed', message: LOGIN_ID_CHECK_FAILED_MESSAGE };
   }
 
   return { status: 'available', loginId: normalized };
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,14 +1,26 @@
 '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 { foxPasswordValidator } from '@fox/core/validation';
+import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import { DEFAULT_ADMIN_ROLE_CODE } from '@/lib/domain/admin-member';
 import {
   ADMIN_LOGIN_ID_HELP_TEXT,
   ADMIN_PASSWORD_HELP_TEXT,
+  ADMIN_PASSWORD_POLICY,
   INITIAL_ADMIN_MEMBER_FORM_STATE,
 } from '@/lib/domain/admin-member-form';
 import {
@@ -16,19 +28,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 +56,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 +73,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 +96,180 @@
   }, [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}` : '';
+
+  // 중복 확인 결과는 서버 검증 오류보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다.
+  // 값이 나쁜 것(unavailable)과 확인을 못 한 것(failed)은 사유가 다르므로 둘 다 문구로 낸다.
+  const loginIdFailure =
+    checkResult.status === 'unavailable' || checkResult.status === 'failed'
+      ? checkResult.message
+      : undefined;
+  const loginIdMessage =
+    loginIdFailure ??
+    (isLoginIdVerified
+      ? '사용할 수 있는 ID입니다.'
+      : (errors.loginId ?? ADMIN_LOGIN_ID_HELP_TEXT));
+
   return (
-    <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={Boolean(loginIdFailure) || 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 () => {
+                try {
+                  setCheckResult(await checkAdminLoginId(loginId));
+                } catch {
+                  // Server Action 호출 자체가 깨진 경우(네트워크 등). 조용히 끝나면 버튼만
+                  // 원복되고 아무 말도 남지 않아, 사유를 알 수 없어도 실패는 알린다.
+                  setCheckResult({
+                    status: 'failed',
+                    message: '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.',
+                  });
+                }
+              })
+            }
+          />
+        </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)}
+          // 규칙은 도메인이 한 벌로 갖는다 — 저장 직전 서버 검증이 같은 값을 본다.
+          validators={[foxPasswordValidator({ ...ADMIN_PASSWORD_POLICY, required: true })]}
+          validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
+        />
+
+        <AdminMemberFormFields
+          phoneNumber={phoneNumber}
+          onPhoneNumberChange={setPhoneNumber}
+          roleCode={roleCode}
+          onRoleCodeChange={setRoleCode}
+          menuCodes={menuCodes}
+          onMenuCodesChange={setMenuCodes}
+          errors={errors}
+          emailField={
+            <>
+              <div className={styles.field}>
+                <FoxFormLabel as="span" requirement="required">
+                  이메일
+                </FoxFormLabel>
+                <div className={styles.emailRow}>
+                  <span className={styles.emailPart}>
+                    <FoxInput
+                      size="md"
+                      aria-label="이메일 아이디"
+                      placeholder="1234-5678"
+                      value={emailLocal}
+                      onChange={setEmailLocal}
+                      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,51 @@
 '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 { foxPasswordValidator } from '@fox/core/validation';
+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,
+  ADMIN_PASSWORD_POLICY,
   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 +56,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 +79,115 @@
   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)}
+            // 비우면 "바꾸지 않음"이라 required를 걸지 않는다(등록 팝업과 다른 점).
+            validators={[foxPasswordValidator(ADMIN_PASSWORD_POLICY)]}
+            validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
+          />
+          <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-toolbar.tsx (deleted)
--- app/(protected)/(basic)/admins/_components/admin-member-list-toolbar.tsx
@@ -1,99 +0,0 @@
-'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-list.tsx (added)
+++ app/(protected)/(basic)/admins/_components/admin-member-list.tsx
@@ -0,0 +1,240 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { FoxBadge, type FoxBadgeColor } from '@fox/core/components/fox-badge';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { FoxSelectText } from '@fox/core/components/fox-select-text';
+import {
+  FoxArrowsDownUpIcon,
+  FoxDownloadSimpleIcon,
+  FoxPlusIcon,
+} from '@fox/core/icons';
+import {
+  formatAdminRoleLabel,
+  formatOptionalValue,
+  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,
+  buildAdminMemberHref,
+  type AdminMemberPageSize,
+  type AdminMemberQuery,
+  type AdminMemberSearchField,
+  type AdminMemberSortOption,
+} from '@/lib/domain/admin-member-query';
+import { AdminMemberCreateModal } from './admin-member-create-modal';
+import { AdminMemberRowActions } from './admin-member-row-actions';
+
+interface AdminMemberListProps {
+  items: AdminMember[];
+  query: AdminMemberQuery;
+  /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+  /** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */
+  currentAdminId: string;
+}
+
+/** 폭이 고정되지 않은 세 열이 나눠 갖는 몫 — 시안 1552px 기준 (1552 - 고정열 720) / 3. */
+const FILL_COLUMN_WIDTH = 277;
+
+/**
+ * 역할 배지의 색. 시안이 최고관리자는 accent(붉은 계열), 그 외는 primary(파란 계열)로
+ * 구분한다 — 색은 시각 결정이라 도메인(`admin-member.ts`)이 아니라 여기가 갖는다.
+ * 모르는 코드는 이름을 지어내지 않듯 색도 입히지 않는다(neutral).
+ */
+const BADGE_COLOR_BY_ROLE: Record<string, FoxBadgeColor> = {
+  ROLE_SYSTEM: 'accent',
+  ROLE_ADMIN: 'primary',
+};
+
+/**
+ * 관리자 회원 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_101 (5227:1545)
+ *
+ * 뼈대는 `FoxListContainer`가 전부 그린다(학생 회원 목록과 같은 구성). 이 파일이 갖는 것은
+ * 이 화면 고유의 것뿐이다 — 8개 열의 정의, 정렬·페이지 크기 셀렉트, 툴바 버튼 두 개,
+ * 그리고 URL을 갱신하는 방법.
+ *
+ * 이동은 두 갈래로 나뉜다. 페이지네이션은 `buildHref`로 **링크**가 되고(주소가 곧 상태라
+ * 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간
+ * `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다.
+ *
+ * [엑셀 다운로드]는 네이티브 GET 폼이다 — 응답이 첨부파일(Content-Disposition)이라 라우터
+ * 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다. 검색·페이징 값을
+ * 싣지 않는 것이 사양이다(파일은 항상 전체 데이터).
+ */
+export function AdminMemberList({
+  items,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+  currentAdminId,
+}: AdminMemberListProps) {
+  const router = useRouter();
+  const [isCreateOpen, setIsCreateOpen] = useState(false);
+
+  /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */
+  function go(patch: Partial<AdminMemberQuery>) {
+    router.replace(buildAdminMemberHref(query, { ...patch, page: 1 }));
+  }
+
+  const offset = (currentPage - 1) * query.pageSize;
+
+  const columns: FoxListColumn<AdminMember>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      // 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다 — 기본 정렬이 생성일
+      // 최신순이라 "가장 최근에 만든 계정이 가장 큰 번호"가 된다.
+      render: (_row, index) => totalCount - offset - index,
+    },
+    // 이름·아이디·이메일은 시안에서 남는 폭을 똑같이 나눠 갖는다(1552 기준 각 277). 폭을
+    // 비워 두면 표가 글자 길이대로 나눠 이메일 열만 넓어져 시안과 어긋난다.
+    { key: 'name', header: '이름', width: FILL_COLUMN_WIDTH },
+    { key: 'loginId', header: '아이디', width: FILL_COLUMN_WIDTH },
+    {
+      key: 'phoneNumber',
+      header: '휴대전화번호',
+      width: 200,
+      render: (row) => formatOptionalValue(row.phoneNumber),
+    },
+    {
+      key: 'email',
+      header: '이메일',
+      width: FILL_COLUMN_WIDTH,
+      render: (row) => formatOptionalValue(row.email),
+    },
+    {
+      key: 'role',
+      header: '역할',
+      width: 120,
+      render: (row) => (
+        <FoxBadge
+          type="pastel"
+          color={BADGE_COLOR_BY_ROLE[row.roleCode] ?? 'neutral'}
+          size="md"
+        >
+          {formatAdminRoleLabel(row.roleCode)}
+        </FoxBadge>
+      ),
+    },
+    {
+      key: 'createdAt',
+      header: '생성일',
+      width: 160,
+      render: (row) => formatOptionalValue(row.createdAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 160,
+      render: (row) => (
+        <AdminMemberRowActions
+          member={row}
+          isSelf={row.id === currentAdminId}
+        />
+      ),
+    },
+  ];
+
+  return (
+    <>
+      <FoxListContainer<AdminMember>
+        title="관리자 회원 목록"
+        breadcrumb={[
+          { label: '홈', href: '/' },
+          { label: '관리자정보관리' },
+          { label: '관리자 회원' },
+        ]}
+        caption="관리자 회원 목록"
+        columns={columns}
+        rows={items}
+        rowKey={(row) => row.id}
+        totalCount={totalCount}
+        emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요."
+        sorts={
+          <>
+            <FoxSelectText
+              size="sm"
+              ariaLabel="정렬"
+              icon={<FoxArrowsDownUpIcon />}
+              options={ADMIN_MEMBER_SORT_OPTIONS.map((option) => ({
+                value: option.value,
+                label: option.label,
+              }))}
+              value={query.sort}
+              onValueChange={(value) => go({ sort: value as AdminMemberSortOption })}
+            />
+            <FoxSelectText
+              size="sm"
+              ariaLabel="페이지 크기"
+              options={ADMIN_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ({
+                value: String(size),
+                label: `${size}개씩 보기`,
+              }))}
+              value={String(query.pageSize)}
+              onValueChange={(value) =>
+                go({ pageSize: Number(value) as AdminMemberPageSize })
+              }
+            />
+          </>
+        }
+        searchFields={ADMIN_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ({
+          value: option.value,
+          label: option.label,
+        }))}
+        searchField={query.searchField}
+        keyword={query.keyword}
+        toolbarActions={
+          <FoxButtonGroup size="md">
+            <form action={ADMIN_MEMBERS_EXCEL_PATH} method="get">
+              <FoxButton
+                type="secondary"
+                size="md"
+                htmlType="submit"
+                leadingIcon={<FoxDownloadSimpleIcon />}
+                label="엑셀 다운로드"
+              />
+            </form>
+            <FoxButton
+              type="primary"
+              size="md"
+              leadingIcon={<FoxPlusIcon />}
+              label="신규 등록"
+              onAction={() => setIsCreateOpen(true)}
+            />
+          </FoxButtonGroup>
+        }
+        page={currentPage}
+        totalPages={totalPages}
+        buildHref={(patch) =>
+          buildAdminMemberHref(query, { page: Number(patch.page ?? 1) })
+        }
+        onQueryChange={(patch) => {
+          if (patch.keyword !== undefined || patch.searchField !== undefined) {
+            go({
+              keyword: String(patch.keyword ?? ''),
+              searchField: (patch.searchField ??
+                query.searchField) as AdminMemberSearchField,
+            });
+          }
+        }}
+      />
+      {isCreateOpen && (
+        <AdminMemberCreateModal onClose={() => setIsCreateOpen(false)} />
+      )}
+    </>
+  );
+}
 
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.module.scss (added)
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.module.scss
@@ -0,0 +1,6 @@
+// 비활성 삭제 버튼을 감싸 네이티브 툴팁을 다는 자리(그 이유는 컴포넌트 주석 참조).
+// 감싸기만 하면 안쪽 버튼이 글자 기준선에 앉아 2px가 더 붙고, 그만큼 행이 시안(44px)보다
+// 높아진다. 래퍼를 flex로 두어 버튼 높이가 그대로 래퍼 높이가 되게 한다.
+.hint {
+  display: flex;
+}
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,11 +1,13 @@
 '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';
 import { useFeedback } from '@/app/_hooks/use-feedback';
 import type { AdminMember } from '@/lib/domain/admin-member';
+import styles from './admin-member-row-actions.module.scss';
 import { deleteAdminMemberAction } from '../_actions';
 import { AdminMemberEditModal } from './admin-member-edit-modal';
 
@@ -27,9 +29,9 @@
  * **본인 계정 삭제는 막는다**(시안 ⑤). 화면에서 버튼을 비활성으로 두되, 실제 차단은 Server
  * Action이 세션의 adminId와 대조해 수행한다 — 화면 비활성은 안내일 뿐 신뢰 경계가 아니다.
  *
- * 시안의 연필·휴지통 아이콘 버튼이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을
- * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로 정의하는
- * 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다).
+ * 시안의 연필·휴지통 아이콘 버튼이다. `FoxIconButton`의 default·sm이 시안 값과 그대로 맞는다
+ * (form/height/sm=28, form/radius/sm=4, 테두리 #b1b8be). 두 버튼의 4px 간격도 시안이 btn-group
+ * 인스턴스라 `FoxButtonGroup`(gap/2=4)이 갖는다 — 여기서 직접 여백을 주지 않는다.
  *
  * 본인 계정 안내는 `<span title>`로 감싸 남긴다 — FoxIconButton은 `label`을 `aria-label`로만
  * 쓰고 네이티브 툴팁을 렌더하지 않으며, 비활성 버튼은 스스로 hover 이벤트를 받지 못한다.
@@ -63,30 +65,26 @@
       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>
       ),
     });
   }
 
   return (
-    <div className="flex items-center gap-1">
+    <FoxButtonGroup size="sm" align="center">
       <FoxIconButton
-        type="ghost"
+        type="default"
         size="sm"
         icon={<FoxPencilSimpleIcon />}
         label="수정"
         onAction={() => setIsEditOpen(true)}
       />
-      <span title={isSelf ? SELF_DELETE_HINT : undefined}>
+      <span className={styles.hint} title={isSelf ? SELF_DELETE_HINT : undefined}>
         <FoxIconButton
-          type="ghost"
+          type="default"
           size="sm"
           icon={<FoxTrashIcon />}
           label={isDeleting ? '삭제 중' : '삭제'}
@@ -101,6 +99,6 @@
           onClose={() => setIsEditOpen(false)}
         />
       )}
-    </div>
+    </FoxButtonGroup>
   );
 }
 
app/(protected)/(basic)/admins/_components/admin-member-search-bar.tsx (deleted)
--- app/(protected)/(basic)/admins/_components/admin-member-search-bar.tsx
@@ -1,74 +0,0 @@
-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 (deleted)
--- app/(protected)/(basic)/admins/_components/admin-member-table.tsx
@@ -1,90 +0,0 @@
-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 (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"',
+    },
+  });
+}
app/(protected)/(basic)/admins/page.tsx
--- app/(protected)/(basic)/admins/page.tsx
+++ app/(protected)/(basic)/admins/page.tsx
@@ -1,16 +1,8 @@
 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';
+import { parseAdminMemberQuery } from '@/lib/domain/admin-member-query';
+import { AdminMemberList } from './_components/admin-member-list';
 
 export const metadata: Metadata = {
   title: '관리자 회원',
@@ -37,51 +29,17 @@
 
   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>
+    <AdminMemberList
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+      currentAdminId={admin.id}
+    />
   );
 }
app/(protected)/(basic)/decoration-items/_actions.ts
--- app/(protected)/(basic)/decoration-items/_actions.ts
+++ app/(protected)/(basic)/decoration-items/_actions.ts
@@ -2,12 +2,14 @@
 
 import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
 import {
   createDecorationItem,
   deleteDecorationItem,
   updateDecorationItem,
   uploadDecorationItemImage,
 } from '@/lib/data/repositories/decoration-item-repository';
+import { CODE_GROUP } from '@/lib/domain/common-code';
 import type { DecorationItemType } from '@/lib/domain/decoration-item';
 import {
   validateDecorationItemCreate,
@@ -24,7 +26,8 @@
  * POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §3 SRP 체크).
  *
  * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/decoration-item-form.ts`의 규칙을 호출) —
- * 화면의 required 속성·기본값 채움은 편의일 뿐 신뢰 경계가 아니다.
+ * 화면의 required 속성·기본값 채움은 편의일 뿐 신뢰 경계가 아니다. **허용 카테고리도 여기서
+ * 다시 조회해 넘긴다** — 화면이 보낸 코드를 그대로 믿으면 코드테이블에 없는 값이 저장된다.
  *
  * **이미지 업로드도 이 계층을 지난다** — 브라우저는 백엔드를 직접 호출하지 않으므로(토큰이
  * httpOnly 세션 안에만 있다) 폼이 실어 보낸 File을 여기서 받아 Repository를 통해 백엔드에
@@ -119,7 +122,8 @@
     readEditableValues(
       formData,
       newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId
-    )
+    ),
+    await fetchCommonCodes(CODE_GROUP.decorationItemCategory)
   );
   if (!validation.ok) {
     return { status: 'error', errors: validation.errors };
@@ -162,7 +166,8 @@
     readEditableValues(
       formData,
       newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId
-    )
+    ),
+    await fetchCommonCodes(CODE_GROUP.decorationItemCategory)
   );
   if (!validation.ok) {
     return { status: 'error', errors: validation.errors };
app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
@@ -1,11 +1,12 @@
 'use client';
 
-import { useActionState, useEffect } 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 } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxModal } from '@fox/core/components/fox-modal';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { CommonCode } from '@/lib/domain/common-code';
 import type { DecorationItemType } from '@/lib/domain/decoration-item';
 import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
 import { createDecorationItemAction } from '../_actions';
@@ -15,27 +16,30 @@
   /** 등록 버튼을 누른 시점의 활성 탭(유형) — 폼의 유형 기본값으로 쓴다. */
   defaultItemType: DecorationItemType;
   typeTotalCount: number;
+  /** 공통코드 `ITEM_CATE_CD` — 카테고리 선택지. */
+  categories: CommonCode[];
   onClose: () => void;
 }
 
-const FORM_ID = 'decoration-item-create-form';
-
 /**
- * 꾸미기 아이템 등록 팝업(시안 ADM_ITM_102_p).
+ * 꾸미기 아이템 등록 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ITM_102_p (5227:4629)
  *
- * 아이템ID는 입력 항목이 아니다 — 시안대로 "등록 후 자동발급됩니다."를 읽기 전용으로 보여주고,
- * 실제 값은 백엔드가 `itemSn`(자동증가)으로 채운다.
+ * **아이템ID는 입력받지 않는다.** 시안은 직접 입력하는 칸으로 그리지만 백엔드 `TB_COM_ITEM`에
+ * 그런 컬럼이 없고 쓰기 API도 그 값을 받지 않는다 — 자동증가 PK인 `itemSn`이 목록의 아이템ID로
+ * 그대로 노출되는 값이라, 등록 시점에는 발급 전이므로 읽기 전용 안내로 둔다(백엔드에 컬럼이
+ * 생기면 이 칸만 입력으로 바꾸면 된다).
  *
- * 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다(admins의 등록 팝업과
- * 동일한 패턴) — 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그
- * form을 제출한다.
+ * 등록 버튼은 foot 슬롯에 그려져 폼의 자손이 아니다 — `FoxButton`에 폼을 가리키는 prop이 없어
+ * ref로 직접 제출한다(학생 회원 조회 팝업과 같은 방식).
  */
 export function DecorationItemCreateModal({
   defaultItemType,
   typeTotalCount,
+  categories,
   onClose,
 }: DecorationItemCreateModalProps) {
   const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
   const [state, formAction, isPending] = useActionState(
     createDecorationItemAction,
     INITIAL_DECORATION_ITEM_FORM_STATE
@@ -51,44 +55,45 @@
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
   return (
-    <Modal
+    <FoxModal
+      open
+      size="sm"
       title="꾸미기 아이템 등록"
       onClose={onClose}
-      footer={
+      actions={
         <>
-          <Button type="button" variant="ghost" onClick={onClose}>
-            취소
-          </Button>
-          <Button
-            type="submit"
-            form={FORM_ID}
-            variant="primary"
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isPending ? '등록 중...' : '등록'}
             disabled={isPending}
-          >
-            {isPending ? '등록 중...' : '등록'}
-          </Button>
+            onAction={() => formRef.current?.requestSubmit()}
+          />
         </>
       }
     >
-      <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
-        <p className="text-right text-body-sm text-danger">
-          * 는 필수 항목입니다.
-        </p>
-
-        <Field label="아이템 ID">
-          <Input type="text" value="등록 후 자동발급됩니다." readOnly />
-        </Field>
-
+      <form ref={formRef} action={formAction}>
         <DecorationItemFormFields
           defaultItemType={defaultItemType}
           typeTotalCount={typeTotalCount}
+          categories={categories}
           errors={errors}
+          itemIdField={
+            <FoxInput
+              size="md"
+              label="아이템 ID"
+              requirement="required"
+              value="등록 후 자동발급됩니다."
+              readOnly
+            />
+          }
         />
 
         {state.status === 'error' && state.message && (
-          <p className="text-body-sm text-danger">{state.message}</p>
+          <FoxHelperText type="danger" message={state.message} />
         )}
       </form>
-    </Modal>
+    </FoxModal>
   );
 }
app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
@@ -1,11 +1,12 @@
 'use client';
 
-import { useActionState, useEffect } 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 } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxModal } from '@fox/core/components/fox-modal';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { CommonCode } from '@/lib/domain/common-code';
 import type { DecorationItem } from '@/lib/domain/decoration-item';
 import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
 import { updateDecorationItemAction } from '../_actions';
@@ -14,26 +15,27 @@
 interface DecorationItemEditModalProps {
   item: DecorationItem;
   typeTotalCount: number;
+  /** 공통코드 `ITEM_CATE_CD` — 카테고리 선택지. */
+  categories: CommonCode[];
   onClose: () => void;
 }
 
-const FORM_ID = 'decoration-item-edit-form';
-
 /**
- * 꾸미기 아이템 수정 팝업(시안 ADM_ITM_103_p) — 등록과 동일 항목이되 **아이템ID는 읽기
- * 전용**이다.
+ * 꾸미기 아이템 수정 팝업(시안 ADM_ITM_103_p) — 등록 팝업(5227:4629)과 같은 구성이되
+ * **아이템ID는 읽기 전용**이다.
  *
- * readOnly로 보여주는 아이템ID `Input`에는 `name`을 주지 않아 제출 대상에서 아예 빠지게 하고,
- * 실제 수정 대상은 별도 hidden input(`itemSn`)으로 넘긴다 — Server Action도 hidden 값만
- * 읽으므로 readOnly 필드를 위조해서 보내도 수정 대상이 바뀌지 않는다(admins의 이름/ID readOnly
- * 처리와 동일한 방어 방식).
+ * 읽기 전용 아이템ID 칸에는 `name`을 주지 않아 제출 대상에서 아예 빠지게 하고, 실제 수정 대상은
+ * 별도 hidden input(`itemSn`)으로 넘긴다 — Server Action도 hidden 값만 읽으므로 읽기 전용 칸을
+ * 위조해 보내도 수정 대상이 바뀌지 않는다.
  */
 export function DecorationItemEditModal({
   item,
   typeTotalCount,
+  categories,
   onClose,
 }: DecorationItemEditModalProps) {
   const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
   const [state, formAction, isPending] = useActionState(
     updateDecorationItemAction,
     INITIAL_DECORATION_ITEM_FORM_STATE
@@ -49,47 +51,48 @@
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
   return (
-    <Modal
+    <FoxModal
+      open
+      size="sm"
       title="꾸미기 아이템 수정"
       onClose={onClose}
-      footer={
+      actions={
         <>
-          <Button type="button" variant="ghost" onClick={onClose}>
-            취소
-          </Button>
-          <Button
-            type="submit"
-            form={FORM_ID}
-            variant="primary"
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isPending ? '수정 중...' : '수정'}
             disabled={isPending}
-          >
-            {isPending ? '수정 중...' : '수정'}
-          </Button>
+            onAction={() => formRef.current?.requestSubmit()}
+          />
         </>
       }
     >
-      <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
+      <form ref={formRef} action={formAction}>
         <input type="hidden" name="itemSn" value={item.itemSn} />
-
-        <p className="text-right text-body-sm text-danger">
-          * 는 필수 항목입니다.
-        </p>
-
-        <Field label="아이템 ID">
-          <Input type="text" value={item.itemSn} readOnly />
-        </Field>
 
         <DecorationItemFormFields
           item={item}
           defaultItemType={item.itemType}
           typeTotalCount={typeTotalCount}
+          categories={categories}
           errors={errors}
+          itemIdField={
+            <FoxInput
+              size="md"
+              label="아이템 ID"
+              requirement="required"
+              value={String(item.itemSn)}
+              readOnly
+            />
+          }
         />
 
         {state.status === 'error' && state.message && (
-          <p className="text-body-sm text-danger">{state.message}</p>
+          <FoxHelperText type="danger" message={state.message} />
         )}
       </form>
-    </Modal>
+    </FoxModal>
   );
 }
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.module.scss (added)
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.module.scss
@@ -0,0 +1,20 @@
+// 등록·수정 팝업의 form-list — 시안 ADM_ITM_102_p(5227:4629)의 `item` 한 줄 배치.
+// 항목마다 위아래 16px 여백과 아래 실선 하나를 갖고, 라벨과 입력 사이는 8px이다.
+// @fox에 폼 줄 배치 조각이 없어 이 화면 전용으로 둔다(값은 전부 @fox 토큰).
+
+@use "@fox/styles/abstracts" as fox;
+
+.fields {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+}
+
+.field {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  gap: fox.gap(3);
+  padding-block: fox.padding(6);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtler);
+}
app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
@@ -1,159 +1,219 @@
 'use client';
 
-import { Field } from '@/components/ui/field';
-import { Input } from '@/components/ui/input';
-import { RadioGroup } from '@/components/ui/radio-group';
-import { Select } from '@/components/ui/select';
+import { useState, type ReactNode } from 'react';
+import { FoxFileUpload } from '@fox/core/components/fox-file-upload';
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import { FoxTab } from '@fox/core/components/fox-tab';
+import { FoxTabItem } from '@fox/core/components/fox-tab-item';
+import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
+import type { CommonCode } from '@/lib/domain/common-code';
 import {
-  DECORATION_ITEM_CATEGORIES,
   DECORATION_ITEM_TYPE_OPTIONS,
-  DEFAULT_DECORATION_ITEM_CATEGORY_CODE,
+  formatDecorationItemActiveLabel,
   type DecorationItem,
   type DecorationItemType,
 } from '@/lib/domain/decoration-item';
 import type { DecorationItemFormErrors } from '@/lib/domain/decoration-item-form';
-
-const ACTIVE_STATUS_OPTIONS = [
-  { value: 'true', label: '사용' },
-  { value: 'false', label: '미사용' },
-];
+import styles from './decoration-item-form-fields.module.scss';
 
 interface DecorationItemFormFieldsProps {
   /** 수정 팝업의 기존 값. 등록 팝업은 넘기지 않는다(빈 폼 + 기본값). */
   item?: DecorationItem;
   /** 등록 팝업을 연 시점의 활성 탭(유형) — 수정 팝업에서는 `item.itemType`이 대신 쓰인다. */
   defaultItemType: DecorationItemType;
-  /** "정렬순서" 라벨의 "(총 등록 N개)" 힌트와 등록 시 기본값 계산에 쓰는, 현재 유형의 전체
-   *  등록 건수(검색어 무관). */
+  /** 현재 유형의 전체 등록 건수(검색어 무관) — 정렬순서의 "/ 총 N개"와 기본값에 쓴다. */
   typeTotalCount: number;
+  /** 공통코드 `ITEM_CATE_CD` — 카테고리 선택지. 서버에서 조회해 내려온다. */
+  categories: CommonCode[];
   errors: DecorationItemFormErrors;
+  /** 아이템ID 줄 — 등록은 자동발급 안내, 수정은 발급된 값이라 팝업마다 달라 밖에서 받는다. */
+  itemIdField: ReactNode;
 }
 
+/** 시안의 안내 문구 그대로. */
+const IMAGE_DESCRIPTION =
+  'JPG, PNG 파일만 가능 (권장 사이즈: 1280x720px, 최대 5MB)';
+
 /**
- * 등록·수정 팝업이 공유하는 입력 항목 — 유형/아이템명/카테고리/포인트/썸네일 이미지/설명/
- * 사용여부/정렬순서(시안 ADM_ITM_102_p / 103_p). 아이템ID 표시만 각 팝업이 따로 그린다.
+ * 등록·수정 팝업이 공유하는 입력 항목 — 시안 ADM_ITM_102_p(5227:4629).
  *
- * **유형(개별/셋트) 전환 시 폼 구성이 바뀔 수 있다는 시안 설명이 있지만, 두 유형의 입력 항목이
- * 완전히 같아 조건부 렌더링을 두지 않았다** — 유형 값 자체만 폼에 실어 저장한다.
+ * 유형은 라디오가 아니라 **탭**이다(시안). 목록의 유형 탭과 달리 이건 화면 이동이 아니라 폼의
+ * 값이라 상태를 여기서 들고, 저장에는 hidden 필드로 실어 보낸다 — `FoxTab`은 폼 요소가 아니다.
+ * 카테고리(`FoxSelect`)도 네이티브 `<select>`가 아니라 버튼+리스트박스라 같은 방식이다.
  *
- * **이미지는 파일 하나만 받는다** — 시안의 "썸네일, 아이템 이미지 각각 등록? → NO"에 따른 것이고,
- * 저장은 백엔드의 `atchFileId`에만 한다(썸네일은 추후 백엔드가 자동생성할 예정, 사용자 확정 사항).
- * 수정 팝업에서 파일을 새로 고르지 않으면 hidden 필드로 유지한 기존 파일 ID가 그대로 저장된다.
+ * **두 유형의 입력 항목은 완전히 같다** — 시안이 "셋트아이템도 동일한 항목으로 처리"라고
+ * 명시해 탭은 저장될 유형 값만 바꾼다.
  *
- * 설명은 여러 줄 입력이 자연스럽지만 공용 textarea 컴포넌트가 없어 로직 우선 단계에서는 단일행
- * `Input`으로 둔다(신설은 design 레인 소관).
+ * 썸네일은 파일 하나만 받는다. `FoxFileUpload`의 네이티브 입력에 `name`을 주면 고른 파일이
+ * 폼 제출에 그대로 실리므로 파일을 따로 들고 있지 않는다. 수정 팝업에서 새로 고르지 않으면
+ * hidden 필드의 기존 파일 ID가 그대로 저장된다.
  */
 export function DecorationItemFormFields({
   item,
   defaultItemType,
   typeTotalCount,
+  categories,
   errors,
+  itemIdField,
 }: DecorationItemFormFieldsProps) {
+  const [itemType, setItemType] = useState<DecorationItemType>(
+    item?.itemType ?? defaultItemType
+  );
+  // 수정 팝업은 기존 값, 등록 팝업은 첫 코드가 기본이다. 코드가 하나도 없으면 빈 값이고,
+  // 그대로 저장하면 Server Action의 검증이 "카테고리를 선택해 주세요."로 잡는다.
+  const [categoryCode, setCategoryCode] = useState(
+    item?.categoryCode || categories[0]?.code || ''
+  );
+  const [isActive, setIsActive] = useState(item?.isActive ?? true);
+  // 수정 팝업에서 파일을 새로 고르지 않았을 때 유지할 기존 이미지. 썸네일을 지우면 함께 비워
+  // 검증이 "썸네일 이미지를 등록해 주세요."로 걸리게 한다 — 비우지 않으면 지운 것처럼 보이는데
+  // 저장은 예전 이미지를 그대로 남긴다.
+  const [retainedImageFileId, setRetainedImageFileId] = useState(
+    item?.imageFileId ?? ''
+  );
+
   return (
     <>
-      <Field label="유형 *">
-        <RadioGroup
-          name="itemType"
-          // RadioGroup의 options는 mutable 배열을 요구해 도메인의 ReadonlyArray를 그대로 넘길
-          // 수 없다(components/ui는 design 레인 소관이라 시그니처를 바꾸지 않는다) — 얕은
-          // 복사로 새 mutable 배열을 만들어 넘긴다.
-          options={[...DECORATION_ITEM_TYPE_OPTIONS]}
-          defaultValue={item?.itemType ?? defaultItemType}
-        />
-      </Field>
-      <FieldError message={errors.itemType} />
+      <FoxTab
+        type="line"
+        size="lg"
+        label="아이템 유형"
+        value={itemType}
+        onValueChange={(value) => setItemType(value as DecorationItemType)}
+      >
+        {DECORATION_ITEM_TYPE_OPTIONS.map((option) => (
+          <FoxTabItem key={option.value} value={option.value}>
+            {option.label}
+          </FoxTabItem>
+        ))}
+      </FoxTab>
+      <input type="hidden" name="itemType" value={itemType} />
 
-      <Field label="아이템명 *">
-        <Input
-          type="text"
-          name="name"
-          defaultValue={item?.name ?? ''}
-          placeholder="아이템명을 입력하세요."
-        />
-      </Field>
-      <FieldError message={errors.name} />
+      <div className={styles.fields}>
+        <div className={styles.field}>{itemIdField}</div>
 
-      <Field label="카테고리 *">
-        <Select
-          name="categoryCode"
-          defaultValue={
-            item?.categoryCode ?? DEFAULT_DECORATION_ITEM_CATEGORY_CODE
-          }
-        >
-          {DECORATION_ITEM_CATEGORIES.map((category) => (
-            <option key={category.code} value={category.code}>
-              {category.label}
-            </option>
-          ))}
-        </Select>
-      </Field>
-      <FieldError message={errors.categoryCode} />
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="name"
+            label="아이템명"
+            requirement="required"
+            defaultValue={item?.name ?? ''}
+            placeholder="아이템명을 입력하세요."
+            invalid={Boolean(errors.name)}
+            message={errors.name}
+            maxLength={100}
+          />
+        </div>
 
-      <Field label="포인트(오픈 가능한 포인트) *">
-        <Input
-          type="number"
-          name="points"
-          min={0}
-          step={1}
-          defaultValue={item?.points}
-          placeholder="오픈 가능한 포인트"
-        />
-      </Field>
-      <FieldError message={errors.points} />
+        <div className={styles.field}>
+          <FoxSelect
+            size="md"
+            label="카테고리"
+            options={categories.map((category) => ({
+              value: category.code,
+              label: category.label,
+            }))}
+            placeholder="카테고리를 선택하세요."
+            value={categoryCode}
+            onValueChange={setCategoryCode}
+            error={Boolean(errors.categoryCode)}
+            hint={errors.categoryCode}
+          />
+          <input type="hidden" name="categoryCode" value={categoryCode} />
+        </div>
 
-      <Field label="썸네일 이미지 *">
-        <Input type="file" name="imageFile" accept="image/*" />
-      </Field>
-      {/* 수정 팝업에서 파일을 새로 고르지 않았을 때 기존 이미지를 유지하는 값. 등록 팝업에서는
-          빈 문자열이라 파일을 고르지 않으면 검증에서 걸린다. */}
-      <input
-        type="hidden"
-        name="imageFileId"
-        defaultValue={item?.imageFileId ?? ''}
-      />
-      {item?.imageFileId && (
-        <p className="text-body-sm text-foreground-muted">
-          현재 등록된 이미지가 있습니다. 새로 선택하면 교체됩니다.
-        </p>
-      )}
-      <FieldError message={errors.imageFileId} />
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            type="number"
+            name="points"
+            label="포인트(오픈 가능한 포인트)"
+            requirement="required"
+            min={0}
+            step={1}
+            defaultValue={item?.points}
+            placeholder="숫자를 입력하세요."
+            invalid={Boolean(errors.points)}
+            message={errors.points}
+          />
+        </div>
 
-      <Field label="설명">
-        <Input
-          type="text"
-          name="description"
-          defaultValue={item?.description ?? ''}
-          placeholder="설명을 입력하세요."
-        />
-      </Field>
-      <FieldError message={errors.description} />
+        <div className={styles.field}>
+          <FoxFileUpload
+            mode="image"
+            name="imageFile"
+            label="썸네일 이미지"
+            requirement="required"
+            accept="image/jpeg,image/png"
+            selectLabel="파일선택"
+            description={IMAGE_DESCRIPTION}
+            thumbnailWidth={120}
+            thumbnailHeight={120}
+            defaultFiles={
+              // 수정 팝업은 이미 등록된 이미지를 썸네일 자리에 먼저 보여준다.
+              item?.imageUrl
+                ? [
+                    {
+                      id: item.imageFileId ?? 'current',
+                      name: item.name,
+                      previewUrl: item.imageUrl,
+                    },
+                  ]
+                : undefined
+            }
+            onRemove={() => setRetainedImageFileId('')}
+          />
+          <input type="hidden" name="imageFileId" value={retainedImageFileId} />
+          {errors.imageFileId && (
+            <FoxHelperText type="danger" message={errors.imageFileId} />
+          )}
+        </div>
 
-      <Field label="사용여부">
-        <RadioGroup
-          name="isActive"
-          options={ACTIVE_STATUS_OPTIONS}
-          defaultValue={String(item?.isActive ?? true)}
-        />
-      </Field>
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="description"
+            label="설명"
+            defaultValue={item?.description ?? ''}
+            placeholder="설명을 입력하세요."
+            invalid={Boolean(errors.description)}
+            message={errors.description}
+            maxLength={500}
+          />
+        </div>
 
-      <Field label={`정렬순서 (총 등록 ${typeTotalCount}개)`}>
-        <Input
-          type="number"
-          name="sortOrder"
-          min={1}
-          step={1}
-          defaultValue={item?.sortOrder ?? typeTotalCount + 1}
-        />
-      </Field>
-      <FieldError message={errors.sortOrder} />
+        <div className={styles.field}>
+          <FoxFormLabel as="span">사용여부</FoxFormLabel>
+          <FoxToggleSwitch
+            size="md"
+            name="isActive"
+            value="true"
+            label={formatDecorationItemActiveLabel(isActive)}
+            checked={isActive}
+            onChange={setIsActive}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            type="number"
+            name="sortOrder"
+            label="정렬순서"
+            min={1}
+            step={1}
+            defaultValue={item?.sortOrder ?? typeTotalCount + 1}
+            placeholder="0번"
+            suffix={`/ 총 ${typeTotalCount}개`}
+            invalid={Boolean(errors.sortOrder)}
+            message={errors.sortOrder}
+          />
+        </div>
+      </div>
     </>
   );
-}
-
-/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */
-export function FieldError({ message }: { message?: string }) {
-  if (!message) {
-    return null;
-  }
-  return <p className="text-body-sm text-danger">{message}</p>;
 }
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-list-toolbar.tsx (deleted)
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-list-toolbar.tsx
@@ -1,73 +0,0 @@
-'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 {
-  DECORATION_ITEM_PAGE_SIZE_OPTIONS,
-  buildDecorationItemHref,
-  type DecorationItemPageSize,
-  type DecorationItemQuery,
-} from '@/lib/domain/decoration-item-query';
-import { DecorationItemCreateModal } from './decoration-item-create-modal';
-
-interface DecorationItemListToolbarProps {
-  query: DecorationItemQuery;
-  /** 현재 유형(개별/셋트)의 검색어 무관 전체 건수 — 등록 팝업의 "(총 등록 N개)" 힌트에 쓴다. */
-  typeTotalCount: number;
-}
-
-/**
- * 목록 툴바(시안 ADM_ITM_101) — 페이지 크기 select는 변경 즉시 URL을 갱신해 서버 데이터를
- * 다시 조회하게 하고 1페이지로 되돌린다. 시안에 admins의 "정렬" select·엑셀다운로드에
- * 대응하는 항목이 없어 페이지 크기와 등록 버튼만 둔다.
- *
- * [+ 신규 등록]은 현재 유형 탭을 기본값으로 들고 등록 팝업을 연다 — 팝업 열림 상태를 이
- * 컴포넌트가 소유한다(admin-member-list-toolbar.tsx와 동일한 패턴).
- */
-export function DecorationItemListToolbar({
-  query,
-  typeTotalCount,
-}: DecorationItemListToolbarProps) {
-  const router = useRouter();
-  const [isCreateOpen, setIsCreateOpen] = useState(false);
-
-  function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) {
-    const pageSize = Number(event.target.value) as DecorationItemPageSize;
-    router.replace(buildDecorationItemHref(query, { pageSize, page: 1 }));
-  }
-
-  return (
-    <div className="flex flex-wrap items-center justify-end gap-3">
-      <Button
-        type="button"
-        variant="primary"
-        onClick={() => setIsCreateOpen(true)}
-      >
-        + 신규 등록
-      </Button>
-
-      <Select
-        name="pageSize"
-        aria-label="페이지 크기"
-        defaultValue={query.pageSize}
-        onChange={handlePageSizeChange}
-      >
-        {DECORATION_ITEM_PAGE_SIZE_OPTIONS.map((size) => (
-          <option key={size} value={size}>
-            {size}개씩
-          </option>
-        ))}
-      </Select>
-
-      {isCreateOpen && (
-        <DecorationItemCreateModal
-          defaultItemType={query.itemType}
-          typeTotalCount={typeTotalCount}
-          onClose={() => setIsCreateOpen(false)}
-        />
-      )}
-    </div>
-  );
-}
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-list.module.scss (added)
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-list.module.scss
@@ -0,0 +1,13 @@
+// 목록 표의 썸네일 — 시안 ADM_ITM_101의 `img-thumb`(80×80 · 1px 테두리 · radius 4).
+// 그림은 잘라내지 않고 상자 안에 다 보이게 담는다(contain) — 아이템 생김새가 정보다.
+// @fox에 이미지 조각이 없어 이 화면 전용으로 둔다(값은 전부 @fox 토큰).
+
+@use "@fox/styles/abstracts" as fox;
+
+.thumbnail {
+  inline-size: 8rem;
+  block-size: 8rem;
+  border: fox.border(1) solid fox.color(border-neutral-subtler);
+  border-radius: fox.radius(2);
+  object-fit: contain;
+}
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-list.tsx (added)
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-list.tsx
@@ -0,0 +1,243 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { FoxSelectText } from '@fox/core/components/fox-select-text';
+import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator';
+import { FoxTab } from '@fox/core/components/fox-tab';
+import { FoxTabItem } from '@fox/core/components/fox-tab-item';
+import { FoxPlusIcon } from '@fox/core/icons';
+import type { CommonCode } from '@/lib/domain/common-code';
+import {
+  DECORATION_ITEM_TYPE_OPTIONS,
+  EMPTY_FIELD_PLACEHOLDER,
+  formatDecorationItemActiveLabel,
+  formatDecorationItemCategory,
+  formatDecorationItemPoints,
+  formatDecorationItemUpdatedAt,
+  type DecorationItem,
+  type DecorationItemType,
+} from '@/lib/domain/decoration-item';
+import {
+  DECORATION_ITEM_PAGE_SIZE_OPTIONS,
+  DECORATION_ITEM_SEARCH_FIELD_OPTIONS,
+  buildDecorationItemHref,
+  type DecorationItemPageSize,
+  type DecorationItemQuery,
+  type DecorationItemSearchField,
+} from '@/lib/domain/decoration-item-query';
+import { DecorationItemCreateModal } from './decoration-item-create-modal';
+import { DecorationItemRowActions } from './decoration-item-row-actions';
+import styles from './decoration-item-list.module.scss';
+
+interface DecorationItemListProps {
+  items: DecorationItem[];
+  query: DecorationItemQuery;
+  /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+  /** 현재 유형(개별/셋트)의 검색어 무관 전체 건수 — 등록·수정 팝업의 정렬순서 힌트에 쓴다. */
+  typeTotalCount: number;
+  /** 공통코드 `ITEM_CATE_CD` — 등록·수정 팝업의 카테고리 선택지. */
+  categories: CommonCode[];
+}
+
+/**
+ * 꾸미기 아이템 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ITM_101 (5227:3549)
+ *
+ * 뼈대는 `FoxListContainer`가 그리고, 이 파일은 이 화면 고유의 것만 갖는다 — 유형 탭, 10개 열의
+ * 정의, 페이지 크기 셀렉트, 신규 등록 버튼.
+ *
+ * **유형 탭이 이 화면의 중심이다.** 셋트아이템은 별도 화면이 없어 탭이 목록의 조회 조건
+ * (`itemType`)만 바꾼다 — 그래서 검색어·페이지 크기와 같은 층위의 상태이고, 다른 조건과
+ * 마찬가지로 URL이 소유한다. 링크(`href`)가 아니라 `router.replace`를 쓰는 이유는 두 가지다.
+ * 같은 화면 안의 전환이라 `role="tablist"` 의미가 맞고(FoxTabItem은 `href`를 주면 탭 역할을
+ * 뗀다), 평범한 `<a>`는 App Router에서 문서 전체를 다시 받는다.
+ *
+ * 탭을 바꾸면 검색어까지 지운다 — 유형이 다르면 아이템 집합 자체가 달라 이전 검색어를 이어
+ * 붙이면 "결과 없음"만 보게 된다.
+ */
+export function DecorationItemList({
+  items,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+  typeTotalCount,
+  categories,
+}: DecorationItemListProps) {
+  const router = useRouter();
+  const [isCreateOpen, setIsCreateOpen] = useState(false);
+
+  /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */
+  function go(patch: Partial<DecorationItemQuery>) {
+    router.replace(buildDecorationItemHref(query, { ...patch, page: 1 }));
+  }
+
+  const columns: FoxListColumn<DecorationItem>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      // 저장된 값이 아니라 현재 페이지 기준 표시 순번이다. 목록이 정렬순서 오름차순이라
+      // "가장 작은 정렬순서가 1번"이 되도록 앞에서부터 센다.
+      render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
+    },
+    { key: 'itemSn', header: '아이템ID', width: 236 },
+    {
+      key: 'thumbnail',
+      header: '썸네일',
+      width: 160,
+      render: (row) =>
+        row.imageUrl ? (
+          // 백엔드의 공개 이미지 GET이라 브라우저가 직접 부른다. `next/image`를 쓰려면 백엔드
+          // 호스트를 next.config에 등록해야 해 여기서는 <img>로 둔다.
+          // eslint-disable-next-line @next/next/no-img-element
+          <img
+            className={styles.thumbnail}
+            src={row.imageUrl}
+            alt={`${row.name} 썸네일`}
+          />
+        ) : (
+          EMPTY_FIELD_PLACEHOLDER
+        ),
+    },
+    { key: 'name', header: '아이템명', width: 236 },
+    {
+      key: 'category',
+      header: '카테고리',
+      width: 160,
+      render: formatDecorationItemCategory,
+    },
+    {
+      key: 'points',
+      header: '포인트',
+      width: 160,
+      render: (row) => formatDecorationItemPoints(row.points),
+    },
+    {
+      key: 'isActive',
+      header: '사용여부',
+      width: 120,
+      render: (row) => (
+        <FoxStatusIndicator
+          type={row.isActive ? 'success' : 'danger'}
+          label={formatDecorationItemActiveLabel(row.isActive)}
+        />
+      ),
+    },
+    { key: 'sortOrder', header: '정렬순서', width: 80 },
+    {
+      key: 'updatedAt',
+      header: '수정일시',
+      width: 160,
+      render: (row) => formatDecorationItemUpdatedAt(row.updatedAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 160,
+      render: (row) => (
+        <DecorationItemRowActions
+          item={row}
+          typeTotalCount={typeTotalCount}
+          categories={categories}
+        />
+      ),
+    },
+  ];
+
+  return (
+    <>
+      <FoxListContainer<DecorationItem>
+        title="꾸미기 아이템 목록"
+        breadcrumb={[
+          { label: '홈', href: '/' },
+          { label: '꾸미기아이템관리' },
+          { label: '아이템 관리' },
+        ]}
+        caption="꾸미기 아이템 목록"
+        tabs={
+          <FoxTab
+            type="line"
+            size="lg"
+            label="아이템 유형"
+            value={query.itemType}
+            onValueChange={(value) =>
+              go({ itemType: value as DecorationItemType, keyword: '' })
+            }
+          >
+            {DECORATION_ITEM_TYPE_OPTIONS.map((option) => (
+              <FoxTabItem key={option.value} value={option.value}>
+                {option.label}
+              </FoxTabItem>
+            ))}
+          </FoxTab>
+        }
+        columns={columns}
+        rows={items}
+        rowKey={(row) => String(row.itemSn)}
+        totalCount={totalCount}
+        emptyMessage="등록된 아이템이 없습니다. 다른 검색어로 다시 시도해 주세요."
+        sorts={
+          <FoxSelectText
+            size="sm"
+            ariaLabel="페이지 크기"
+            options={DECORATION_ITEM_PAGE_SIZE_OPTIONS.map((size) => ({
+              value: String(size),
+              label: `${size}개씩 보기`,
+            }))}
+            value={String(query.pageSize)}
+            onValueChange={(value) =>
+              go({ pageSize: Number(value) as DecorationItemPageSize })
+            }
+          />
+        }
+        searchFields={DECORATION_ITEM_SEARCH_FIELD_OPTIONS.map((option) => ({
+          value: option.value,
+          label: option.label,
+        }))}
+        searchField={query.searchField}
+        keyword={query.keyword}
+        toolbarActions={
+          <FoxButton
+            type="primary"
+            size="md"
+            leadingIcon={<FoxPlusIcon />}
+            label="신규 등록"
+            onAction={() => setIsCreateOpen(true)}
+          />
+        }
+        page={currentPage}
+        totalPages={totalPages}
+        buildHref={(patch) =>
+          buildDecorationItemHref(query, { page: Number(patch.page ?? 1) })
+        }
+        onQueryChange={(patch) => {
+          if (patch.keyword !== undefined || patch.searchField !== undefined) {
+            go({
+              keyword: String(patch.keyword ?? ''),
+              searchField: (patch.searchField ??
+                query.searchField) as DecorationItemSearchField,
+            });
+          }
+        }}
+      />
+
+      {isCreateOpen && (
+        <DecorationItemCreateModal
+          defaultItemType={query.itemType}
+          typeTotalCount={typeTotalCount}
+          categories={categories}
+          onClose={() => setIsCreateOpen(false)}
+        />
+      )}
+    </>
+  );
+}
app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
@@ -1,35 +1,34 @@
 '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';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { CommonCode } from '@/lib/domain/common-code';
 import type { DecorationItem } from '@/lib/domain/decoration-item';
 import { deleteDecorationItemAction } from '../_actions';
 import { DecorationItemEditModal } from './decoration-item-edit-modal';
 
 interface DecorationItemRowActionsProps {
   item: DecorationItem;
-  /** 수정 팝업의 "(총 등록 N개)" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */
+  /** 수정 팝업의 "/ 총 N개" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */
   typeTotalCount: number;
+  categories: CommonCode[];
 }
 
 /**
- * 목록 행의 "관리" 셀(시안 ADM_ITM_101) — 수정 팝업 열림 상태만 소유하는 최말단 상호작용
- * 경계다(테이블 전체를 클라이언트로 내리지 않기 위해 이 셀만 분리했다, admins와 동일 패턴).
+ * 목록 행의 "관리" 셀(시안 ADM_ITM_101) — 시안의 btn-ico 두 개(28×28 · 테두리 있는 default
+ * 계열)를 4px 간격으로 세운다.
  *
- * **삭제는 시안대로 확인 얼럿을 거친 뒤 실행한다** — 전역 얼럿(FeedbackProvider)의 actions
- * 슬롯에 직접 [취소]/[삭제] 버튼을 넣어 구성한다. 삭제는 되돌릴 수 없는 동작이라 얼럿 문구에
- * 명시한다.
- *
- * 시안의 연필·휴지통 아이콘 버튼이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을
- * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로 정의하는
- * 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다).
+ * **삭제는 확인 얼럿을 거친다** — 전역 얼럿(FeedbackProvider)의 actions 슬롯에 [취소]/[삭제]를
+ * 넣어 구성한다. 되돌릴 수 없는 동작이라 얼럿 문구에 명시한다.
  */
 export function DecorationItemRowActions({
   item,
   typeTotalCount,
+  categories,
 }: DecorationItemRowActionsProps) {
   const { showAlert, hideAlert, showToast } = useFeedback();
   const [isEditOpen, setIsEditOpen] = useState(false);
@@ -57,42 +56,41 @@
       message: `${item.name}(아이템ID ${item.itemSn})을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
       actions: (
         <>
-          <Button type="button" variant="ghost" onClick={hideAlert}>
-            취소
-          </Button>
-          <Button type="button" variant="primary" onClick={runDelete}>
-            삭제
-          </Button>
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton type="error" size="md" label="삭제" onAction={runDelete} />
         </>
       ),
     });
   }
 
   return (
-    <div className="flex items-center gap-1">
-      <FoxIconButton
-        type="ghost"
-        size="sm"
-        icon={<FoxPencilSimpleIcon />}
-        label="수정"
-        onAction={() => setIsEditOpen(true)}
-      />
-      <FoxIconButton
-        type="ghost"
-        size="sm"
-        icon={<FoxTrashIcon />}
-        label={isDeleting ? '삭제 중' : '삭제'}
-        disabled={isDeleting}
-        onAction={confirmDelete}
-      />
+    <>
+      <FoxButtonGroup size="sm">
+        <FoxIconButton
+          type="default"
+          size="sm"
+          icon={<FoxPencilSimpleIcon />}
+          label="수정"
+          onAction={() => setIsEditOpen(true)}
+        />
+        <FoxIconButton
+          type="default"
+          size="sm"
+          icon={<FoxTrashIcon />}
+          label={isDeleting ? '삭제 중' : '삭제'}
+          disabled={isDeleting}
+          onAction={confirmDelete}
+        />
+      </FoxButtonGroup>
 
       {isEditOpen && (
         <DecorationItemEditModal
           item={item}
           typeTotalCount={typeTotalCount}
+          categories={categories}
           onClose={() => setIsEditOpen(false)}
         />
       )}
-    </div>
+    </>
   );
 }
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-search-bar.tsx (deleted)
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-search-bar.tsx
@@ -1,74 +0,0 @@
-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 {
-  DECORATION_ITEMS_PATH,
-  DECORATION_ITEM_SEARCH_FIELD_OPTIONS,
-  DEFAULT_DECORATION_ITEM_SEARCH_FIELD,
-  buildDecorationItemHref,
-  type DecorationItemQuery,
-} from '@/lib/domain/decoration-item-query';
-
-interface DecorationItemSearchBarProps {
-  query: DecorationItemQuery;
-}
-
-/**
- * 검색 영역(시안 ADM_ITM_101) — 순수 `<form method="get">` GET 제출로 URL을 갱신한다
- * (자바스크립트 없이도 동작). 현재 `itemType`(유형 탭)·`pageSize`는 hidden input으로 함께
- * 제출해 검색 후에도 유지되게 하고, 검색 자체는 항상 1페이지로 돌아간다(hidden page 필드를
- * 두지 않아 `parseDecorationItemQuery`의 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼
- * 제출이 아니라 기본값(검색대상·검색어)으로 되돌린 링크라 현재 입력값과 무관하게 동작한다
- * (유형 탭은 초기화 대상이 아니다 — 지금 보고 있는 목록은 유지). 상호작용이 전부 네이티브 폼
- * 제출/링크 내비게이션이라 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다
- * (admin-member-search-bar.tsx와 동일한 설계).
- */
-export function DecorationItemSearchBar({ query }: DecorationItemSearchBarProps) {
-  const resetHref = buildDecorationItemHref(query, {
-    searchField: DEFAULT_DECORATION_ITEM_SEARCH_FIELD,
-    keyword: '',
-    page: 1,
-  });
-
-  return (
-    <form
-      method="get"
-      action={DECORATION_ITEMS_PATH}
-      className="flex flex-wrap items-end gap-3"
-    >
-      <input type="hidden" name="itemType" value={query.itemType} />
-      <input type="hidden" name="pageSize" value={query.pageSize} />
-
-      <Field label="검색 대상">
-        <Select name="searchField" defaultValue={query.searchField}>
-          {DECORATION_ITEM_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)/decoration-items/_components/decoration-item-table.tsx (deleted)
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-table.tsx
@@ -1,109 +0,0 @@
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeaderCell,
-  TableRow,
-} from '@/components/ui/table';
-import {
-  EMPTY_FIELD_PLACEHOLDER,
-  formatDecorationItemActiveLabel,
-  formatDecorationItemCategory,
-  formatDecorationItemPoints,
-  formatDecorationItemUpdatedAt,
-  type DecorationItem,
-} from '@/lib/domain/decoration-item';
-import { DecorationItemRowActions } from './decoration-item-row-actions';
-
-interface DecorationItemTableProps {
-  items: DecorationItem[];
-  /** 1-based 현재 페이지. "번호" 열의 표시 순번 계산에 쓴다. */
-  page: number;
-  pageSize: number;
-  /** 수정 팝업의 "(총 등록 N개)" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */
-  typeTotalCount: number;
-}
-
-const COLUMN_HEADERS = [
-  '번호',
-  '아이템ID',
-  '썸네일',
-  '아이템명',
-  '카테고리',
-  '포인트',
-  '사용여부',
-  '정렬순서',
-  '수정일시',
-  '관리',
-];
-
-/**
- * 꾸미기 아이템 목록 테이블(시안 ADM_ITM_101, 10컬럼).
- *
- * **"번호"는 admins와 달리 오름차순 표시 순번이다**(`offset + index + 1`) — 이 화면의 기본
- * 정렬이 정렬순서 오름차순이라(Repository가 보장) "가장 작은 정렬순서가 1번"이 직관적이다.
- * admins가 전체 건수에서 거꾸로 세는 이유(생성일 최신순 정렬)가 여기에는 없다.
- *
- * **썸네일은 백엔드 이미지 URL을 그대로 건다** — 등록한 파일 ID(`atchFileId`)로 만든
- * `GET /api/v1/common/file/image` 주소이며, 이 GET은 인증이 필요 없어 브라우저가 백엔드를 직접
- * 호출한다(사용자 확정 사항). URL 문자열은 서버에서 만들어 내려주므로 백엔드 주소 환경변수는
- * 여전히 서버 전용이다. `next/image`가 아니라 `<img>`를 쓰는 이유는 외부 호스트를
- * `next.config`에 등록해야 하는 설정 변경을 이번 범위(로직)에서 하지 않기 때문이다.
- *
- * "관리" 열은 행별 수정/삭제 트리거(`DecorationItemRowActions`)에 위임한다 — 상호작용이
- * 필요한 것은 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트
- * 경계로 뗀다.
- */
-export function DecorationItemTable({
-  items,
-  page,
-  pageSize,
-  typeTotalCount,
-}: DecorationItemTableProps) {
-  const offset = (page - 1) * pageSize;
-
-  return (
-    <Table>
-      <TableHead>
-        <TableRow>
-          {COLUMN_HEADERS.map((header) => (
-            <TableHeaderCell key={header}>{header}</TableHeaderCell>
-          ))}
-        </TableRow>
-      </TableHead>
-      <TableBody>
-        {items.map((item, index) => (
-          <TableRow key={item.itemSn}>
-            <TableCell>{offset + index + 1}</TableCell>
-            <TableCell>{item.itemSn}</TableCell>
-            <TableCell>
-              {item.imageUrl ? (
-                // eslint-disable-next-line @next/next/no-img-element -- 위 주석 참조(외부 호스트 설정 회피)
-                <img
-                  src={item.imageUrl}
-                  alt={`${item.name} 썸네일`}
-                  className="size-9 rounded-md object-cover"
-                />
-              ) : (
-                EMPTY_FIELD_PLACEHOLDER
-              )}
-            </TableCell>
-            <TableCell>{item.name}</TableCell>
-            <TableCell>{formatDecorationItemCategory(item)}</TableCell>
-            <TableCell>{formatDecorationItemPoints(item.points)}</TableCell>
-            <TableCell>{formatDecorationItemActiveLabel(item.isActive)}</TableCell>
-            <TableCell>{item.sortOrder}</TableCell>
-            <TableCell>{formatDecorationItemUpdatedAt(item.updatedAt)}</TableCell>
-            <TableCell>
-              <DecorationItemRowActions
-                item={item}
-                typeTotalCount={typeTotalCount}
-              />
-            </TableCell>
-          </TableRow>
-        ))}
-      </TableBody>
-    </Table>
-  );
-}
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-type-tabs.tsx (deleted)
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-type-tabs.tsx
@@ -1,59 +0,0 @@
-import Link from 'next/link';
-import { DECORATION_ITEM_TYPE_OPTIONS } from '@/lib/domain/decoration-item';
-import {
-  buildDecorationItemHref,
-  type DecorationItemQuery,
-} from '@/lib/domain/decoration-item-query';
-
-interface DecorationItemTypeTabsProps {
-  query: DecorationItemQuery;
-}
-
-const TAB_BASE_CLASSES =
-  'inline-flex h-10 items-center justify-center rounded-md px-4 text-body-md transition-colors duration-200';
-const ACTIVE_TAB_CLASSES = 'bg-primary text-on-primary';
-const INACTIVE_TAB_CLASSES =
-  'border border-border bg-surface text-foreground-muted hover:bg-surface-muted hover:text-foreground';
-
-/**
- * 유형 탭(시안 ADM_ITM_101 — 개별아이템/셋트아이템 전환). **셋트아이템은 별도 화면이 없고 이
- * 탭이 같은 목록·같은 폼의 유형만 바꾼다**는 것이 시안 두 곳에 명시된 사양이다
- * (`decoration-item-query.ts` 상단 주석 참조).
- *
- * 값이 URL(`itemType` 쿼리 파라미터)에 실리므로 순수 `next/link` 내비게이션으로 충분해
- * 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다.
- *
- * 탭 전환 시 검색어/검색대상/페이지크기는 유지하고 페이지만 1로 되돌린다(admin-member의
- * 정렬/페이지크기 전환과 같은 규칙) — 다른 유형으로 넘어가면 전체 건수 자체가 달라지기
- * 때문이다. 전용 탭 컴포넌트가 없어(§10.3, 신설은 design 레인 소관) 버튼 형태 Link 2개로
- * 구성했다.
- */
-export function DecorationItemTypeTabs({ query }: DecorationItemTypeTabsProps) {
-  return (
-    <div
-      role="tablist"
-      aria-label="아이템 유형"
-      className="flex items-center gap-2"
-    >
-      {DECORATION_ITEM_TYPE_OPTIONS.map((option) => {
-        const isActive = option.value === query.itemType;
-        return (
-          <Link
-            key={option.value}
-            href={buildDecorationItemHref(query, {
-              itemType: option.value,
-              page: 1,
-            })}
-            role="tab"
-            aria-selected={isActive}
-            className={`${TAB_BASE_CLASSES} ${
-              isActive ? ACTIVE_TAB_CLASSES : INACTIVE_TAB_CLASSES
-            }`}
-          >
-            {option.label}
-          </Link>
-        );
-      })}
-    </div>
-  );
-}
app/(protected)/(basic)/decoration-items/page.tsx
--- app/(protected)/(basic)/decoration-items/page.tsx
+++ app/(protected)/(basic)/decoration-items/page.tsx
@@ -1,99 +1,60 @@
 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 { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
 import { fetchDecorationItems } from '@/lib/data/repositories/decoration-item-repository';
-import {
-  buildDecorationItemHref,
-  parseDecorationItemQuery,
-} from '@/lib/domain/decoration-item-query';
-import { DecorationItemListToolbar } from './_components/decoration-item-list-toolbar';
-import { DecorationItemSearchBar } from './_components/decoration-item-search-bar';
-import { DecorationItemTable } from './_components/decoration-item-table';
-import { DecorationItemTypeTabs } from './_components/decoration-item-type-tabs';
+import { CODE_GROUP } from '@/lib/domain/common-code';
+import { parseDecorationItemQuery } from '@/lib/domain/decoration-item-query';
+import { DecorationItemList } from './_components/decoration-item-list';
 
 export const metadata: Metadata = {
   title: '꾸미기 아이템',
 };
+
+// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를
+// 원천 차단하기 위해 명시적으로 강제한다(학생 회원 목록과 동일).
+export const dynamic = 'force-dynamic';
 
 interface PageProps {
   searchParams: Promise<Record<string, string | string[] | undefined>>;
 }
 
 /**
- * 꾸미기 아이템 목록(시안 ADM_ITM_101).
+ * 꾸미기 아이템 목록(시안 ADM_ITM_101 — 5227:3549).
  *
  * **셋트아이템은 별도 화면이 없다** — 유형 탭(개별/셋트)만 바꿔 같은 목록·같은 폼을 재사용
- * 한다(시안 두 곳에 명시된 사양). `query.itemType`이 곧 그 탭 상태이고, 다른 검색·페이지
- * 상태와 마찬가지로 URL(searchParams)이 단일 진실원천이다(`useState`로 들지 않는다).
- *
- * `verifySession()`을 호출만 하고 반환값을 쓰지 않는 이유는 인증 확인 자체가 목적이기
- * 때문이다 — admins의 "본인 계정 삭제 방지" 같은 사용자 조건부 로직이 이 화면에는 없다.
+ * 한다(시안 두 곳에 명시된 사양). `query.itemType`이 곧 그 탭 상태이고, 검색·페이지와 마찬가지로
+ * URL(searchParams)이 단일 진실원천이다.
  */
 export default async function Page({ searchParams }: PageProps) {
   await verifySession();
 
   const query = parseDecorationItemQuery(await searchParams);
-  const { items, totalCount, isTotalCountExact, typeTotalCount } =
-    await fetchDecorationItems(query);
+  // 목록과 카테고리는 서로를 기다릴 이유가 없어 함께 띄운다.
+  const [{ items, totalCount, isTotalCountExact, typeTotalCount }, categories] =
+    await Promise.all([
+      fetchDecorationItems(query),
+      fetchCommonCodes(CODE_GROUP.decorationItemCategory),
+    ]);
 
   // 전체 건수가 확정되지 않았다면(백엔드가 count를 주지 않아 하한값만 아는 상태) 다음 페이지를
   // 한 칸 열어 둔다 — 열어 두지 않으면 가득 찬 페이지 뒤의 데이터에 접근할 방법이 없어진다
-  // (학생 목록과 동일한 보정. Repository 주석 참조).
+  // (Repository 주석 참조).
   const totalPages = isTotalCountExact
     ? Math.max(1, Math.ceil(totalCount / query.pageSize))
     : query.page + 1;
-  // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐, 또는 유형 탭 전환으로
-  // 전체 건수가 줄어듦) 마지막 페이지로 맞춘다 — 표의 순번 계산도 이 값을 기준으로 해야 헤더의
-  // "현재페이지"와 어긋나지 않는다.
+  // 요청 페이지가 범위를 벗어나면(삭제로 마지막 페이지가 사라짐, 유형 탭 전환으로 건수가 줄어듦)
+  // 마지막 페이지로 맞춘다 — 표의 순번도 이 값을 기준으로 세야 페이지 표시와 어긋나지 않는다.
   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>
-
-      <DecorationItemTypeTabs query={query} />
-
-      <DecorationItemSearchBar query={query} />
-
-      <DecorationItemListToolbar query={query} typeTotalCount={typeTotalCount} />
-
-      <p className="text-body-md text-foreground-muted">
-        총 {totalCount}개{isTotalCountExact ? '' : ' 이상'} | 현재페이지{' '}
-        {currentPage}
-        {isTotalCountExact ? `/${totalPages}` : ''}
-      </p>
-
-      {items.length === 0 ? (
-        <Alert
-          title="등록된 아이템이 없습니다."
-          actions={
-            <Link
-              href={buildDecorationItemHref(query, { keyword: '', page: 1 })}
-              className="bg-foreground px-4 py-2 text-background"
-            >
-              확인
-            </Link>
-          }
-        >
-          다른 검색어로 다시 시도해 주세요.
-        </Alert>
-      ) : (
-        <>
-          <DecorationItemTable
-            items={items}
-            page={currentPage}
-            pageSize={query.pageSize}
-            typeTotalCount={typeTotalCount}
-          />
-          <Pagination
-            currentPage={currentPage}
-            totalPages={totalPages}
-            buildHref={(page: number) => buildDecorationItemHref(query, { page })}
-          />
-        </>
-      )}
-    </section>
+    <DecorationItemList
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+      typeTotalCount={typeTotalCount}
+      categories={categories}
+    />
   );
 }
app/(protected)/(basic)/students/_actions.ts
--- app/(protected)/(basic)/students/_actions.ts
+++ app/(protected)/(basic)/students/_actions.ts
@@ -1,34 +1,49 @@
 'use server';
 
+import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
+import { updateStudentMemberUseYn } from '@/lib/data/repositories/student-member-repository';
+import { STUDENT_MEMBERS_PATH } from '@/lib/domain/student-member-query';
 
 export type UpdateStudentActiveStatusState =
   | { status: 'idle' }
   | { status: 'error'; error: string }
   | { status: 'success' };
 
-const UNSUPPORTED_ERROR =
-  '사용여부 변경은 아직 제공되지 않습니다. (백엔드 API 준비 중)';
+const INVALID_INPUT_ERROR = '요청 값이 올바르지 않습니다.';
 
 /**
- * 학생 회원 사용여부 변경 Server Action — **현재는 미지원 상태의 이식 지점**이다.
+ * 학생 회원 사용여부 변경 Server Action.
  *
- * 목록이 백엔드 API(`GET /api/v1/mngr/user/pagination`)로 전환되면서 이 화면의 데이터 원천은
- * 백엔드가 됐지만, 사용여부는 **조회 응답에 값도 없고 변경 API도 없다.** 이전의 mock 쓰기를
- * 그대로 두면 mock 배열에만 존재하는 id를 찾다가 "존재하지 않는 학생 회원" 오류가 나므로
- * (목록의 id는 이제 백엔드 `userId`다) 쓰기 경로를 명시적으로 막았다. 팝업의 사용여부 라디오와
- * 저장 버튼도 같은 이유로 비활성이다.
+ *   PUT /api/v1/mngr/user/{userId}/{useYn}
  *
- * 변경 API가 생기면 `formData: FormData` 인자를 되살리고 본문을 "입력 검증 → Repository 호출
- * → `revalidatePath`"로 되돌리면 된다(지금은 읽을 입력이 없어 인자를 받지 않는다 — 인자를
- * 줄여도 `useActionState`의 호출 규약에는 어긋나지 않는다). 인증 확인을 본문 맨 앞에 남겨 둔
- * 것도 그 형태를 유지하기 위함이다 — Server Action은 UI를 거치지 않고 직접 POST될 수 있어
- * 이 확인이 유일한 최종 방어선이다.
+ * 백엔드에 값도 변경 API도 없어 한동안 막아 두었던 경로다. edupay-backend develop 4d98756에서
+ * 목록 응답에 `useYn`이 실리고 변경 API가 생기면서 되살렸다.
+ *
+ * 인증 확인을 본문 맨 앞에 둔다 — Server Action은 UI를 거치지 않고 직접 POST될 수 있어 이
+ * 확인이 유일한 최종 방어선이다. 실패 사유는 일반화된 문구만 화면으로 보낸다(백엔드 message
+ * 원문에는 내부 정보가 실릴 수 있다).
  */
 export async function updateStudentActiveStatus(
-  _prevState: UpdateStudentActiveStatusState
+  _prevState: UpdateStudentActiveStatusState,
+  formData: FormData
 ): Promise<UpdateStudentActiveStatusState> {
   await verifySession();
 
-  return { status: 'error', error: UNSUPPORTED_ERROR };
+  const id = formData.get('id');
+  if (typeof id !== 'string' || id.trim() === '') {
+    return { status: 'error', error: INVALID_INPUT_ERROR };
+  }
+
+  // 체크박스는 켜졌을 때만 값이 실린다 — 없으면 꺼진 것이다.
+  const isActive = formData.get('isActive') !== null;
+
+  const result = await updateStudentMemberUseYn(id, isActive);
+  if (!result.ok) {
+    return { status: 'error', error: result.message };
+  }
+
+  // 목록의 사용여부 열이 방금 바꾼 값을 반영해야 한다.
+  revalidatePath(STUDENT_MEMBERS_PATH);
+  return { status: 'success' };
 }
 
app/(protected)/(basic)/students/_components/student-detail-modal.module.scss (added)
+++ app/(protected)/(basic)/students/_components/student-detail-modal.module.scss
@@ -0,0 +1,19 @@
+@use "@fox/styles/abstracts" as fox;
+
+// 학생 회원 조회 팝업 — 이 화면에만 필요한 배치.
+
+// FoxModal의 `contents`가 `align-items: flex-start`인 flex 열이라, 그 안의 블록 요소는
+// 내용 너비로 줄어든다. 폼은 조회 목록을 감싸기만 하므로 자리를 그대로 넘겨야 한다.
+.form {
+  inline-size: 100%;
+}
+
+// 저장 실패 사유. 사용여부 변경 API가 아직 없어 저장을 누르면 이 자리에 뜬다.
+.error {
+  margin: 0;
+  color: fox.color(font-system-danger);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(body-sm);
+  line-height: 1.5;
+  letter-spacing: -0.025em;
+}
app/(protected)/(basic)/students/_components/student-detail-modal.tsx
--- app/(protected)/(basic)/students/_components/student-detail-modal.tsx
+++ app/(protected)/(basic)/students/_components/student-detail-modal.tsx
@@ -1,12 +1,16 @@
 'use client';
 
-import { useActionState, useEffect } 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 { RadioGroup } from '@/components/ui/radio-group';
+import { useActionState, useEffect, useRef, useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
 import {
+  FoxDescriptionList,
+  type FoxDescriptionItem,
+} from '@fox/core/components/fox-description-list';
+import { FoxModal } from '@fox/core/components/fox-modal';
+import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
+import { formatPhoneNumber } from '@/lib/domain/phone-number';
+import {
+  EMPTY_FIELD_PLACEHOLDER,
   formatGradeClassNumber,
   formatOptionalValue,
   type StudentMember,
@@ -15,148 +19,150 @@
   updateStudentActiveStatus,
   type UpdateStudentActiveStatusState,
 } from '../_actions';
+import styles from './student-detail-modal.module.scss';
 
 interface StudentDetailModalProps {
   member: StudentMember;
   onClose: () => void;
 }
 
-const ACTIVE_STATUS_OPTIONS = [
-  { value: 'true', label: '활성' },
-  { value: 'false', label: '비활성' },
-];
-
 const INITIAL_STATE: UpdateStudentActiveStatusState = { status: 'idle' };
 const DETAIL_FORM_ID = 'student-active-status-form';
 
-/**
- * 사용여부 수정 가능 여부의 단일 토글. 백엔드에 사용여부 값도 변경 API도 없어 현재는 false다 —
- * 변경 API가 생기면 이 상수를 true로 되돌리고 `_actions.ts`의 미지원 처리를 실제 구현으로
- * 바꾸면 된다(라디오·저장 버튼의 비활성이 함께 풀린다).
- */
-const IS_ACTIVE_STATUS_EDITABLE = false;
-const ACTIVE_STATUS_UNSUPPORTED_NOTE =
-  '사용여부 변경은 백엔드 API 준비 후 제공됩니다.';
+/** 시안은 두 값을 " / "로 묶는다("홍라애 / 010-2222-1234"). 둘 다 없으면 `-`다. */
+function joinPair(first: string | null, second: string | null) {
+  const parts = [first, second].filter(
+    (value): value is string => value !== null && value !== ''
+  );
+  return parts.length > 0 ? parts.join(' / ') : '-';
+}
 
 /**
- * 학생 회원 조회 팝업 — 모든 항목이 readOnly다. 데이터는 목록 행에서 이미 갖고 있는 member를
- * props로 그대로 받으므로 재조회하지 않는다(백엔드에 단건 조회 API도 아직 없다).
- * 백엔드가 주지 않는 항목은 `-`로 표시한다.
+ * 학생 회원 조회 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) modal 5227:809
  *
- * 사용여부는 원래 이 팝업에서 유일하게 수정 가능한 항목이었지만, 백엔드 목록 응답에 값이 없고
- * 변경 API도 없어 지금은 라디오·저장 버튼을 비활성으로 둔다(`_actions.ts`의 미지원 처리와 짝).
- * 폼·Server Action 배선은 그대로 남겨 두었으므로 변경 API가 생기면 `disabled`만 걷어내면 된다.
- * 저장(Server Action) 성공 시 자동으로 닫히는 동작도 그대로다.
+ * 모든 항목이 조회 전용이다. 데이터는 목록 행이 이미 갖고 있는 member를 그대로 받으므로
+ * 재조회하지 않는다(백엔드에 단건 조회 API도 아직 없다). 백엔드가 주지 않는 값은 `-`다.
  *
- * Modal이 내부적으로 portal을 쓸 수도 있어(구현 미확정 — components/ui/modal.tsx는 design
- * 레인 소관) 저장 버튼은 footer 슬롯에서 `form={DETAIL_FORM_ID}` 속성으로 폼과 연결한다.
- * 네이티브 HTML의 `form` 속성은 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서
- * id만 일치하면 그 form을 제출한다 — footer가 body와 다른 DOM 위치에 렌더돼도 안전하다.
+ * 시안은 보호자(이름/연락처)와 학교(학교/학년·반·번호)를 각각 한 줄로 묶는다 — 종전처럼
+ * 항목을 따로 두지 않고 시안의 8줄 구성을 그대로 따랐다.
+ *
+ * 사용여부는 이 팝업에서 유일하게 수정 가능한 항목이다. 토글을 넘기면 화면이 바로 반응하고,
+ * 저장을 누르면 `PUT /api/v1/mngr/user/{userId}/{useYn}`로 나간다.
+ *
+ * 저장 버튼은 `form={DETAIL_FORM_ID}`로 폼과 이어 둔다 — FoxModal이 버튼을 `foot` 슬롯에
+ * 그려 폼의 자손이 아니게 되는데, 네이티브 `form` 속성은 같은 문서 안에서 id만 맞으면 그
+ * 폼을 제출한다.
  */
 export function StudentDetailModal({ member, onClose }: StudentDetailModalProps) {
   const [state, formAction, isPending] = useActionState(
     updateStudentActiveStatus,
     INITIAL_STATE
   );
+  const formRef = useRef<HTMLFormElement>(null);
+  // 토글은 화면에서 바로 반응해야 하므로 이 컴포넌트가 상태를 갖는다 — prop에서 계산하면
+  // 스위치를 넘겨도 라벨이 그대로다. 저장하면 이 값이 폼에 실려 나간다.
+  const [isActive, setIsActive] = useState(member.isActive ?? false);
 
+  // 학년·반이 하나도 없으면 `-`가 오는데, 그때는 "학교 / -"가 아니라 학교만 보여야 한다.
+  const formattedGradeClass = formatGradeClassNumber(member);
+  const gradeClassNumber =
+    formattedGradeClass === EMPTY_FIELD_PLACEHOLDER ? null : formattedGradeClass;
+
+  // 저장에 성공하면 닫는다. 목록은 Server Action의 revalidatePath가 다시 그린다.
   useEffect(() => {
     if (state.status === 'success') {
       onClose();
     }
   }, [state, onClose]);
 
-  const gradeClassNumber = formatGradeClassNumber(member);
+  const items: FoxDescriptionItem[] = [
+    { key: 'name', term: '이름', description: member.name },
+    { key: 'loginId', term: 'ID', description: member.loginId },
+    {
+      key: 'phoneNumber',
+      term: '휴대전화 번호',
+      description: formatOptionalValue(formatPhoneNumber(member.phoneNumber)),
+    },
+    {
+      key: 'email',
+      term: '이메일',
+      description: formatOptionalValue(member.email),
+    },
+    {
+      key: 'birthDate',
+      term: '생년월일',
+      description: formatOptionalValue(member.birthDate),
+    },
+    {
+      key: 'guardian',
+      term: '보호자 이름 / 연락처',
+      description: joinPair(
+        member.guardianName,
+        formatPhoneNumber(member.guardianPhoneNumber)
+      ),
+    },
+    {
+      key: 'school',
+      term: '학교 / 학년·반·번호',
+      description: joinPair(member.schoolName, gradeClassNumber),
+    },
+    {
+      key: 'isActive',
+      term: '사용여부',
+      description: (
+        <FoxToggleSwitch
+          name="isActive"
+          value="true"
+          label={isActive ? '활성' : '비활성'}
+          checked={isActive}
+          onChange={(checked) => setIsActive(checked)}
+        />
+      ),
+    },
+  ];
 
   return (
-    <Modal
+    <FoxModal
+      open
+      size="sm"
       title="학생 회원 조회"
       onClose={onClose}
-      footer={
+      utility={
+        state.status === 'error' ? (
+          <p className={styles.error} role="alert">
+            {state.error}
+          </p>
+        ) : undefined
+      }
+      actions={
         <>
-          <Button type="button" variant="ghost" onClick={onClose}>
-            취소
-          </Button>
-          <Button
-            type="submit"
-            form={DETAIL_FORM_ID}
-            variant="primary"
-            disabled={isPending || !IS_ACTIVE_STATUS_EDITABLE}
-            title={
-              IS_ACTIVE_STATUS_EDITABLE
-                ? undefined
-                : ACTIVE_STATUS_UNSUPPORTED_NOTE
-            }
-          >
-            {isPending ? '저장 중...' : '저장'}
-          </Button>
+          <FoxButton
+            type="default"
+            size="md"
+            label="취소"
+            onAction={onClose}
+          />
+          {/* FoxButton에는 폼을 가리키는 prop이 없다(네이티브 `form` 속성 미노출). 버튼이
+              foot 슬롯에 그려져 폼의 자손이 아니므로 ref로 직접 제출한다. */}
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isPending ? '저장 중...' : '저장'}
+            disabled={isPending}
+            onAction={() => formRef.current?.requestSubmit()}
+          />
         </>
       }
     >
-      <form id={DETAIL_FORM_ID} action={formAction} className="flex flex-col gap-4">
+      <form
+        ref={formRef}
+        id={DETAIL_FORM_ID}
+        action={formAction}
+        className={styles.form}
+      >
         <input type="hidden" name="id" value={member.id} />
-
-        <p className="text-body-sm text-foreground-muted">
-          본 화면의 모든 항목은 조회 전용입니다.
-        </p>
-
-        <Field label="이름">
-          <Input value={member.name} readOnly />
-        </Field>
-        <Field label="ID">
-          <Input value={member.loginId} readOnly />
-        </Field>
-        <Field label="휴대전화 번호">
-          <Input value={formatOptionalValue(member.phoneNumber)} readOnly />
-        </Field>
-        <Field label="이메일">
-          <Input value={formatOptionalValue(member.email)} readOnly />
-        </Field>
-        <Field label="생년월일">
-          <Input value={formatOptionalValue(member.birthDate)} readOnly />
-        </Field>
-        <Field label="보호자 이름">
-          <Input value={formatOptionalValue(member.guardianName)} readOnly />
-        </Field>
-        <Field label="보호자 연락처">
-          <Input
-            value={formatOptionalValue(member.guardianPhoneNumber)}
-            readOnly
-          />
-        </Field>
-        <Field label="학교">
-          <Input value={formatOptionalValue(member.schoolName)} readOnly />
-        </Field>
-        <Field label="학년/반/번호">
-          <Input value={gradeClassNumber} readOnly />
-        </Field>
-
-        <Field label="사용여부">
-          <RadioGroup
-            name="isActive"
-            options={ACTIVE_STATUS_OPTIONS}
-            defaultValue={
-              member.isActive === null
-                ? undefined
-                : String(member.isActive)
-            }
-            disabled={!IS_ACTIVE_STATUS_EDITABLE}
-          />
-        </Field>
-
-        {!IS_ACTIVE_STATUS_EDITABLE && (
-          <p className="text-body-sm text-foreground-muted">
-            {ACTIVE_STATUS_UNSUPPORTED_NOTE}
-          </p>
-        )}
-
-        {state.status === 'error' && (
-          <p className="text-body-sm text-danger">{state.error}</p>
-        )}
-
-        <p className="text-body-sm text-foreground-muted">
-          회원 가입일 : {formatOptionalValue(member.joinedAt)}
-        </p>
+        <FoxDescriptionList items={items} />
       </form>
-    </Modal>
+    </FoxModal>
   );
 }
 
app/(protected)/(basic)/students/_components/student-list-toolbar.tsx (deleted)
--- app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
@@ -1,86 +0,0 @@
-'use client';
-
-import { useRouter } from 'next/navigation';
-import type { ChangeEvent } from 'react';
-import { Button } from '@/components/ui/button';
-import { Select } from '@/components/ui/select';
-import {
-  STUDENT_MEMBERS_EXCEL_PATH,
-  STUDENT_MEMBER_PAGE_SIZE_OPTIONS,
-  STUDENT_MEMBER_SORT_OPTIONS,
-  buildStudentMemberHref,
-  type StudentMemberPageSize,
-  type StudentMemberQuery,
-  type StudentMemberSortOption,
-} from '@/lib/domain/student-member-query';
-
-interface StudentListToolbarProps {
-  query: StudentMemberQuery;
-}
-
-/**
- * 목록 툴바 — 정렬/페이지 크기 select는 변경 즉시 URL을 갱신해 서버 데이터를 다시 조회하게
- * 한다(select의 onChange가 필요해 이 컴포넌트만 최말단 클라이언트 경계로 분리했다). 두 값 모두
- * 바뀌면 1페이지로 되돌린다(기존 페이지 번호가 새 정렬·크기 기준으로는 의미가 달라지므로).
- * 엑셀다운로드는 이번 범위에서 버튼 배치만 하고 동작은 구현하지 않는다(사용자 지시) —
- * disabled로 두어 클릭해도 아무 일도 일어나지 않게 한다.
- *
- * 정렬 select는 URL의 `sort`만 바꾸고, 그 값을 어떻게 만족시킬지는 Repository가 정한다 —
- * 가입일순은 백엔드의 고정 순서와 같아 그대로 받아오고, 이름순은 백엔드에 정렬 파라미터가 없어
- * 전체를 받아 서버에서 정렬한다. 화면은 그 차이를 알 필요가 없다.
- */
-export function StudentListToolbar({ query }: StudentListToolbarProps) {
-  const router = useRouter();
-
-  function handleSortChange(event: ChangeEvent<HTMLSelectElement>) {
-    const sort = event.target.value as StudentMemberSortOption;
-    router.replace(buildStudentMemberHref(query, { sort, page: 1 }));
-  }
-
-  function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) {
-    const pageSize = Number(event.target.value) as StudentMemberPageSize;
-    router.replace(buildStudentMemberHref(query, { pageSize, page: 1 }));
-  }
-
-  return (
-    <div className="flex flex-wrap items-center justify-between gap-3">
-      {/* 네이티브 GET 폼 제출 — 응답이 첨부파일(Content-Disposition: attachment)이라 브라우저가
-          현재 화면을 그대로 둔 채 파일만 내려받는다. 라우터 내비게이션(router.push)으로는
-          첨부파일 응답을 처리할 수 없고, Button은 <button>이라 링크로 쓸 수 없어 폼으로 감쌌다.
-          검색·페이징 값을 hidden으로 싣지 않는 것이 사양이다 — 파일은 항상 전체 데이터다. */}
-      <form action={STUDENT_MEMBERS_EXCEL_PATH} method="get">
-        <Button type="submit" variant="primary">
-          엑셀다운로드
-        </Button>
-      </form>
-
-      <div className="flex items-center gap-3">
-        <Select
-          name="sort"
-          aria-label="정렬"
-          defaultValue={query.sort}
-          onChange={handleSortChange}
-        >
-          {STUDENT_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}
-        >
-          {STUDENT_MEMBER_PAGE_SIZE_OPTIONS.map((size) => (
-            <option key={size} value={size}>
-              {size}개씩
-            </option>
-          ))}
-        </Select>
-      </div>
-    </div>
-  );
-}
 
app/(protected)/(basic)/students/_components/student-list.tsx (added)
+++ app/(protected)/(basic)/students/_components/student-list.tsx
@@ -0,0 +1,217 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { FoxBadge } from '@fox/core/components/fox-badge';
+import { FoxButton } from '@fox/core/components/fox-button';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { FoxSelectText } from '@fox/core/components/fox-select-text';
+import {
+  FoxArrowsDownUpIcon,
+  FoxDownloadSimpleIcon,
+} from '@fox/core/icons';
+import { formatPhoneNumber } from '@/lib/domain/phone-number';
+import {
+  formatGradeClassNumber,
+  type StudentMember,
+} from '@/lib/domain/student-member';
+import {
+  STUDENT_MEMBERS_EXCEL_PATH,
+  STUDENT_MEMBER_PAGE_SIZE_OPTIONS,
+  STUDENT_MEMBER_SEARCH_FIELD_OPTIONS,
+  STUDENT_MEMBER_SORT_OPTIONS,
+  buildStudentMemberHref,
+  type StudentMemberPageSize,
+  type StudentMemberQuery,
+  type StudentMemberSearchField,
+  type StudentMemberSortOption,
+} from '@/lib/domain/student-member-query';
+import { StudentDetailModal } from './student-detail-modal';
+
+interface StudentListProps {
+  items: StudentMember[];
+  query: StudentMemberQuery;
+  /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+}
+
+/** 값이 없으면 시안처럼 자리를 비우지 않고 `-`로 채운다. */
+function text(value: string | number | null | undefined) {
+  return value === null || value === undefined || value === '' ? '-' : String(value);
+}
+
+/**
+ * 학생 회원 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container 5199:9014
+ *
+ * 뼈대는 `FoxListContainer`가 전부 그린다. 이 파일이 갖는 것은 **이 화면 고유의 것**뿐이다 —
+ * 13개 열의 정의, 정렬·페이지 크기 셀렉트, 엑셀 다운로드, 그리고 URL을 갱신하는 방법.
+ *
+ * 이동은 두 갈래로 나뉜다. 페이지네이션은 `buildHref`로 **링크**가 되고(주소가 곧 상태라
+ * 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간
+ * `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다.
+ *
+ * 순번은 `query.page`가 아니라 `currentPage`를 기준으로 센다 — 백엔드가 범위를 벗어난 페이지
+ * 요청을 잘라서 응답하면 둘이 어긋나, "1/1 페이지"인데 순번은 31부터 시작하는 상태가 된다.
+ */
+export function StudentList({
+  items,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+}: StudentListProps) {
+  const router = useRouter();
+  const [detailMember, setDetailMember] = useState<StudentMember | null>(null);
+
+  /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */
+  function go(patch: Partial<StudentMemberQuery>) {
+    router.replace(buildStudentMemberHref(query, { ...patch, page: 1 }));
+  }
+
+  const columns: FoxListColumn<StudentMember>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      // 저장된 값이 아니라 현재 페이지 기준 표시 순번이다(종전 표와 같은 규칙).
+      render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
+    },
+    { key: 'memberCode', header: '회원코드', width: 120 },
+    { key: 'name', header: '이름', width: 120 },
+    { key: 'loginId', header: '아이디', width: 160 },
+    {
+      key: 'phoneNumber',
+      header: '휴대전화번호',
+      width: 160,
+      render: (row) => text(formatPhoneNumber(row.phoneNumber)),
+    },
+    { key: 'email', header: '이메일', width: 200, render: (row) => text(row.email) },
+    {
+      key: 'role',
+      header: '역할',
+      width: 120,
+      render: (row) => (
+        <FoxBadge type="pastel" color="primary" size="md">
+          {row.role}
+        </FoxBadge>
+      ),
+    },
+    {
+      key: 'schoolName',
+      header: '학교',
+      width: 160,
+      render: (row) => text(row.schoolName),
+    },
+    { key: 'classInfo', header: '학년/반/번호', width: 160, render: formatGradeClassNumber },
+    {
+      key: 'guardianName',
+      header: '보호자이름',
+      width: 120,
+      render: (row) => text(row.guardianName),
+    },
+    {
+      key: 'guardianPhoneNumber',
+      header: '보호자연락처',
+      width: 160,
+      render: (row) => text(formatPhoneNumber(row.guardianPhoneNumber)),
+    },
+    {
+      key: 'joinedAt',
+      header: '가입일',
+      width: 160,
+      render: (row) => text(row.joinedAt),
+    },
+  ];
+
+  return (
+    <>
+      <FoxListContainer<StudentMember>
+      title="학생 회원 목록"
+      breadcrumb={[
+        { label: '홈', href: '/' },
+        { label: '회원정보관리' },
+        { label: '학생 회원' },
+      ]}
+      caption="학생 회원 목록"
+      columns={columns}
+      rows={items}
+      rowKey={(row) => row.id}
+      totalCount={totalCount}
+      emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요."
+      sorts={
+        <>
+          <FoxSelectText
+            size="sm"
+            ariaLabel="정렬"
+            icon={<FoxArrowsDownUpIcon />}
+            options={STUDENT_MEMBER_SORT_OPTIONS.map((option) => ({
+              value: option.value,
+              label: option.label,
+            }))}
+            value={query.sort}
+            onValueChange={(value) => go({ sort: value as StudentMemberSortOption })}
+          />
+          <FoxSelectText
+            size="sm"
+            ariaLabel="페이지 크기"
+            options={STUDENT_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ({
+              value: String(size),
+              label: `${size}개씩 보기`,
+            }))}
+            value={String(query.pageSize)}
+            onValueChange={(value) =>
+              go({ pageSize: Number(value) as StudentMemberPageSize })
+            }
+          />
+        </>
+      }
+      searchFields={STUDENT_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ({
+        value: option.value,
+        label: option.label,
+      }))}
+      searchField={query.searchField}
+      keyword={query.keyword}
+      toolbarActions={
+        // 네이티브 GET 폼이라야 한다 — 응답이 첨부파일(Content-Disposition)이라 라우터
+        // 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다.
+        // 검색·페이징 값을 싣지 않는 것이 사양이다(파일은 항상 전체 데이터).
+        <form action={STUDENT_MEMBERS_EXCEL_PATH} method="get">
+          <FoxButton
+            type="secondary"
+            size="md"
+            htmlType="submit"
+            leadingIcon={<FoxDownloadSimpleIcon />}
+            label="엑셀 다운로드"
+          />
+        </form>
+      }
+      onRowSelect={(row) => setDetailMember(row)}
+      page={currentPage}
+      totalPages={totalPages}
+      buildHref={(patch) =>
+        buildStudentMemberHref(query, { page: Number(patch.page ?? 1) })
+      }
+      onQueryChange={(patch) => {
+        if (patch.keyword !== undefined || patch.searchField !== undefined) {
+          go({
+            keyword: String(patch.keyword ?? ''),
+            searchField: (patch.searchField ??
+              query.searchField) as StudentMemberSearchField,
+          });
+        }
+      }}
+      />
+      {detailMember && (
+        <StudentDetailModal
+          member={detailMember}
+          onClose={() => setDetailMember(null)}
+        />
+      )}
+    </>
+  );
+}
 
app/(protected)/(basic)/students/_components/student-row-actions.tsx (deleted)
--- app/(protected)/(basic)/students/_components/student-row-actions.tsx
@@ -1,42 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { FoxIconButton } from '@fox/core/components/fox-icon-button';
-import { FoxMagnifyingGlassIcon } from '@fox/core/icons';
-import type { StudentMember } from '@/lib/domain/student-member';
-import { StudentDetailModal } from './student-detail-modal';
-
-interface StudentRowActionsProps {
-  member: StudentMember;
-}
-
-/**
- * 목록 행의 "관리" 셀 — 조회 팝업의 열림 상태만 소유하는 최말단 상호작용 경계다(테이블 전체를
- * 클라이언트로 내리지 않기 위해 이 셀만 분리했다 — §2.4 "'use client'는 최말단에만"). 팝업에
- * 필요한 데이터는 이미 이 행이 갖고 있는 member를 그대로 전달한다(재조회 없음).
- *
- * 시안의 "돋보기 아이콘 버튼"이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을
- * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로
- * 정의하는 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다).
- */
-export function StudentRowActions({ member }: StudentRowActionsProps) {
-  const [isDetailOpen, setIsDetailOpen] = useState(false);
-
-  return (
-    <>
-      <FoxIconButton
-        type="ghost"
-        size="sm"
-        icon={<FoxMagnifyingGlassIcon />}
-        label="조회"
-        onAction={() => setIsDetailOpen(true)}
-      />
-      {isDetailOpen && (
-        <StudentDetailModal
-          member={member}
-          onClose={() => setIsDetailOpen(false)}
-        />
-      )}
-    </>
-  );
-}
 
app/(protected)/(basic)/students/_components/student-search-bar.tsx (deleted)
--- app/(protected)/(basic)/students/_components/student-search-bar.tsx
@@ -1,72 +0,0 @@
-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 {
-  DEFAULT_STUDENT_MEMBER_SEARCH_FIELD,
-  STUDENT_MEMBERS_PATH,
-  STUDENT_MEMBER_SEARCH_FIELD_OPTIONS,
-  buildStudentMemberHref,
-  type StudentMemberQuery,
-} from '@/lib/domain/student-member-query';
-
-interface StudentSearchBarProps {
-  query: StudentMemberQuery;
-}
-
-/**
- * 검색 영역 — 순수 `<form method="get">` GET 제출로 URL을 갱신한다(자바스크립트 없이도
- * 동작). 현재 `sort`·`pageSize`는 hidden input으로 함께 제출해 검색 후에도 유지되게 하고,
- * 검색 자체는 항상 1페이지로 돌아간다(hidden page 필드를 두지 않아 parseStudentMemberQuery의
- * 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 제출이 아니라 기본값으로 되돌린 링크라
- * 현재 입력값과 무관하게 동작한다. 상호작용이 전부 네이티브 폼 제출/링크 내비게이션이라
- * 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다.
- */
-export function StudentSearchBar({ query }: StudentSearchBarProps) {
-  const resetHref = buildStudentMemberHref(query, {
-    searchField: DEFAULT_STUDENT_MEMBER_SEARCH_FIELD,
-    keyword: '',
-    page: 1,
-  });
-
-  return (
-    <form
-      method="get"
-      action={STUDENT_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}>
-          {STUDENT_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)/students/_components/student-table.tsx (deleted)
--- app/(protected)/(basic)/students/_components/student-table.tsx
@@ -1,86 +0,0 @@
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeaderCell,
-  TableRow,
-} from '@/components/ui/table';
-import {
-  formatGradeClassNumber,
-  formatOptionalValue,
-  type StudentMember,
-} from '@/lib/domain/student-member';
-import { StudentRowActions } from './student-row-actions';
-
-interface StudentTableProps {
-  items: StudentMember[];
-  page: number;
-  pageSize: number;
-}
-
-const COLUMN_HEADERS = [
-  '번호',
-  '회원코드',
-  '이름',
-  '아이디',
-  '휴대전화번호',
-  '이메일',
-  '역할',
-  '학교',
-  '학년/반/번호',
-  '보호자이름',
-  '보호자연락처',
-  '가입일',
-  '관리',
-];
-
-/**
- * 학생 회원 목록 테이블 — 조회 전용(등록/수정/삭제 없음). "번호"는 저장된 값이 아니라
- * 현재 페이지 기준 표시 순번(= (page-1)*pageSize + 행 인덱스 + 1)이다. 개인정보 마스킹은
- * 기획 시안에서 명시적으로 해제 지시가 있어 원본 값을 그대로 노출한다.
- *
- * 백엔드가 아직 주지 않는 항목(휴대전화·이메일·학교·학년/반/번호·보호자·가입일)은 열을 그대로
- * 유지한 채 `-`로 표시한다 — 백엔드가 필드를 추가하면 Repository 매핑만 늘리면 이 파일은
- * 그대로 값이 채워진다.
- *
- * "관리" 열은 행별 조회 팝업 트리거(StudentRowActions)에 위임한다 — 상호작용이 필요한 것은
- * 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 경계로 뗀다
- * (§2.4 "'use client'는 최말단에만").
- */
-export function StudentTable({ items, page, pageSize }: StudentTableProps) {
-  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>{(page - 1) * pageSize + index + 1}</TableCell>
-            <TableCell>{member.memberCode}</TableCell>
-            <TableCell>{member.name}</TableCell>
-            <TableCell>{member.loginId}</TableCell>
-            <TableCell>{formatOptionalValue(member.phoneNumber)}</TableCell>
-            <TableCell>{formatOptionalValue(member.email)}</TableCell>
-            <TableCell>{member.role}</TableCell>
-            <TableCell>{formatOptionalValue(member.schoolName)}</TableCell>
-            <TableCell>{formatGradeClassNumber(member)}</TableCell>
-            <TableCell>{formatOptionalValue(member.guardianName)}</TableCell>
-            <TableCell>
-              {formatOptionalValue(member.guardianPhoneNumber)}
-            </TableCell>
-            <TableCell>{formatOptionalValue(member.joinedAt)}</TableCell>
-            <TableCell>
-              <StudentRowActions member={member} />
-            </TableCell>
-          </TableRow>
-        ))}
-      </TableBody>
-    </Table>
-  );
-}
app/(protected)/(basic)/students/page.tsx
--- app/(protected)/(basic)/students/page.tsx
+++ app/(protected)/(basic)/students/page.tsx
@@ -1,17 +1,11 @@
-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 { fetchStudentMembers } from '@/lib/data/repositories/student-member-repository';
-import { buildStudentMemberHref, parseStudentMemberQuery } from '@/lib/domain/student-member-query';
-import { StudentListToolbar } from './_components/student-list-toolbar';
-import { StudentSearchBar } from './_components/student-search-bar';
-import { StudentTable } from './_components/student-table';
+import { parseStudentMemberQuery } from '@/lib/domain/student-member-query';
+import { StudentList } from './_components/student-list';
 
-export const metadata: Metadata = {
-  title: '학생 회원',
-};
+// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를
+// 원천 차단하기 위해 명시적으로 강제한다.
+export const dynamic = 'force-dynamic';
 
 interface PageProps {
   searchParams: Promise<Record<string, string | string[] | undefined>>;
@@ -32,50 +26,12 @@
   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>
-
-      <StudentSearchBar query={query} />
-
-      <StudentListToolbar query={query} />
-
-      <p className="text-body-md text-foreground-muted">
-        총 {totalCount}명{isTotalCountExact ? '' : ' 이상'} | 현재페이지{' '}
-        {currentPage}
-        {isTotalCountExact ? `/${totalPages}` : ''}
-      </p>
-
-      {items.length === 0 ? (
-        <Alert
-          title="검색 결과가 없습니다."
-          actions={
-            <Link
-              href={buildStudentMemberHref(query, { keyword: '', page: 1 })}
-              className="bg-foreground px-4 py-2 text-background"
-            >
-              확인
-            </Link>
-          }
-        >
-          다른 검색어로 다시 시도해 주세요.
-        </Alert>
-      ) : (
-        <>
-          {/* 표의 순번 기준은 요청 페이지(query.page)가 아니라 화면이 실제로 보여주는
-              페이지(currentPage)다 — 백엔드가 범위를 벗어난 페이지 요청을 clamp해서 응답하면
-              둘이 어긋나 "현재페이지 1/1"인데 순번은 31부터 시작하는 상태가 된다. */}
-          <StudentTable
-            items={items}
-            page={currentPage}
-            pageSize={query.pageSize}
-          />
-          <Pagination
-            currentPage={currentPage}
-            totalPages={totalPages}
-            buildHref={(page: number) => buildStudentMemberHref(query, { page })}
-          />
-        </>
-      )}
-    </section>
+    <StudentList
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+    />
   );
 }
 
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/data/repositories/common-code-repository.ts (added)
+++ lib/data/repositories/common-code-repository.ts
@@ -0,0 +1,71 @@
+import 'server-only';
+import { cache } from 'react';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import type { CommonCode } from '@/lib/domain/common-code';
+
+/**
+ * 공통코드 Repository.
+ *
+ * ```
+ * GET /api/v1/mngr/code/list/{comCd}   그룹의 상세코드 목록 (ROLE_ADMIN)
+ * ```
+ *
+ * 백엔드(edupay-backend, develop)의 MngrCodeApiController / MngrCodeMapper.xml을 읽고 확인한 것:
+ *
+ * - 응답은 `TB_SYS_COM_CD_DTL` 행 배열이고, 쓸 값은 **`comDtlCd`(코드)와 `cdNm`(이름)** 둘이다.
+ * - 삭제된 코드(`DEL_YN='Y'`)는 SQL이 이미 걸러 준다.
+ * - **정렬은 백엔드가 준 순서를 그대로 쓴다.** SQL이 `ROW_NUMBER() OVER (ORDER BY FRST_REG_DT
+ *   DESC, SORT_SEQ)`를 다시 `ORDER BY RNUM DESC`로 뒤집어, 결과는 등록일 오름차순 + 정렬순서
+ *   내림차순이다(아이템 목록과 같은 이중 역순 패턴). 화면에서 다시 정렬하면 다른 화면과 순서가
+ *   어긋나므로 손대지 않는다.
+ *
+ * `cache()`로 감싼 이유 — 한 요청 안에서 화면(선택지 그리기)과 Server Action(입력값 검증)이
+ * 같은 그룹을 각각 부른다. 요청 단위로 기억해 두면 왕복이 한 번으로 줄고, 그리는 데 쓴 목록과
+ * 검증에 쓴 목록이 반드시 같아진다.
+ *
+ * 캐시: `no-store` — 코드는 자주 바뀌지 않지만 관리 화면에서 코드를 고치자마자 반영돼야 하고,
+ * 위 `cache()`가 이미 요청 안의 중복 호출을 막는다.
+ */
+export const fetchCommonCodes = cache(async function fetchCommonCodes(
+  groupCode: string
+): Promise<CommonCode[]> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(`/api/v1/mngr/code/list/${groupCode}`, {
+    method: 'GET',
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  if (!Array.isArray(result.data)) {
+    throw new Error('공통코드 목록 응답의 형식이 올바르지 않습니다.');
+  }
+
+  return result.data.flatMap(toCommonCode);
+});
+
+/**
+ * 응답 1건 → 도메인 타입. 코드나 이름이 없는 행은 선택지로 쓸 수 없으므로 **예외 대신 건너뛴다**
+ * — 코드 한 줄이 깨졌다고 화면 전체를 못 쓰게 만들 이유가 없다(목록 조회의 fail-fast와 다른
+ * 판단이다. 그쪽은 없으면 화면의 존재 이유가 사라진다).
+ */
+function toCommonCode(raw: unknown): CommonCode[] {
+  if (raw === null || typeof raw !== 'object') {
+    return [];
+  }
+
+  const source = raw as Record<string, unknown>;
+  const code = source.comDtlCd;
+  const label = source.cdNm;
+
+  if (typeof code !== 'string' || code.length === 0) {
+    return [];
+  }
+
+  return [{ code, label: typeof label === 'string' && label ? label : code }];
+}
lib/data/repositories/decoration-item-repository.ts
--- lib/data/repositories/decoration-item-repository.ts
+++ lib/data/repositories/decoration-item-repository.ts
@@ -26,14 +26,20 @@
  * 아래는 백엔드 저장소(edupay-backend, develop)의 실제 구현을 읽고 확인한 것이다 —
  * MngrItemApiController / MngrItemServiceImpl / MngrItemMapper.xml / FileCommonApiController.
  *
- * - **쓰기 API는 JSON이 아니라 form 인코딩이다.** 컨트롤러가 `@RequestBody`가 아닌
- *   `@ParameterObject`로 받으므로 JSON을 보내면 전 필드가 null인 채 저장된다(오류도 나지 않는다).
+ * - **등록과 수정의 본문 형식이 다르다.** 등록은 `@ParameterObject`(form 인코딩), 수정은
+ *   `@RequestBody`(JSON)로 받는다 — 한쪽 형식으로 통일해 보내면 반대쪽이 전 필드 null이 되거나
+ *   415로 떨어진다. 백엔드 커밋 "FIX API 수정"에서 수정만 JSON으로 바뀌었다.
  * - **목록은 `searchItemType`이 필수다** — SQL의 WHERE에 `AND a.ITEM_TYPE = #{searchItemType}`이
  *   무조건 붙는다. 값이 없으면 아무것도 조회되지 않는다.
  * - **검색은 아이템명(`searchCondition="1"`)만 구현돼 있다.** 아이템ID 검색 분기는 없어, 그
  *   값을 보내면 조건 없이 전체가 반환된다(사용자 지시로 화면 선택지는 유지하고 백엔드에 추가 요청).
  * - **정렬은 고정이다** — `ROW_NUMBER() OVER (ORDER BY SORT_ORDR DESC)`를 다시 역순으로 정렬해
  *   결과적으로 정렬순서 오름차순이며(시안 ADM_ITM_101과 일치) 정렬 파라미터는 없다.
+ * - **수정일시는 `lastMdfcnDt`가 아니라 `lastMdfcnDtStr`로 온다.** VO의 `lastMdfcnDt`는
+ *   `@JsonIgnore`라 응답에 실리지 않고, SQL이 `DATE_FORMAT(...) AS last_mdfcn_dt_str`로 따로
+ *   내려 준다(`mapUnderscoreToCamelCase`).
+ * - **수정으로는 설명을 비울 수 없다** — UPDATE의 `<if test="itemExplan != ''">` 가드가 빈 문자열을
+ *   건너뛴다. 지우려는 의도가 조용히 무시되므로 백엔드에 조건 완화를 요청한다.
  * - **응답의 `totalCount`는 전체 건수가 아니라 그 페이지의 행 수다**(학생·관리자 목록과 동일한
  *   `PaginationUtil` 결함). 그대로 믿으면 페이지가 가득 찰 때마다 다음 페이지에 도달할 수 없어
  *   하한값으로 보정한다.
@@ -137,7 +143,7 @@
     sortOrder: readNumber(raw, 'sortOrdr') ?? 0,
     imageFileId,
     imageUrl: buildImageUrl(imageFileId),
-    updatedAt: readString(raw, 'lastMdfcnDt'),
+    updatedAt: readString(raw, 'lastMdfcnDtStr'),
   };
 }
 
@@ -247,7 +253,8 @@
 
 /*
  * ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
- * 컨트롤러가 `@ParameterObject`로 받으므로 전부 form 인코딩으로 보낸다(파일 상단 주석 참조).
+ * 보내는 값은 등록·수정이 같고 **실어 보내는 형식만 다르다** — 등록은 form, 수정은 JSON
+ * (파일 상단 주석 참조).
  */
 
 /** 등록·수정이 공유하는 전송 필드. */
@@ -267,19 +274,23 @@
   };
 }
 
+/**
+ * 등록·수정 공통 호출. `payload`가 본문 형식을 정한다 — 이 한 곳에서만 갈린다.
+ *
+ * 등록·수정 성공 응답은 `ApiResponseVO.success(null)`이라 data가 정상적으로 null이다.
+ */
 async function sendWrite(
   path: string,
   method: 'POST' | 'PUT',
-  values: DecorationItemEditableValues
+  payload: { form: Record<string, string | number | undefined> } | { body: unknown }
 ): Promise<void> {
   const accessToken = await getSessionAccessToken();
 
   const result = await backendFetch<null>(path, {
     method,
-    form: buildWriteForm(values),
+    ...payload,
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
-    // 등록·수정 성공 응답은 `ApiResponseVO.success(null)` — data가 정상적으로 null이다.
     canHaveNullData: true,
   });
 
@@ -288,26 +299,27 @@
   }
 }
 
+/** 등록 — 컨트롤러가 `@ParameterObject`라 form 인코딩이다. */
 export async function createDecorationItem(
   values: DecorationItemEditableValues
 ): Promise<void> {
-  await sendWrite(DECORATION_ITEM_PATH, 'POST', values);
+  await sendWrite(DECORATION_ITEM_PATH, 'POST', { form: buildWriteForm(values) });
 }
 
 /**
- * 수정.
+ * 수정(시안 ADM_ITM_103_p) — 컨트롤러가 `@RequestBody`라 **JSON**이다. 등록과 같은 필드를
+ * 보내지만 형식이 다르다.
  *
- * ⚠️ **백엔드 결함**: 컨트롤러의 update가 요청 VO에서 `itemType`을 빌더에 담지 않는데 UPDATE 문은
- * `ITEM_TYPE = #{itemType}`을 쓴다 — 그래서 수정하면 ITEM_TYPE이 null이 되고, 목록은
- * `ITEM_TYPE = searchItemType`으로 필터하므로 그 아이템이 개별·셋트 양쪽 탭에서 사라진다.
- * 우리가 `itemType`을 보내도 컨트롤러가 버리므로 프론트에서 우회할 수 없다(사용자 지시로 그대로
- * 연동하고 이슈로 보고한다). `lastMdfcnDt`도 컨트롤러가 채우지 않아 수정일시가 null이 된다.
+ * UPDATE 문이 필드마다 `<if>`로 감싸여 있어 **보내지 않은 값은 건드리지 않는다** — 썸네일
+ * 파일 ID(`thumbAtchFileId`)를 보내지 않는 것이 기존 값을 지우지 않는 이유다.
  */
 export async function updateDecorationItem(
   itemSn: number,
   values: DecorationItemEditableValues
 ): Promise<void> {
-  await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', values);
+  await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', {
+    body: buildWriteForm(values),
+  });
 }
 
 export async function deleteDecorationItem(itemSn: number): Promise<void> {
lib/data/repositories/student-member-repository.ts
--- lib/data/repositories/student-member-repository.ts
+++ lib/data/repositories/student-member-repository.ts
@@ -1,6 +1,10 @@
 import 'server-only';
 import { getSessionAccessToken } from '@/lib/auth/dal';
-import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import {
+  BackendRequestError,
+  backendFetch,
+  type BackendResult,
+} from '@/lib/http/backend-fetch';
 import type { StudentMember } from '@/lib/domain/student-member';
 import type {
   StudentMemberQuery,
@@ -13,7 +17,8 @@
  * `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다.
  *
  *   GET /api/v1/mngr/user/pagination  (ROLE_ADMIN 전용)
- *   → data: { list: [{ rnum, userId, loginId, userNm }], page, size, totalCount, totalPages }
+ *   → data: { list: [{ rnum, userId, userNm, userTelno, userEmlAddr, userType, loginId, schNm,
+ *              grade, clsNo, birth, useYn, … }], page, size, totalCount, totalPages }
  *
  * 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다
  * — MngrUserApiController / MngrUserServiceImpl / PaginationUtil / MngrUserMapper.xml.
@@ -37,6 +42,7 @@
  */
 
 const STUDENT_MEMBER_PAGINATION_PATH = '/api/v1/mngr/user/pagination';
+const STUDENT_MEMBER_BASE_PATH = '/api/v1/mngr/user';
 
 /**
  * 화면의 "검색 대상" → 백엔드 `searchCondition` 값 매핑. 값은 MngrUserMapper.xml의
@@ -115,6 +121,25 @@
   return value;
 }
 
+/** 있으면 문자열로, 없거나 비어 있으면 null. 백엔드가 빈 문자열로 "없음"을 표현하기도 한다. */
+function readOptionalString(raw: Record<string, unknown>, key: string): string | null {
+  const value = raw[key];
+  if (typeof value === 'string') {
+    return value.trim() === '' ? null : value;
+  }
+  return typeof value === 'number' ? String(value) : null;
+}
+
+/** 학년·반처럼 숫자로 쓰는 값. 숫자로 읽히지 않으면 null이다(백엔드가 문자열로 준다). */
+function readOptionalNumber(raw: Record<string, unknown>, key: string): number | null {
+  const text = readOptionalString(raw, key);
+  if (text === null) {
+    return null;
+  }
+  const parsed = Number(text);
+  return Number.isFinite(parsed) ? parsed : null;
+}
+
 /**
  * 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`로 둔다(설계서 §8.1 5층 —
  * 원본 응답을 그대로 흘리지 않고 화면에 필요한 필드만 골라 담는다).
@@ -125,6 +150,15 @@
  * 식별자·이름 세 필드는 없으면 예외로 끊는다(fail-fast) — 목록의 존재 이유인 값이라
  * 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다.
  */
+/** 백엔드의 `useYn`은 'Y'/'N' 문자열이다. 값이 없으면 판단하지 않고 null로 둔다. */
+function readUseYn(raw: Record<string, unknown>): boolean | null {
+  const value = readOptionalString(raw, 'useYn');
+  if (value === null) {
+    return null;
+  }
+  return value.toUpperCase() === 'Y';
+}
+
 function toStudentMember(raw: unknown): StudentMember {
   if (!isRecord(raw)) {
     throw new Error('학생 회원 응답 항목의 형식이 올바르지 않습니다.');
@@ -138,18 +172,20 @@
     memberCode: userId,
     name: readRequiredString(raw, 'userNm'),
     loginId: readRequiredString(raw, 'loginId'),
-    phoneNumber: null,
-    email: null,
+    phoneNumber: readOptionalString(raw, 'userTelno'),
+    email: readOptionalString(raw, 'userEmlAddr'),
     role: STUDENT_ROLE_LABEL,
-    schoolName: null,
-    grade: null,
-    classNumber: null,
+    schoolName: readOptionalString(raw, 'schNm'),
+    grade: readOptionalNumber(raw, 'grade'),
+    classNumber: readOptionalNumber(raw, 'clsNo'),
+    // 학생 번호는 응답에 없다 — 목록 SQL에도 VO에도 해당 컬럼이 없다.
     studentNumber: null,
+    // 보호자 정보와 가입일도 아직 응답에 없다.
     guardianName: null,
     guardianPhoneNumber: null,
     joinedAt: null,
-    birthDate: null,
-    isActive: null,
+    birthDate: readOptionalString(raw, 'birth'),
+    isActive: readUseYn(raw),
   };
 }
 
@@ -277,3 +313,38 @@
 ): Promise<StudentMemberPage> {
   return query.sort === 'name' ? fetchByName(query) : fetchByJoinedAt(query);
 }
+
+/**
+ * 사용여부 변경.
+ *
+ *   PUT /api/v1/mngr/user/{userId}/{useYn}   (ROLE_ADMIN 전용)
+ *
+ * 값과 대상이 모두 **경로에** 실린다 — 본문도 쿼리도 쓰지 않는다.
+ *
+ * ⚠️ 이 엔드포인트는 **성공 시 본문이 비어 온다.** 인터페이스에는 `ApiResponseVO` 반환으로
+ * 문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버린다
+ * (edupay-backend develop 4d98756의 `MngrUserApiController.updateUserUseYn`). 그래서
+ * `canHaveEmptyBody`로 봉투 없는 2xx를 성공으로 받는다 — 백엔드가 봉투를 돌려주도록 고쳐도
+ * 그대로 동작한다.
+ *
+ * 최종수정자 정보는 보내지 않는다. 매퍼가 `LAST_MDFR_*`를 쓰지만 그 값은 백엔드의
+ * `CrudLogInterceptor`가 UPDATE마다 자동으로 채운다.
+ */
+export async function updateStudentMemberUseYn(
+  userId: string,
+  isActive: boolean
+): Promise<BackendResult<null>> {
+  const accessToken = await getSessionAccessToken();
+
+  return backendFetch<null>(
+    `${STUDENT_MEMBER_BASE_PATH}/${encodeURIComponent(userId)}/${
+      isActive ? 'Y' : 'N'
+    }`,
+    {
+      method: 'PUT',
+      accessToken: accessToken ?? undefined,
+      canHaveEmptyBody: true,
+      canHaveNullData: true,
+    }
+  );
+}
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,29 +26,28 @@
 } 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;
-const PASSWORD_MIN_LENGTH = 10;
+
+/**
+ * 비밀번호 규칙 — **화면과 서버가 같은 값을 본다.** 팝업은 이 값을 `foxPasswordValidator`에
+ * 그대로 넘겨 입력 중에 안내하고, 서버 검증은 아래 `validateEditableValues`가 같은 값으로 판정한다.
+ * 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다.
+ */
+export const ADMIN_PASSWORD_POLICY = {
+  minLength: 10,
+  kinds: 2,
+} as const;
 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 +123,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,24 +150,27 @@
 /**
  * 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다.
  *
- * 비밀번호는 등록·수정 모두 필수다. 빈 값일 때의 안내 문구만 호출부가 정한다 — 등록은 형식 안내,
- * 수정은 "비우면 로그인 불가"라는 백엔드 제약을 알려야 하기 때문이다
- * (`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
+    password.length < ADMIN_PASSWORD_POLICY.minLength ||
+    countCharacterKinds(password) < ADMIN_PASSWORD_POLICY.kinds
   ) {
     errors.password = ADMIN_PASSWORD_HELP_TEXT;
   }
@@ -183,8 +179,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 +192,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 +221,8 @@
   }
 
   const editable = validateEditableValues(values, errors, {
-    emptyPasswordMessage: ADMIN_PASSWORD_HELP_TEXT,
+    passwordRequired: true,
+    emailRequired: true,
   });
 
   if (Object.keys(errors).length > 0) {
@@ -241,7 +242,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';
 
 
lib/domain/common-code.ts (added)
+++ lib/domain/common-code.ts
@@ -0,0 +1,34 @@
+/**
+ * 공통코드 도메인 — 순수 데이터 표현, 외부 의존 없음.
+ *
+ * 백엔드 `TB_SYS_COM_CD_DTL`의 한 그룹(`COM_CD`)에 속한 상세코드 목록이며,
+ * `GET /api/v1/mngr/code/list/{comCd}`(ROLE_ADMIN)로 가져온다.
+ */
+
+export type CommonCode = {
+  /** 백엔드 `comDtlCd` — 저장·전송에 쓰는 코드값. */
+  code: string;
+  /** 백엔드 `cdNm` — 화면에 보이는 이름. */
+  label: string;
+};
+
+/** 코드 그룹 ID — 화면이 문자열을 직접 적지 않도록 여기 모은다. */
+export const CODE_GROUP = {
+  /** 꾸미기 아이템 카테고리. */
+  decorationItemCategory: 'ITEM_CATE_CD',
+} as const;
+
+/** 코드값 → 이름. 목록에 없는 코드는 코드값 자체를 보여준다(이름을 지어내지 않는다). */
+export function formatCommonCode(
+  codes: readonly CommonCode[],
+  code: string
+): string {
+  return codes.find((item) => item.code === code)?.label ?? code;
+}
+
+export function isKnownCommonCode(
+  codes: readonly CommonCode[],
+  code: string
+): boolean {
+  return codes.some((item) => item.code === code);
+}
lib/domain/decoration-item-form.ts
--- lib/domain/decoration-item-form.ts
+++ lib/domain/decoration-item-form.ts
@@ -13,8 +13,8 @@
  * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 거친다.
  */
 
+import { isKnownCommonCode, type CommonCode } from '@/lib/domain/common-code';
 import {
-  DECORATION_ITEM_CATEGORIES,
   DECORATION_ITEM_TYPE_OPTIONS,
   type DecorationItemType,
 } from '@/lib/domain/decoration-item';
@@ -69,10 +69,6 @@
   return DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value);
 }
 
-function isDecorationItemCategoryCode(value: string): boolean {
-  return DECORATION_ITEM_CATEGORIES.some((category) => category.code === value);
-}
-
 /**
  * 등록·수정 공통 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다.
  *
@@ -83,6 +79,7 @@
  */
 function validateEditableValues(
   values: DecorationItemEditableValues,
+  categories: readonly CommonCode[],
   errors: DecorationItemFormErrors
 ): DecorationItemEditableValues {
   const name = values.name.trim();
@@ -98,7 +95,7 @@
     errors.name = `아이템명은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`;
   }
 
-  if (!isDecorationItemCategoryCode(values.categoryCode)) {
+  if (!isKnownCommonCode(categories, values.categoryCode)) {
     errors.categoryCode = '카테고리를 선택해 주세요.';
   }
 
@@ -123,12 +120,18 @@
   return { ...values, name, description };
 }
 
-/** 시안 ADM_ITM_102_p — 등록 검증. */
+/**
+ * 시안 ADM_ITM_102_p — 등록 검증.
+ *
+ * 허용 카테고리는 상수가 아니라 **인자로 받는다** — 값이 백엔드 공통코드(`ITEM_CATE_CD`)에서
+ * 오므로 이 파일이 알 수 없고, 알아서도 안 된다(domain 계층은 통신을 하지 않는다).
+ */
 export function validateDecorationItemCreate(
-  values: DecorationItemEditableValues
+  values: DecorationItemEditableValues,
+  categories: readonly CommonCode[]
 ): ValidationResult<DecorationItemEditableValues> {
   const errors: DecorationItemFormErrors = {};
-  const editable = validateEditableValues(values, errors);
+  const editable = validateEditableValues(values, categories, errors);
 
   if (Object.keys(errors).length > 0) {
     return { ok: false, errors };
@@ -139,7 +142,8 @@
 
 /** 시안 ADM_ITM_103_p — 수정 검증. 아이템ID는 읽기 전용이라 검증 대상이 아니다. */
 export function validateDecorationItemUpdate(
-  values: DecorationItemEditableValues
+  values: DecorationItemEditableValues,
+  categories: readonly CommonCode[]
 ): ValidationResult<DecorationItemEditableValues> {
-  return validateDecorationItemCreate(values);
+  return validateDecorationItemCreate(values, categories);
 }
lib/domain/decoration-item.ts
--- lib/domain/decoration-item.ts
+++ lib/domain/decoration-item.ts
@@ -26,27 +26,6 @@
 
 export const DEFAULT_DECORATION_ITEM_TYPE: DecorationItemType = 'individual';
 
-/**
- * 카테고리 — **임시 값이다.** 백엔드는 공통코드테이블(`TB_SYS_COM_CD_DTL`, `COM_CD='ITEM_CATE_CD'`)을
- * 조인해 `itemCateNm`을 내려주지만 그 코드가 아직 정비되지 않아, 사용자 지시에 따라 프론트에서
- * 임의 코드로 개발한다(시안 등록 팝업의 예시값 계절/축하/시즌을 그대로 씀).
- *
- * **백엔드에 코드가 추가되면 이 상수를 지우고 `GET /api/v1/common/code/ITEM_CATE_CD`(인증 불필요)
- * 응답으로 교체한다.** 그때까지 저장되는 `itemCateCd`는 여기 정의된 임시 코드라, 실제 코드 체계가
- * 정해지면 기존 데이터의 코드값 마이그레이션이 필요하다.
- */
-export const DECORATION_ITEM_CATEGORIES: ReadonlyArray<{
-  code: string;
-  label: string;
-}> = [
-  { code: 'CATE01', label: '계절' },
-  { code: 'CATE02', label: '축하' },
-  { code: 'CATE03', label: '시즌' },
-];
-
-export const DEFAULT_DECORATION_ITEM_CATEGORY_CODE =
-  DECORATION_ITEM_CATEGORIES[0].code;
-
 export type DecorationItem = {
   /**
    * 백엔드 `itemSn` — 자동증가 PK이자 화면의 "아이템ID"로 그대로 노출하는 값(사용자 확정 사항).
@@ -56,12 +35,9 @@
   itemSn: number;
   itemType: DecorationItemType;
   name: string;
-  /** 백엔드 `itemCateCd` — 저장·전송에 쓰는 코드값. */
+  /** 백엔드 `itemCateCd` — 저장·전송에 쓰는 코드값(공통코드 `ITEM_CATE_CD`의 상세코드). */
   categoryCode: string;
-  /**
-   * 백엔드 `itemCateNm` — 공통코드테이블 조인 결과. 코드가 코드테이블에 없으면 null로 온다
-   * (지금은 임시 코드를 쓰므로 대개 null이다 — 화면은 `formatDecorationItemCategory`로 보완한다).
-   */
+  /** 백엔드 `itemCateNm` — 공통코드테이블 조인 결과. 코드가 코드테이블에 없으면 null이다. */
   categoryName: string | null;
   /** 백엔드 `itemAmount` — 오픈 가능한 포인트. */
   points: number;
@@ -82,9 +58,9 @@
 /** 값이 없는 항목의 화면 표기. */
 export const EMPTY_FIELD_PLACEHOLDER = '-';
 
-/** 사용여부 → 화면 표기(시안: O/X). */
+/** 사용여부 → 화면 표기(시안 ADM_ITM_101: 점 + "사용"/"미사용"). */
 export function formatDecorationItemActiveLabel(isActive: boolean): string {
-  return isActive ? 'O' : 'X';
+  return isActive ? '사용' : '미사용';
 }
 
 /** 포인트 → 화면 표기(시안: "100 P" 형태). */
@@ -93,19 +69,11 @@
 }
 
 /**
- * 카테고리 표기 — 백엔드가 코드테이블에서 찾은 이름을 우선 쓰고, 없으면 프론트 임시 목록에서
- * 찾고, 그것도 없으면 코드값 자체를 보여준다. 임시 코드 단계에서는 두 번째 경로가 주로 쓰이고,
- * 백엔드 코드가 정비되면 자연스럽게 첫 번째 경로로 넘어간다.
+ * 카테고리 표기 — 목록 SQL이 코드테이블을 조인해 내려 준 이름을 쓴다. 코드가 코드테이블에서
+ * 지워졌거나 값 자체가 비었을 때만 코드값·`-`로 떨어진다(이름을 지어내지 않는다).
  */
 export function formatDecorationItemCategory(item: DecorationItem): string {
-  if (item.categoryName) {
-    return item.categoryName;
-  }
-
-  const known = DECORATION_ITEM_CATEGORIES.find(
-    (category) => category.code === item.categoryCode
-  );
-  return known?.label ?? item.categoryCode ?? EMPTY_FIELD_PLACEHOLDER;
+  return item.categoryName || item.categoryCode || EMPTY_FIELD_PLACEHOLDER;
 }
 
 /** 수정일시 표기 — 백엔드가 이미 `YYYY-MM-DD`로 포맷해 주므로 그대로 쓰고 null만 보완한다. */
 
lib/domain/phone-number.ts (added)
+++ lib/domain/phone-number.ts
@@ -0,0 +1,59 @@
+/**
+ * 전화번호 표기.
+ *
+ * 백엔드는 하이픈 없이 숫자만 저장한다(예: `01011233400`). 시안은 `010-1123-3400`으로
+ * 끊어 보여주므로 화면에 나가기 직전에 여기서 끊는다 — 저장된 값 자체는 건드리지 않는다.
+ *
+ * 도메인마다 두지 않고 한 파일에 모은 이유는 학생·관리자·보호자가 같은 규칙을 써야 하기
+ * 때문이다.
+ */
+
+/** 자릿수별 묶음 규칙. 앞자리가 `02`인 서울 번호만 지역번호가 두 자리다. */
+function splitGroups(digits: string): string[] | null {
+  if (digits.startsWith('02')) {
+    if (digits.length === 9) {
+      return [digits.slice(0, 2), digits.slice(2, 5), digits.slice(5)];
+    }
+    if (digits.length === 10) {
+      return [digits.slice(0, 2), digits.slice(2, 6), digits.slice(6)];
+    }
+    return null;
+  }
+
+  if (digits.length === 11) {
+    return [digits.slice(0, 3), digits.slice(3, 7), digits.slice(7)];
+  }
+  if (digits.length === 10) {
+    return [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6)];
+  }
+  // 지역번호 없이 저장된 국번+번호.
+  if (digits.length === 8) {
+    return [digits.slice(0, 4), digits.slice(4)];
+  }
+
+  return null;
+}
+
+/**
+ * 하이픈을 넣어 돌려준다.
+ *
+ * - 이미 하이픈이 들어 있으면 그대로 둔다 — 백엔드가 형식을 갖춰 준 값을 다시 끊지 않는다.
+ * - 자릿수가 알려진 규칙에 맞지 않으면 **원본을 그대로** 돌려준다. 임의로 끊으면 잘못된
+ *   번호를 그럴듯하게 보여주게 되므로, 판단이 서지 않을 때는 손대지 않는다.
+ * - 값이 없으면 null 그대로다. `-` 표기는 호출부의 `formatOptionalValue`가 맡는다.
+ */
+export function formatPhoneNumber(value: string | null): string | null {
+  if (value === null) {
+    return null;
+  }
+
+  const trimmed = value.trim();
+  if (trimmed === '' || trimmed.includes('-')) {
+    return trimmed === '' ? null : trimmed;
+  }
+
+  const digits = trimmed.replace(/\D/g, '');
+  const groups = digits.length === trimmed.length ? splitGroups(digits) : null;
+
+  return groups === null ? trimmed : groups.join('-');
+}
lib/domain/student-member.ts
--- lib/domain/student-member.ts
+++ lib/domain/student-member.ts
@@ -52,15 +52,19 @@
 
 /**
  * "학년/반/번호" 합성 표기. 표와 조회 팝업이 같은 규칙을 쓰도록 한 곳에 둔다.
- * 세 값 중 하나라도 없으면 부분 문장("1학년 -반 -번")을 만들지 않고 통째로 `-`로 표기한다 —
- * 셋이 함께여야 의미가 성립하는 한 덩어리이기 때문이다.
+ *
+ * **있는 값만 이어 붙인다.** 백엔드 `TB_COM_USER`에는 GRADE·CLS_NO만 있고 학생 번호 컬럼이
+ * 아예 없어(코드베이스 전체 VO가 그 둘만 쓴다) 번호는 영영 오지 않는다. 셋이 다 있어야
+ * 표기하던 종전 규칙으로는 실제로 오는 학년·반까지 함께 묻혔다.
+ *
+ * 하나도 없으면 `-`다.
  */
 export function formatGradeClassNumber(member: StudentMember): string {
-  const { grade, classNumber, studentNumber } = member;
+  const parts = [
+    member.grade === null ? null : `${member.grade}학년`,
+    member.classNumber === null ? null : `${member.classNumber}반`,
+    member.studentNumber === null ? null : `${member.studentNumber}번`,
+  ].filter((part): part is string => part !== null);
 
-  if (grade === null || classNumber === null || studentNumber === null) {
-    return EMPTY_FIELD_PLACEHOLDER;
-  }
-
-  return `${grade}학년 ${classNumber}반 ${studentNumber}번`;
+  return parts.length > 0 ? parts.join(' ') : EMPTY_FIELD_PLACEHOLDER;
 }
lib/http/backend-fetch.ts
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
@@ -57,10 +57,13 @@
    * form 인코딩 본문(`application/x-www-form-urlencoded`). `body`와 함께 지정하지 않는다.
    *
    * 백엔드의 일부 쓰기 API는 `@RequestBody`가 아니라 **`@ParameterObject`(= ModelAttribute
-   * 바인딩)** 로 파라미터를 받는다(예: 관리자 등록·수정 `MngrAdminRequestVo`,
-   * 아이템 등록·수정 `MngrItemRequestVo`). 그런 엔드포인트에 JSON을 보내면 바인딩이 하나도 되지
-   * 않아 **전 필드가 null인 채로 저장된다** — 400도 나지 않고 조용히 빈 레코드가 생기므로,
-   * 엔드포인트가 어느 쪽인지 확인하고 맞는 형식을 골라야 한다.
+   * 바인딩)** 로 파라미터를 받는다(예: 관리자 등록·수정 `MngrAdminRequestVo`, 아이템 **등록**
+   * `MngrItemRequestVo`). 그런 엔드포인트에 JSON을 보내면 바인딩이 하나도 되지 않아 **전 필드가
+   * null인 채로 저장된다** — 400도 나지 않고 조용히 빈 레코드가 생기므로, 엔드포인트가 어느
+   * 쪽인지 확인하고 맞는 형식을 골라야 한다.
+   *
+   * ⚠️ **같은 도메인 안에서도 갈린다** — 아이템 **수정**은 `@RequestBody`(JSON)다. 등록만 보고
+   * 도메인 전체를 form으로 단정하면 수정이 조용히 깨진다.
    *
    * `undefined`인 값은 전송에서 제외한다(백엔드가 "미전송"과 "빈 문자열"을 다르게 볼 수 있다).
    */
@@ -89,6 +92,20 @@
    * null이 직렬화됨).
    */
   canHaveNullData?: boolean;
+  /**
+   * 이 호출은 성공 시 **본문이 비어 올 수 있다.** 봉투를 파싱하지 않고 2xx를 그대로 성공으로
+   * 본다(`data`는 null).
+   *
+   * 백엔드가 봉투를 안 주는 엔드포인트가 실제로 있다 — 사용여부 변경
+   * (`PUT /api/v1/mngr/user/{userId}/{useYn}`)은 인터페이스에 `ApiResponseVO` 반환으로
+   * 문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버려서,
+   * `@RestController` + `void` + `HttpServletResponse` 조합상 본문 없는 200이 나간다
+   * (edupay-backend develop 4d98756 확인).
+   *
+   * 백엔드가 봉투를 돌려주도록 고쳐도 이 플래그를 그대로 둘 수 있다 — 본문이 있으면 아래에서
+   * 정상적으로 파싱한다.
+   */
+  canHaveEmptyBody?: boolean;
   /** 기본 타임아웃보다 오래 걸리는 호출(파일 업로드 등)이 값을 올려 잡는다. */
   timeoutMs?: number;
 };
@@ -268,6 +285,30 @@
     return communicationError(`예상치 못한 HTTP 상태: ${response.status}`, undefined);
   }
 
+  // 본문 없는 2xx를 허용한 호출은 먼저 글자를 읽어 비었는지 본다 — 비어 있으면 `response.json()`이
+  // 파싱 오류를 던지므로 그 전에 갈라야 한다.
+  if (init.canHaveEmptyBody) {
+    let raw: string;
+    try {
+      raw = await response.text();
+    } catch (error) {
+      return communicationError('응답 읽기 실패', error);
+    }
+
+    if (raw.trim() === '') {
+      return { ok: true, data: null as T };
+    }
+
+    try {
+      const parsed = JSON.parse(raw) as BackendEnvelope<T>;
+      return parsed.success
+        ? { ok: true, data: parsed.data as T }
+        : { ok: false, code: parsed.code, message: parsed.message };
+    } catch (error) {
+      return communicationError('응답 파싱 실패', error);
+    }
+  }
+
   let envelope: BackendEnvelope<T>;
   try {
     envelope = (await response.json()) as BackendEnvelope<T>;
Add a comment
List