임동욱 임동욱 08-19
feat: 꾸미기 아이템 관리 화면을 시안대로 — 유형 탭 + @fox 목록/등록 팝업
시안 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ITM_101(5227:3549)·ADM_ITM_102_p(5227:4629).
로직(백엔드 연동·Server Action·검증)은 이미 있었고 화면만 Tailwind 제거 이후 무스타일로
남아 있던 components/ui 위에 있었다 — 관리자·학생 화면과 같이 @fox로 옮겼다.

목록
  - FoxListContainer + 유형 탭(개별/셋트). 셋트는 별도 화면이 없어 탭이 조회 조건(itemType)만
    바꾼다 — 검색·페이지와 같은 층위라 URL이 소유하고, 탭을 바꾸면 검색어를 지운다(유형이
    다르면 아이템 집합 자체가 달라 이전 검색어는 "결과 없음"만 만든다).
  - href 대신 router.replace를 쓴다. 같은 화면 안의 전환이라 role="tablist" 의미가 맞고
    (FoxTabItem은 href를 주면 탭 역할을 뗀다), 평범한 는 App Router에서 문서를 다시 받는다.
  - 10개 열·사용여부는 FoxStatusIndicator(점+사용/미사용)·관리는 테두리 있는 FoxIconButton.

등록·수정 팝업
  - FoxModal(sm=400) + 폼 안의 유형 탭. FoxTab·FoxSelect는 폼 요소가 아니라 값은 hidden으로
    실어 보낸다.
  - 썸네일은 FoxFileUpload(image) — 네이티브 입력에 name을 주면 고른 파일이 폼 제출에 그대로
    실려 파일을 따로 들고 있지 않는다.

@fox (전부 추가·수정이고 기존 화면의 모양은 그대로다)
  - FoxListContainer.tabs — 머리말과 목록 사이 슬롯(시안이 탭을 그 자리에 둔다).
  - FoxInput.suffix — 입력 오른쪽 글자(시안 정렬순서의 "/ 총 39개").
  - FoxInput.requirement / FoxFileUpload.requirement — 라벨의 필수 표시. FoxSelect에만 있던
    prop을 같은 규약으로 맞췄다.
  - FoxFileUpload.name — 네이티브 파일 입력의 name 통과.
  - FoxTab(line) 밑줄을 border → inset 그림자로. Figma는 stroke를 상자 안쪽에 그려 시안의
    tab-item이 고름 여부와 무관하게 60px인데, border는 상자를 키워 고른 탭만 64px이 되고
    옆 탭과 3px 어긋났다.

검증 — 시안 대조(PC 1600): 열 폭 80/236/160/236/160/160/120/80/160/160 일치, 탭 60px 일치,
썸네일 80×80·1px #e6e8ea·radius 4, 사용여부 #267337·점 6px, 관리 버튼 28×28·1px #b1b8be·
아이콘 20, 팝업 400px·버튼 80×40, 정렬순서 suffix "/ 총 39개".

Co-Authored-By: Claude Opus 5 
@f15976e62c8b4e74cb36a226693564499b51947c
@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
@@ -10,7 +10,7 @@
   type ReactNode,
 } from "react";
 import { cx } from "../../utils";
-import { FoxFormLabel } from "../fox-form-label";
+import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
 
 export type FoxInputSize = "lg" | "md" | "sm";
 export type FoxInputState = "default" | "focused" | "completed" | "error" | "disabled" | "view";
@@ -31,12 +31,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;
@@ -70,9 +74,11 @@
   onChange,
   onInput,
   label,
+  requirement,
   message,
   messageIcon,
   icon,
+  suffix,
   clearable = false,
   clearLabel = "입력 지우기",
   invalid = false,
@@ -138,7 +144,9 @@
       data-state={state}
     >
       {label && (
-        <FoxFormLabel htmlFor={fieldId}>{label}</FoxFormLabel>
+        <FoxFormLabel htmlFor={fieldId} requirement={requirement}>
+          {label}
+        </FoxFormLabel>
       )}
 
       <div className="fox-input__box">
@@ -170,6 +178,8 @@
           </button>
         )}
 
+        {suffix && <span className="fox-input__suffix">{suffix}</span>}
+
         {icon && (
           <span className="fox-input__icon" aria-hidden="true">
             {icon}
@fox/core/components/fox-list-container/fox-list-container.tsx
--- @fox/core/components/fox-list-container/fox-list-container.tsx
+++ @fox/core/components/fox-list-container/fox-list-container.tsx
@@ -42,6 +42,11 @@
   title?: ReactNode;
   breadcrumb?: FoxBreadcrumbItem[];
   headerActions?: ReactNode;
+  /**
+   * 머리말과 목록 사이. 시안에서 이 자리에 오는 것은 `FoxTab`이다 — 목록 전체를 갈아 끼우는
+   * 전환이라 도구 줄(정렬·검색)보다 위에 선다.
+   */
+  tabs?: ReactNode;
 
   // ── 표 ────────────────────────────────────────────────────────────────────
   columns: FoxListColumn<T>[];
@@ -107,6 +112,7 @@
   title,
   breadcrumb,
   headerActions,
+  tabs,
   columns,
   rows,
   rowKey,
@@ -170,6 +176,8 @@
         />
       )}
 
+      {tabs}
+
       <section className="fox-list-container__section">
         {hasToolbar && (
           <FoxListToolbar
@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-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);
 }
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,10 +1,10 @@
 '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 { DecorationItemType } from '@/lib/domain/decoration-item';
 import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
@@ -18,17 +18,16 @@
   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,
@@ -36,6 +35,7 @@
   onClose,
 }: DecorationItemCreateModalProps) {
   const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
   const [state, formAction, isPending] = useActionState(
     createDecorationItemAction,
     INITIAL_DECORATION_ITEM_FORM_STATE
@@ -51,44 +51,44 @@
   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}
           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,10 +1,10 @@
 '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 { DecorationItem } from '@/lib/domain/decoration-item';
 import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
@@ -17,16 +17,13 @@
   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,
@@ -34,6 +31,7 @@
   onClose,
 }: DecorationItemEditModalProps) {
   const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
   const [state, formAction, isPending] = useActionState(
     updateDecorationItemAction,
     INITIAL_DECORATION_ITEM_FORM_STATE
@@ -49,47 +47,47 @@
   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}
           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,213 @@
 '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 {
   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;
   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,
   errors,
+  itemIdField,
 }: DecorationItemFormFieldsProps) {
+  const [itemType, setItemType] = useState<DecorationItemType>(
+    item?.itemType ?? defaultItemType
+  );
+  const [categoryCode, setCategoryCode] = useState(
+    item?.categoryCode || DEFAULT_DECORATION_ITEM_CATEGORY_CODE
+  );
+  const [isActive, setIsActive] = useState(item?.isActive ?? true);
+
   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={DECORATION_ITEM_CATEGORIES.map((category) => ({
+              value: category.code,
+              label: category.label,
+            }))}
+            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
+            }
+          />
+          {/* 파일을 새로 고르지 않았을 때 기존 이미지를 유지하는 값. 등록 팝업에서는 빈
+              문자열이라 파일을 고르지 않으면 검증에서 걸린다. */}
+          <input
+            type="hidden"
+            name="imageFileId"
+            defaultValue={item?.imageFileId ?? ''}
+          />
+          {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·엑셀다운로드에
- * 대응하는 항목이 없어 페이지 크기와 등록 버튼만 둔다.
- *
- * [+ 신규 등록]은 현재 유형 탭을 기본값으로 들고 등록 팝업을 연다 — 팝업 열림 상태를 이
- * 컴포넌트가 소유한다(관리자 회원 목록과 동일한 패턴).
- */
-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,12 @@
+// 목록 표의 썸네일 — 시안 ADM_ITM_101의 `img-thumb`(80×80 · 1px 테두리 · radius 4 · cover).
+// @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: cover;
+}
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-list.tsx (added)
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-list.tsx
@@ -0,0 +1,234 @@
+'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 {
+  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;
+}
+
+/**
+ * 꾸미기 아이템 목록 — 시안: 관리자페이지(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,
+}: 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} />
+      ),
+    },
+  ];
+
+  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}
+          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,7 +1,8 @@
 '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';
@@ -11,21 +12,16 @@
 
 interface DecorationItemRowActionsProps {
   item: DecorationItem;
-  /** 수정 팝업의 "(총 등록 N개)" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */
+  /** 수정 팝업의 "/ 총 N개" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */
   typeTotalCount: number;
 }
 
 /**
- * 목록 행의 "관리" 셀(시안 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,
@@ -57,34 +53,32 @@
       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
@@ -93,6 +87,6 @@
           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로 유지한다
- * (관리자 회원 목록 이전 검색바와 동일한 설계).
- */
-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,35 +1,27 @@
 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 { 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 { 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();
@@ -40,60 +32,22 @@
 
   // 전체 건수가 확정되지 않았다면(백엔드가 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}
+    />
   );
 }
lib/domain/decoration-item.ts
--- lib/domain/decoration-item.ts
+++ lib/domain/decoration-item.ts
@@ -82,9 +82,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" 형태). */
Add a comment
List