임동욱 임동욱 08-19
merge: hub 최신 반영
@0f41b449006359ab8c4e946b61b937e1400762bd
@fox/core/components/fox-email/fox-email.tsx
--- @fox/core/components/fox-email/fox-email.tsx
+++ @fox/core/components/fox-email/fox-email.tsx
@@ -1,7 +1,7 @@
 "use client";
 
 import { useId, useState, type ReactNode } 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 { FoxInput } from "../fox-input";
@@ -29,6 +29,8 @@
   verifyLabel?: string;
 
   label?: string;
+  /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxInput`·`FoxSelect`와 같은 규약). */
+  requirement?: FoxFormLabelRequirement;
   /** 헬퍼 메시지. 오류일 때 색이 바뀐다. */
   message?: string;
   messageIcon?: ReactNode;
@@ -88,6 +90,7 @@
   onVerifyFail,
   verifyLabel = "확인",
   label,
+  requirement,
   message,
   messageIcon,
   localPlaceholder = "아이디",
@@ -163,12 +166,16 @@
       aria-labelledby={label ? `${autoId}-label` : undefined}
     >
       {label && (
-        <FoxFormLabel as="span" id={`${autoId}-label`}>
+        <FoxFormLabel as="span" id={`${autoId}-label`} requirement={requirement}>
           {label}
         </FoxFormLabel>
       )}
 
-      <div className="fox-email__row">
+      {/* 시안의 form-email 알맹이 — form-group / select / btn이 4px 간격으로 쌓인다.
+          라벨·헬퍼와의 간격은 바깥(`field.root`)이 갖는다. */}
+      <div className="fox-email__body">
+      {/* 시안의 form-group — 아이디·@·도메인이 한 줄이다. 도메인 목록은 아래 줄에 따로 선다. */}
+      <div className="fox-email__group">
         <div className="fox-email__part">
           <FoxInput
             size="md"
@@ -186,18 +193,6 @@
           @
         </span>
 
-        <div className="fox-email__part">
-          <FoxSelect
-            size="md"
-            options={domainItems}
-            value={parts.custom ? CUSTOM_DOMAIN : parts.domain}
-            onValueChange={handleDomainPick}
-            placeholder={domainPlaceholder}
-            error={invalid}
-            disabled={disabled}
-          />
-        </div>
-
         {/* 목록에서 고른 값이 여기에 채워지고 잠긴다. "직접입력"일 때만 풀린다. */}
         <div className="fox-email__part">
           <FoxInput
@@ -211,17 +206,28 @@
             disabled={disabled}
           />
         </div>
+      </div>
 
-        {onVerify && (
-          <FoxButton
-            size="md"
-            type="primary"
-            label={verifyLabel}
-            loading={verifying}
-            disabled={disabled}
-            onAction={handleVerify}
-          />
-        )}
+      <FoxSelect
+        size="md"
+        options={domainItems}
+        value={parts.custom ? CUSTOM_DOMAIN : parts.domain}
+        onValueChange={handleDomainPick}
+        placeholder={domainPlaceholder}
+        error={invalid}
+        disabled={disabled}
+      />
+
+      {onVerify && (
+        <FoxButton
+          size="md"
+          type="primary"
+          label={verifyLabel}
+          loading={verifying}
+          disabled={disabled}
+          onAction={handleVerify}
+        />
+      )}
       </div>
 
       {message && (
@fox/core/components/fox-phone-number/fox-phone-number.tsx
--- @fox/core/components/fox-phone-number/fox-phone-number.tsx
+++ @fox/core/components/fox-phone-number/fox-phone-number.tsx
@@ -184,47 +184,52 @@
   const body =
     type === "unit" ? (
       <div className="fox-phone-number__row">
-        <div className="fox-phone-number__part">
-          <FoxSelect
-            size="md"
-            options={prefixItems}
-            value={parts[0]}
-            onValueChange={(next) => {
-              commit(0, next);
-              if (autoAdvance) {
-                focusSegment(1);
-              }
-            }}
-            placeholder={placeholders[0]}
-            error={errored}
-            disabled={disabled || state === "disabled"}
-            readOnly={readOnly || state === "view"}
-          />
-        </div>
-
-        {[1, 2].map((index) => (
+        {[0, 1, 2].map((index) => (
           <Fragment key={index}>
-            <span className="fox-phone-number__separator" aria-hidden="true">
-              -
-            </span>
+            {index > 0 && (
+              <span className="fox-phone-number__separator" aria-hidden="true">
+                -
+              </span>
+            )}
             <div className="fox-phone-number__part">
-              <FoxInput
-                size="md"
-                inputMode="numeric"
-                aria-label={SEGMENT_LABELS[index]}
-                value={parts[index]}
-                onChange={handleSegmentChange(index)}
-                onKeyDown={handleSegmentKeyDown(index)}
-                placeholder={placeholders[index]}
-                maxLength={maxLengths[index]}
-                invalid={invalid}
-                disabled={disabled}
-                readOnly={readOnly}
-                state={state}
-                ref={(node) => {
-                  tailRefs.current[index] = node;
-                }}
-              />
+              {/* 앞자리는 고를 목록이 있을 때만 셀렉트다. 목록이 비었는데 셀렉트를 그리면
+                  placeholder("010")가 이미 고른 값처럼 보이는데 실제 값은 비어 있어, 뒷자리만
+                  채운 번호가 조용히 만들어진다 — 그때는 나머지 칸과 같은 입력 칸이 된다. */}
+              {index === 0 && prefixItems.length > 0 ? (
+                <FoxSelect
+                  size="md"
+                  options={prefixItems}
+                  value={parts[0]}
+                  onValueChange={(next) => {
+                    commit(0, next);
+                    if (autoAdvance) {
+                      focusSegment(1);
+                    }
+                  }}
+                  placeholder={placeholders[0]}
+                  error={errored}
+                  disabled={disabled || state === "disabled"}
+                  readOnly={readOnly || state === "view"}
+                />
+              ) : (
+                <FoxInput
+                  size="md"
+                  inputMode="numeric"
+                  aria-label={SEGMENT_LABELS[index]}
+                  value={parts[index]}
+                  onChange={handleSegmentChange(index)}
+                  onKeyDown={handleSegmentKeyDown(index)}
+                  placeholder={placeholders[index]}
+                  maxLength={maxLengths[index]}
+                  invalid={invalid}
+                  disabled={disabled}
+                  readOnly={readOnly}
+                  state={state}
+                  ref={(node) => {
+                    tailRefs.current[index] = node;
+                  }}
+                />
+              )}
             </div>
           </Fragment>
         ))}
@fox/core/components/fox-text-area/fox-text-area.tsx
--- @fox/core/components/fox-text-area/fox-text-area.tsx
+++ @fox/core/components/fox-text-area/fox-text-area.tsx
@@ -9,7 +9,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 FoxTextAreaState = "default" | "focused" | "completed" | "error" | "disabled" | "view";
 
@@ -27,6 +27,8 @@
   onChange?: (value: string, event: ChangeEvent<HTMLTextAreaElement>) => void;
   onInput?: (value: string, event: FormEvent<HTMLTextAreaElement>) => void;
   label?: string;
+  /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다. */
+  requirement?: FoxFormLabelRequirement;
   /** 헬퍼 메시지. 색은 상태를 따라간다. */
   message?: string;
   /** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */
@@ -53,6 +55,7 @@
   onChange,
   onInput,
   label,
+  requirement,
   message,
   messageIcon,
   counter = false,
@@ -96,7 +99,9 @@
   return (
     <div className={cx("fox-text-area", className)} data-state={state}>
       {label && (
-        <FoxFormLabel htmlFor={fieldId}>{label}</FoxFormLabel>
+        <FoxFormLabel htmlFor={fieldId} requirement={requirement}>
+          {label}
+        </FoxFormLabel>
       )}
 
       <div className="fox-text-area__box">
@fox/styles/_fox-email.scss
--- @fox/styles/_fox-email.scss
+++ @fox/styles/_fox-email.scss
@@ -1,25 +1,27 @@
 // FoxEmail — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
 //                   Figma form-email (136:3092 · 섹션 392:5820)
 //
-// [아이디] @ [도메인 드롭다운] [도메인 직접입력] + 선택적 확인 버튼.
-// 시안에서 세 칸이 모두 input·select 인스턴스이고 버튼도 btn 인스턴스라, 이 파일은 줄
-// 배치와 `@` 구분자만 갖는다 — 상자 모양과 상태는 FoxInput·FoxSelect가 가진 것을 쓴다.
+// 시안 구조는 **줄이 나뉜다** — [아이디] @ [도메인] 한 줄, 그 아래 도메인 드롭다운, 그 아래
+// 선택적 확인 버튼(form-email 5543:9940: form-group 320×40 · select 320×40 · btn).
+// 종전에는 넷을 한 줄에 두고 넘치면 접히게 했는데, 그러면 좁은 곳에서 드롭다운이 도메인 칸보다
+// 먼저 접혀 시안과 순서가 어긋났다.
+//
+// 세 칸이 모두 input·select 인스턴스이고 버튼도 btn 인스턴스라, 이 파일은 줄 배치와 `@`
+// 구분자만 갖는다 — 상자 모양과 상태는 FoxInput·FoxSelect가 가진 것을 쓴다.
 //
 // 마크업 계약 (React 밖 소비자용):
 //   <div class="fox-email">
-//     <div class="fox-email__row">
-//       <div class="fox-email__part">…fox-input…</div>
+//     <div class="fox-email__body">
+//     <div class="fox-email__group">
+//       <div class="fox-email__part">…fox-input(아이디)…</div>
 //       <span class="fox-email__at">@</span>
-//       <div class="fox-email__part">…fox-select…</div>
-//       <div class="fox-email__part">…fox-input…</div>
-//       <button class="fox-button fox-button--primary fox-button--md">확인</button>
+//       <div class="fox-email__part">…fox-input(도메인)…</div>
 //     </div>
+//     …fox-select(도메인 목록)…
+//     <button class="fox-button …">확인</button>            <!-- 선택 -->
 //   </div>
 //
 // 토큰이 아닌 값(사용자 승인):
-//   - 칸의 `min-inline-size: 24rem`(240px): 시안이 세 칸을 240px로 고정했는데 토큰
-//     스케일에 240이 없다. 고정 대신 최소폭으로 두어 넓은 곳에서는 나눠 갖고 좁아지면
-//     줄이 넘어가게 했다.
 //   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
 //     export하지 않는다.
 
@@ -30,18 +32,27 @@
   @include field.root;
 
 
-  // 좁아지면 칸이 찌그러지는 대신 줄이 넘어간다.
-  &__row {
+  // 세 줄이 4px 간격으로 쌓인다(시안 form-group 0-40 · select 44-84).
+  &__body {
     display: flex;
-    flex-wrap: wrap;
+    inline-size: 100%;
+    flex-direction: column;
+    gap: fox.gap(2);
+  }
+
+  // 아이디·@·도메인이 한 줄을 나눠 갖는다(시안 149 + @ + 149).
+  &__group {
+    display: flex;
     align-items: center;
     gap: fox.gap(2);
     inline-size: 100%;
   }
 
+  // 두 칸이 남는 폭을 똑같이 나눈다. `min-inline-size: 0`이 없으면 입력의 기본 폭 때문에
+  // 좁은 곳에서 넘친다.
   &__part {
     flex: 1 1 0;
-    min-inline-size: 24rem;
+    min-inline-size: 0;
   }
 
   &__at {
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
@@ -9,9 +9,8 @@
 } 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 { FoxEmail } from '@fox/core/components/fox-email';
 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,
@@ -46,8 +45,6 @@
   onClose: () => void;
 }
 
-const CUSTOM_EMAIL_DOMAIN = '__custom__';
-
 const EMAIL_DOMAIN_OPTIONS = [
   'naver.com',
   'gmail.com',
@@ -73,9 +70,7 @@
   const [isChecking, startChecking] = useTransition();
 
   const [phoneNumber, setPhoneNumber] = useState(DEFAULT_MOBILE_PHONE_PREFIX);
-  const [emailLocal, setEmailLocal] = useState('');
-  const [emailDomain, setEmailDomain] = useState(EMAIL_DOMAIN_OPTIONS[0]);
-  const [isCustomDomain, setIsCustomDomain] = useState(false);
+  const [emailValue, setEmailValue] = useState(`@${EMAIL_DOMAIN_OPTIONS[0]}`);
   const [isEmailTouched, setIsEmailTouched] = useState(false);
   const [roleCode, setRoleCode] = useState<string>(DEFAULT_ADMIN_ROLE_CODE);
   const [menuCodes, setMenuCodes] = useState<string[]>([]);
@@ -92,7 +87,8 @@
 
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
-  const email = emailLocal && emailDomain ? `${emailLocal}@${emailDomain}` : '';
+  const [emailLocal = '', emailDomain = ''] = emailValue.split('@');
+  const email = emailLocal && emailDomain ? emailValue : '';
 
   const emailMessage =
     errors.email ??
@@ -206,65 +202,19 @@
           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}
-                      onBlur={() => setIsEmailTouched(true)}
-                      invalid={Boolean(emailMessage)}
-                    />
-                  </span>
-                  <span aria-hidden="true">@</span>
-                  <span className={styles.emailPart}>
-                    {isCustomDomain ? (
-                      <FoxInput
-                        size="md"
-                        aria-label="이메일 도메인"
-                        placeholder="직접입력"
-                        value={emailDomain}
-                        onChange={setEmailDomain}
-                        onBlur={() => setIsEmailTouched(true)}
-                        invalid={Boolean(emailMessage)}
-                      />
-                    ) : (
-                      <FoxSelect
-                        size="md"
-                        options={[
-                          ...EMAIL_DOMAIN_OPTIONS.map((domain) => ({
-                            value: domain,
-                            label: domain,
-                          })),
-                          { value: CUSTOM_EMAIL_DOMAIN, label: '직접입력' },
-                        ]}
-                        value={emailDomain}
-                        onValueChange={(next) => {
-                          setIsEmailTouched(true);
-                          if (next === CUSTOM_EMAIL_DOMAIN) {
-                            setIsCustomDomain(true);
-                            setEmailDomain('');
-                            return;
-                          }
-                          setEmailDomain(next);
-                        }}
-                        error={Boolean(emailMessage)}
-                      />
-                    )}
-                  </span>
-                </div>
-                {emailMessage && (
-                  <p className={styles.fieldError} role="alert">
-                    {emailMessage}
-                  </p>
-                )}
-              </div>
+              <FoxEmail
+                label="이메일"
+                requirement="required"
+                domainOptions={[...EMAIL_DOMAIN_OPTIONS]}
+                localPlaceholder="ID"
+                value={emailValue}
+                onChange={(next) => {
+                  setEmailValue(next);
+                  setIsEmailTouched(true);
+                }}
+                message={emailMessage}
+                invalid={Boolean(emailMessage)}
+              />
               <input type="hidden" name="email" value={email} />
             </>
           }
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
@@ -49,7 +49,7 @@
       actions: (
         <FoxButtonGroup size="md">
           <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
-          <FoxButton type="error" size="md" label="삭제" onAction={runDelete} />
+          <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
         </FoxButtonGroup>
       ),
     });
 
app/(protected)/(basic)/boards/_components/board-filter-bar.tsx (deleted)
--- app/(protected)/(basic)/boards/_components/board-filter-bar.tsx
@@ -1,144 +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 { BOARD_CATEGORY_OPTIONS, type BoardType } from '@/lib/domain/board-post';
-import {
-  ANSWERED_FILTER_OPTIONS,
-  BOARD_PATHS,
-  BOARD_QUERY_FIELDS,
-  BOARD_SEARCH_FIELDS,
-  BOARD_TYPE_OPTIONS,
-  VISIBILITY_FILTER_OPTIONS,
-  buildBoardPostHref,
-  type BoardPostQuery,
-} from '@/lib/domain/board-post-query';
-
-interface BoardFilterBarProps {
-  boardType: BoardType;
-  query: BoardPostQuery;
-}
-
-/**
- * 검색·필터 영역 — 세 게시판이 공유한다. 어떤 필터를 그릴지는 `BOARD_QUERY_FIELDS`가 정한다
- * (공지사항: 구분·사용여부 / 1:1문의: 구분·질문유형·답변여부 / FAQ: 구분·유형).
- *
- * 순수 `<form method="get">` GET 제출로 URL을 갱신한다(자바스크립트 없이도 동작). `pageSize`는
- * hidden으로 함께 제출해 검색 후에도 유지되며, 검색 자체는 항상 1페이지로 돌아간다(hidden page를
- * 두지 않아 `parseBoardPostQuery`의 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 제출이 아니라
- * 기본값 링크라 현재 입력값과 무관하게 동작한다.
- *
- * 상호작용이 전부 네이티브 폼 제출/링크라 클라이언트 자바스크립트가 필요 없다 — Server Component.
- */
-export function BoardFilterBar({ boardType, query }: BoardFilterBarProps) {
-  const fields = BOARD_QUERY_FIELDS[boardType];
-  const searchFields = BOARD_SEARCH_FIELDS[boardType];
-  const typeOptions = BOARD_TYPE_OPTIONS[boardType];
-
-  const resetHref = buildBoardPostHref(boardType, query, {
-    category: '',
-    type: '',
-    visibility: '',
-    answered: '',
-    searchField: searchFields[0].value,
-    keyword: '',
-    page: 1,
-  });
-
-  return (
-    <form
-      method="get"
-      action={BOARD_PATHS[boardType]}
-      className="flex flex-wrap items-end gap-3"
-    >
-      <input type="hidden" name="pageSize" value={query.pageSize} />
-
-      {fields.category && (
-        <Field label="구분">
-          <Select name="category" defaultValue={query.category}>
-            <option value="">전체</option>
-            {BOARD_CATEGORY_OPTIONS.map((option) => (
-              <option key={option.value} value={option.value}>
-                {option.label}
-              </option>
-            ))}
-          </Select>
-        </Field>
-      )}
-
-      {fields.type && (
-        <Field label={boardType === 'inquiry' ? '질문유형' : '유형'}>
-          <Select name="type" defaultValue={query.type}>
-            <option value="">전체</option>
-            {typeOptions.map((option) => (
-              <option key={option.value} value={option.value}>
-                {option.label}
-              </option>
-            ))}
-          </Select>
-        </Field>
-      )}
-
-      {fields.visibility && (
-        <Field label="사용여부">
-          <Select name="visibility" defaultValue={query.visibility}>
-            <option value="">전체</option>
-            {VISIBILITY_FILTER_OPTIONS.map((option) => (
-              <option key={option.value} value={option.value}>
-                {option.label}
-              </option>
-            ))}
-          </Select>
-        </Field>
-      )}
-
-      {fields.answered && (
-        <Field label="답변여부">
-          <Select name="answered" defaultValue={query.answered}>
-            <option value="">전체</option>
-            {ANSWERED_FILTER_OPTIONS.map((option) => (
-              <option key={option.value} value={option.value}>
-                {option.label}
-              </option>
-            ))}
-          </Select>
-        </Field>
-      )}
-
-      {/* 검색 대상이 하나뿐인 화면(공지사항)은 select를 그리지 않고 hidden으로만 싣는다. */}
-      {searchFields.length > 1 ? (
-        <Field label="검색 대상">
-          <Select name="searchField" defaultValue={query.searchField}>
-            {searchFields.map((option) => (
-              <option key={option.value} value={option.value}>
-                {option.label}
-              </option>
-            ))}
-          </Select>
-        </Field>
-      ) : (
-        <input type="hidden" name="searchField" value={searchFields[0].value} />
-      )}
-
-      <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)/boards/_components/board-list-toolbar.tsx (deleted)
--- app/(protected)/(basic)/boards/_components/board-list-toolbar.tsx
@@ -1,78 +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 type { BoardType } from '@/lib/domain/board-post';
-import {
-  BOARD_PAGE_SIZE_OPTIONS,
-  buildBoardPostHref,
-  type BoardPageSize,
-  type BoardPostQuery,
-} from '@/lib/domain/board-post-query';
-import { BoardPostFormModal } from './board-post-form-modal';
-
-interface BoardListToolbarProps {
-  boardType: BoardType;
-  query: BoardPostQuery;
-  /** 1:1문의는 관리자가 글을 등록하지 않으므로 false다(사용자 확인 사항). */
-  canCreate: boolean;
-}
-
-/**
- * 목록 툴바 — [신규 등록]과 페이지 크기 select. 페이지 크기가 바뀌면 1페이지로 되돌린다
- * (기존 페이지 번호가 새 크기 기준으로는 의미가 달라지므로).
- *
- * 시안의 "최근등록순" 정렬 select는 두지 않았다 — 백엔드 목록 SQL의 정렬이 최신순으로 고정돼
- * 있고 시안의 select에도 다른 선택지가 없어, 선택지가 하나뿐인 컨트롤이 되기 때문이다.
- * 정렬 기준이 추가되면 그때 select를 붙인다.
- */
-export function BoardListToolbar({
-  boardType,
-  query,
-  canCreate,
-}: BoardListToolbarProps) {
-  const router = useRouter();
-  const [isCreateOpen, setIsCreateOpen] = useState(false);
-
-  function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) {
-    const pageSize = Number(event.target.value) as BoardPageSize;
-    router.replace(buildBoardPostHref(boardType, query, { pageSize, page: 1 }));
-  }
-
-  return (
-    <div className="flex flex-wrap items-center justify-end gap-3">
-      {canCreate && (
-        <Button
-          type="button"
-          variant="primary"
-          onClick={() => setIsCreateOpen(true)}
-        >
-          + 신규 등록
-        </Button>
-      )}
-
-      <Select
-        name="pageSize"
-        aria-label="페이지 크기"
-        defaultValue={query.pageSize}
-        onChange={handlePageSizeChange}
-      >
-        {BOARD_PAGE_SIZE_OPTIONS.map((size) => (
-          <option key={size} value={size}>
-            {size}개씩
-          </option>
-        ))}
-      </Select>
-
-      {isCreateOpen && (
-        <BoardPostFormModal
-          boardType={boardType}
-          mode="create"
-          onClose={() => setIsCreateOpen(false)}
-        />
-      )}
-    </div>
-  );
-}
 
app/(protected)/(basic)/boards/_components/board-post-columns.tsx (added)
+++ app/(protected)/(basic)/boards/_components/board-post-columns.tsx
@@ -0,0 +1,186 @@
+'use client';
+
+import { FoxBadge } from '@fox/core/components/fox-badge';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator';
+import type { FoxListColumn } from '@fox/core/components/fox-list-container';
+import { FoxPaperclipIcon } from '@fox/core/icons';
+import { formatPhoneNumber } from '@/lib/domain/phone-number';
+import {
+  formatAnswerStatusLabel,
+  formatBoardCategoryLabel,
+  formatFaqTypeLabel,
+  formatInquiryTypeLabel,
+  formatOptionalValue,
+  formatVisibilityLabel,
+  type BoardPost,
+  type BoardType,
+} from '@/lib/domain/board-post';
+import { BoardRowActions } from './board-row-actions';
+
+function numberColumn(page: number, pageSize: number): FoxListColumn<BoardPost> {
+  return {
+    key: 'no',
+    header: '번호',
+    width: 80,
+    render: (_row, index) => (page - 1) * pageSize + index + 1,
+  };
+}
+
+function visibilityColumn(): FoxListColumn<BoardPost> {
+  return {
+    key: 'isVisible',
+    header: '사용여부',
+    width: 120,
+    render: (row) => (
+      <FoxStatusIndicator
+        type={row.isVisible ? 'success' : 'danger'}
+        label={formatVisibilityLabel(row.isVisible)}
+      />
+    ),
+  };
+}
+
+function actionsColumn(boardType: BoardType): FoxListColumn<BoardPost> {
+  return {
+    key: 'actions',
+    header: '관리',
+    width: 120,
+    render: (row) => (
+      <span onClick={(event) => event.stopPropagation()}>
+        <BoardRowActions boardType={boardType} post={row} />
+      </span>
+    ),
+  };
+}
+
+export function noticeColumns(page: number, pageSize: number): FoxListColumn<BoardPost>[] {
+  return [
+    numberColumn(page, pageSize),
+    {
+      key: 'category',
+      header: '구분',
+      width: 120,
+      render: (row) => (
+        <FoxBadge type="pastel" color="neutral" size="md">
+          {formatBoardCategoryLabel(row.categoryCode)}
+        </FoxBadge>
+      ),
+    },
+    { key: 'title', header: '제목', width: 320, emphasis: true },
+    {
+      key: 'attachment',
+      header: '첨부',
+      width: 100,
+      render: (row) =>
+        row.attachmentId ? (
+          <FoxIconButton
+            type="default"
+            size="sm"
+            icon={<FoxPaperclipIcon />}
+            label={`${row.title} 첨부파일`}
+          />
+        ) : null,
+    },
+    {
+      key: 'authorName',
+      header: '작성자',
+      width: 160,
+      render: (row) => formatOptionalValue(row.authorName),
+    },
+    visibilityColumn(),
+    {
+      key: 'createdAt',
+      header: '작성일',
+      width: 140,
+      render: (row) => formatOptionalValue(row.createdAt),
+    },
+    actionsColumn('notice'),
+  ];
+}
+
+export function faqColumns(page: number, pageSize: number): FoxListColumn<BoardPost>[] {
+  return [
+    numberColumn(page, pageSize),
+    {
+      key: 'category',
+      header: '구분',
+      width: 120,
+      render: (row) => (
+        <FoxBadge type="pastel" color="neutral" size="md">
+          {formatBoardCategoryLabel(row.categoryCode)}
+        </FoxBadge>
+      ),
+    },
+    {
+      key: 'type',
+      header: '유형',
+      width: 120,
+      render: (row) => formatFaqTypeLabel(row.typeCode),
+    },
+    { key: 'title', header: '제목', width: 320, emphasis: true },
+    {
+      key: 'authorName',
+      header: '작성자',
+      width: 160,
+      render: (row) => formatOptionalValue(row.authorName),
+    },
+    {
+      key: 'createdAt',
+      header: '등록일',
+      width: 140,
+      render: (row) => formatOptionalValue(row.createdAt),
+    },
+    visibilityColumn(),
+    actionsColumn('faq'),
+  ];
+}
+
+export function inquiryColumns(page: number, pageSize: number): FoxListColumn<BoardPost>[] {
+  return [
+    numberColumn(page, pageSize),
+    {
+      key: 'type',
+      header: '질문유형',
+      width: 120,
+      render: (row) => formatInquiryTypeLabel(row.typeCode),
+    },
+    { key: 'title', header: '질문제목', width: 280, emphasis: true },
+    {
+      key: 'authorName',
+      header: '질문자명',
+      width: 140,
+      render: (row) => formatOptionalValue(row.authorName),
+    },
+    {
+      key: 'authorPhoneNumber',
+      header: '전화번호',
+      width: 160,
+      render: (row) => formatOptionalValue(formatPhoneNumber(row.authorPhoneNumber)),
+    },
+    {
+      key: 'createdAt',
+      header: '질문일',
+      width: 140,
+      render: (row) => formatOptionalValue(row.createdAt),
+    },
+    {
+      key: 'answerStatus',
+      header: '상태',
+      width: 120,
+      render: (row) => (
+        <FoxStatusIndicator
+          type={row.answeredAt ? 'success' : 'warning'}
+          label={formatAnswerStatusLabel(row.answerStatusCode)}
+        />
+      ),
+    },
+    {
+      key: 'answeredAt',
+      header: '답변일',
+      width: 140,
+      render: (row) => formatOptionalValue(row.answeredAt),
+    },
+    actionsColumn('inquiry'),
+  ];
+}
app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
--- app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
@@ -1,17 +1,21 @@
 '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 { Select } from '@/components/ui/select';
+import { useActionState, useEffect, useRef, useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+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 { FoxModal } from '@fox/core/components/fox-modal';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import { FoxTextArea } from '@fox/core/components/fox-text-area';
+import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
 import { useFeedback } from '@/app/_hooks/use-feedback';
 import {
   BOARD_CATEGORY_OPTIONS,
-  formatBoardCategoryLabel,
-  formatOptionalValue,
+  FAQ_TYPE_OPTIONS,
+  INQUIRY_TYPE_OPTIONS,
   type BoardPost,
   type BoardType,
 } from '@/lib/domain/board-post';
@@ -20,34 +24,15 @@
   INITIAL_BOARD_POST_FORM_STATE,
   TITLE_MAX_LENGTH,
 } from '@/lib/domain/board-post-form';
-import { BOARD_TYPE_OPTIONS } from '@/lib/domain/board-post-query';
 import { createBoardPostAction, updateBoardPostAction } from '../_actions';
-import { BoardAttachmentField } from './board-attachment-field';
-import { BoardContentField } from './board-content-field';
-import { submitFormAction } from '@/app/_hooks/submit-form-action';
+import styles from './board-post-form.module.scss';
 
 interface BoardPostFormModalProps {
   boardType: BoardType;
   mode: 'create' | 'edit';
-  /** 수정 모드의 기존 값. 등록 모드는 넘기지 않는다. */
   post?: BoardPost;
   onClose: () => void;
 }
-
-const FORM_ID = 'board-post-form';
-
-const PINNED_OPTIONS = [
-  { value: 'N', label: '사용안함' },
-  { value: 'Y', label: '사용' },
-];
-const VISIBILITY_OPTIONS = [
-  { value: 'Y', label: '노출' },
-  { value: 'N', label: '미노출' },
-];
-const PUSH_OPTIONS = [
-  { value: 'Y', label: '발송' },
-  { value: 'N', label: '미발송' },
-];
 
 const BOARD_TITLES: Record<BoardType, string> = {
   notice: '공지사항',
@@ -55,32 +40,32 @@
   faq: 'FAQ',
 };
 
-/**
- * 공지사항·FAQ의 등록/수정 팝업 — 시안 A_BOA_007_p·008_p(공지사항), 015_p·016_p(FAQ).
- *
- * 네 시안이 같은 폼의 변형이라 한 컴포넌트가 게시판 종류(`boardType`)와 모드(`mode`)로 분기한다.
- * 게시판별 차이는 두 가지뿐이다 — 공지사항에만 상단고정·노출기간·앱푸쉬가, FAQ에만 유형이 있다.
- *
- * **수정 시 구분은 읽기 전용이다**(시안 ①). 값 자체를 제출하지 않고, Server Action도 수정
- * 경로에서는 구분을 검증하지 않는다 — 직접 POST로 다른 값을 보내도 반영되지 않는다.
- *
- * 첨부파일이 있어 폼은 multipart로 제출된다(Server Action은 이를 자동으로 처리한다).
- * 저장 버튼은 footer에서 `form={FORM_ID}`로 연결한다 — 네이티브 HTML의 `form` 속성은 버튼이
- * DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 form을 제출한다.
- */
+const TYPE_OPTIONS: Partial<Record<BoardType, readonly { value: string; label: string }[]>> = {
+  faq: FAQ_TYPE_OPTIONS,
+  inquiry: INQUIRY_TYPE_OPTIONS,
+};
+
 export function BoardPostFormModal({
   boardType,
   mode,
   post,
   onClose,
 }: BoardPostFormModalProps) {
-  const { showToast } = useFeedback();
   const isCreate = mode === 'create';
-
+  const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
   const [state, formAction, isPending] = useActionState(
     isCreate ? createBoardPostAction : updateBoardPostAction,
     INITIAL_BOARD_POST_FORM_STATE
   );
+
+  const [categoryCode, setCategoryCode] = useState(
+    post?.categoryCode || BOARD_CATEGORY_OPTIONS[0].value
+  );
+  const [typeCode, setTypeCode] = useState(post?.typeCode ?? '');
+  const [isPinned, setIsPinned] = useState(post?.isPinned ?? false);
+  const [isVisible, setIsVisible] = useState(post?.isVisible ?? true);
+  const [isPushEnabled, setIsPushEnabled] = useState(post?.isPushEnabled ?? false);
 
   useEffect(() => {
     if (state.status === 'success') {
@@ -93,164 +78,190 @@
   }, [state, showToast, onClose, isCreate]);
 
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
-  const typeOptions = BOARD_TYPE_OPTIONS[boardType];
+  const typeOptions = TYPE_OPTIONS[boardType];
   const hasNoticeOnlyFields = boardType === 'notice';
 
   return (
-    <Modal
+    <FoxModal
+      open
+      size="md"
       title={`${BOARD_TITLES[boardType]} ${isCreate ? '신규 등록' : '수정'}`}
       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 ? '저장 중...' : isCreate ? '등록' : '수정'}
             disabled={isPending}
-          >
-            {isPending ? '저장 중...' : isCreate ? '등록' : '수정'}
-          </Button>
+            onAction={() => formRef.current?.requestSubmit()}
+          />
         </>
       }
     >
-      <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
+      <form
+        ref={formRef}
+        onSubmit={(event) => submitFormAction(event, formAction)}
+        className={styles.fields}
+      >
         <input type="hidden" name="boardType" value={boardType} />
         {!isCreate && post && <input type="hidden" name="id" value={post.id} />}
 
-        <p className="text-right text-body-sm text-danger">
-          * 는 필수 항목입니다.
-        </p>
+        <div className={styles.field}>
+          <FoxSelect
+            size="md"
+            label="구분"
+            options={BOARD_CATEGORY_OPTIONS.map((option) => ({
+              value: option.value,
+              label: option.label,
+            }))}
+            value={categoryCode}
+            onValueChange={setCategoryCode}
+            error={Boolean(errors.categoryCode)}
+            hint={errors.categoryCode}
+          />
+          <input type="hidden" name="categoryCode" value={categoryCode} />
+        </div>
 
-        <Field label={isCreate ? '구분 *' : '구분'}>
-          {isCreate ? (
-            <Select name="categoryCode" defaultValue={BOARD_CATEGORY_OPTIONS[0].value}>
-              {BOARD_CATEGORY_OPTIONS.map((option) => (
-                <option key={option.value} value={option.value}>
-                  {option.label}
-                </option>
-              ))}
-            </Select>
-          ) : (
-            <Input
-              value={formatBoardCategoryLabel(post?.categoryCode ?? null)}
-              readOnly
+        {typeOptions && (
+          <div className={styles.field}>
+            <FoxSelect
+              size="md"
+              label="유형"
+              options={typeOptions.map((option) => ({
+                value: option.value,
+                label: option.label,
+              }))}
+              value={typeCode}
+              onValueChange={setTypeCode}
+              placeholder="유형을 선택하세요."
+              error={Boolean(errors.typeCode)}
+              hint={errors.typeCode}
             />
-          )}
-        </Field>
-        <FieldError message={errors.categoryCode} />
-
-        {typeOptions.length > 0 && (
-          <>
-            <Field label="유형 *">
-              <Select name="typeCode" defaultValue={post?.typeCode ?? ''}>
-                <option value="">선택</option>
-                {typeOptions.map((option) => (
-                  <option key={option.value} value={option.value}>
-                    {option.label}
-                  </option>
-                ))}
-              </Select>
-            </Field>
-            <FieldError message={errors.typeCode} />
-          </>
+            <input type="hidden" name="typeCode" value={typeCode} />
+          </div>
         )}
 
-        <Field label="제목 *">
-          <Input
-            type="text"
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
             name="title"
+            label="제목"
+            requirement="required"
             defaultValue={post?.title ?? ''}
-            maxLength={TITLE_MAX_LENGTH}
             placeholder="제목을 입력하세요."
+            invalid={Boolean(errors.title)}
+            message={errors.title}
+            maxLength={TITLE_MAX_LENGTH}
           />
-        </Field>
-        <FieldError message={errors.title} />
+        </div>
 
-        <BoardContentField
-          name="content"
-          label="내용 *"
-          defaultValue={post?.content ?? ''}
-          maxLength={CONTENT_MAX_LENGTH}
-          placeholder="내용을 입력하세요."
-        />
-        <FieldError message={errors.content} />
+        <div className={styles.field}>
+          <FoxTextArea
+            name="content"
+            label="내용"
+            requirement="required"
+            defaultValue={post?.content ?? ''}
+            placeholder="내용을 입력하세요."
+            rows={8}
+            counter
+            maxLength={CONTENT_MAX_LENGTH}
+            invalid={Boolean(errors.content)}
+            message={errors.content}
+          />
+        </div>
 
-        <BoardAttachmentField attachmentId={post?.attachmentId} />
+        <div className={styles.field}>
+          <FoxFileUpload
+            mode="default"
+            name="attachment"
+            label="첨부파일"
+            selectLabel="파일선택"
+            placeholder="파일을 선택해 주세요."
+          />
+          <input
+            type="hidden"
+            name="attachmentId"
+            defaultValue={post?.attachmentId ?? ''}
+          />
+        </div>
 
         {hasNoticeOnlyFields && (
-          <>
-            <Field label="상단고정">
-              <RadioGroup
-                name="isPinned"
-                options={PINNED_OPTIONS}
-                defaultValue={post?.isPinned ? 'Y' : 'N'}
-              />
-            </Field>
-
-            <Field label="노출기간">
-              <div className="flex items-center gap-2">
-                <Input
-                  type="date"
-                  name="startDate"
-                  defaultValue={post?.startDate ?? ''}
-                  aria-label="게시시작일시"
-                />
-                <span className="text-foreground-muted">~</span>
-                <Input
-                  type="date"
-                  name="endDate"
-                  defaultValue={post?.endDate ?? ''}
-                  aria-label="게시종료일시"
-                />
-              </div>
-            </Field>
-            <FieldError message={errors.startDate} />
-            <FieldError message={errors.endDate} />
-          </>
-        )}
-
-        <Field label="사용여부 *">
-          <RadioGroup
-            name="isVisible"
-            options={VISIBILITY_OPTIONS}
-            defaultValue={post?.isVisible === false ? 'N' : 'Y'}
-          />
-        </Field>
-
-        {hasNoticeOnlyFields && (
-          <Field label="앱푸쉬 설정 *">
-            <RadioGroup
-              name="isPushEnabled"
-              options={PUSH_OPTIONS}
-              defaultValue={post?.isPushEnabled === false ? 'N' : 'Y'}
+          <div className={styles.field}>
+            <FoxFormLabel as="span">상단고정</FoxFormLabel>
+            <FoxToggleSwitch
+              size="md"
+              name="isPinned"
+              value="true"
+              label={isPinned ? '고정' : '미고정'}
+              checked={isPinned}
+              onChange={setIsPinned}
             />
-          </Field>
+          </div>
         )}
 
-        {!isCreate && post && (
-          <Field label="작성자 / 작성일">
-            <div className="flex items-center gap-2">
-              <Input value={formatOptionalValue(post.authorName)} readOnly />
-              <Input value={formatOptionalValue(post.createdAt)} readOnly />
+        {hasNoticeOnlyFields && (
+          <div className={styles.field}>
+            <FoxFormLabel as="span">게시기간</FoxFormLabel>
+            <div className={styles.period}>
+              <FoxInput
+                size="md"
+                type="date"
+                name="startDate"
+                aria-label="게시 시작일"
+                defaultValue={post?.startDate ?? ''}
+                invalid={Boolean(errors.startDate)}
+              />
+              <span aria-hidden="true">~</span>
+              <FoxInput
+                size="md"
+                type="date"
+                name="endDate"
+                aria-label="게시 종료일"
+                defaultValue={post?.endDate ?? ''}
+                invalid={Boolean(errors.endDate)}
+              />
             </div>
-          </Field>
+            {(errors.startDate ?? errors.endDate) && (
+              <FoxHelperText
+                type="danger"
+                message={errors.startDate ?? errors.endDate ?? ''}
+              />
+            )}
+          </div>
+        )}
+
+        <div className={styles.field}>
+          <FoxFormLabel as="span">사용여부</FoxFormLabel>
+          <FoxToggleSwitch
+            size="md"
+            name="isVisible"
+            value="true"
+            label={isVisible ? '노출' : '미노출'}
+            checked={isVisible}
+            onChange={setIsVisible}
+          />
+        </div>
+
+        {hasNoticeOnlyFields && (
+          <div className={styles.field}>
+            <FoxFormLabel as="span">앱푸쉬 설정</FoxFormLabel>
+            <FoxToggleSwitch
+              size="md"
+              name="isPushEnabled"
+              value="true"
+              label={isPushEnabled ? '발송' : '미발송'}
+              checked={isPushEnabled}
+              onChange={setIsPushEnabled}
+            />
+          </div>
         )}
 
         {state.status === 'error' && state.message && (
-          <p className="text-body-sm text-danger">{state.message}</p>
+          <FoxHelperText type="danger" message={state.message} />
         )}
       </form>
-    </Modal>
+    </FoxModal>
   );
-}
-
-/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */
-export function FieldError({ message }: { message?: string }) {
-  if (!message) {
-    return null;
-  }
-  return <p className="text-body-sm text-danger">{message}</p>;
 }
 
app/(protected)/(basic)/boards/_components/board-post-form.module.scss (added)
+++ app/(protected)/(basic)/boards/_components/board-post-form.module.scss
@@ -0,0 +1,28 @@
+@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);
+}
+
+.period {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(3);
+
+  > :first-child,
+  > :last-child {
+    flex: 1 1 0;
+    min-inline-size: 0;
+  }
+}
 
app/(protected)/(basic)/boards/_components/board-post-list.module.scss (added)
+++ app/(protected)/(basic)/boards/_components/board-post-list.module.scss
@@ -0,0 +1,7 @@
+@use "@fox/styles/abstracts" as fox;
+
+.filter {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(3);
+}
 
app/(protected)/(basic)/boards/_components/board-post-list.tsx (added)
+++ app/(protected)/(basic)/boards/_components/board-post-list.tsx
@@ -0,0 +1,168 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState, useTransition, type ReactNode } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { FoxSelectText } from '@fox/core/components/fox-select-text';
+import { FoxTab } from '@fox/core/components/fox-tab';
+import { FoxTabItem } from '@fox/core/components/fox-tab-item';
+import { FoxArrowsClockwiseIcon, FoxPlusIcon } from '@fox/core/icons';
+import type { BoardPost, BoardType } from '@/lib/domain/board-post';
+import {
+  BOARD_PAGE_SIZE_OPTIONS,
+  BOARD_SEARCH_FIELDS,
+  FILTER_ALL,
+  VISIBILITY_FILTER_OPTIONS,
+  buildBoardPostHref,
+  type BoardPageSize,
+  type BoardPostQuery,
+} from '@/lib/domain/board-post-query';
+import { BoardPostFormModal } from './board-post-form-modal';
+import styles from './board-post-list.module.scss';
+
+interface BoardPostListProps {
+  boardType: BoardType;
+  title: string;
+  breadcrumb: { label: string; href?: string }[];
+  columns: FoxListColumn<BoardPost>[];
+  items: BoardPost[];
+  query: BoardPostQuery;
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+  /** 1:1문의처럼 관리자가 글을 쓰지 않는 게시판은 끈다. */
+  canCreate?: boolean;
+  /** 행의 "관리" 열을 화면마다 다르게 그려야 할 때. */
+  extraToolbar?: ReactNode;
+}
+
+const SORT_OPTIONS = [{ value: 'recent', label: '최근등록순' }];
+
+export function BoardPostList({
+  boardType,
+  title,
+  breadcrumb,
+  columns,
+  items,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+  canCreate = true,
+  extraToolbar,
+}: BoardPostListProps) {
+  const router = useRouter();
+  const [isCreateOpen, setIsCreateOpen] = useState(false);
+  const [, startRefresh] = useTransition();
+
+  function go(patch: Partial<BoardPostQuery>) {
+    router.replace(buildBoardPostHref(boardType, query, { ...patch, page: 1 }));
+  }
+
+  return (
+    <>
+      <FoxListContainer<BoardPost>
+        title={title}
+        breadcrumb={breadcrumb}
+        caption={title}
+        columns={columns}
+        rows={items}
+        rowKey={(row) => row.id}
+        totalCount={totalCount}
+        emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요."
+        sorts={
+          <>
+            <FoxSelectText
+              size="sm"
+              ariaLabel="정렬"
+              options={SORT_OPTIONS}
+              value="recent"
+            />
+            <FoxSelectText
+              size="sm"
+              ariaLabel="페이지 크기"
+              options={BOARD_PAGE_SIZE_OPTIONS.map((size) => ({
+                value: String(size),
+                label: `${size}개씩 보기`,
+              }))}
+              value={String(query.pageSize)}
+              onValueChange={(value) =>
+                go({ pageSize: Number(value) as BoardPageSize })
+              }
+            />
+          </>
+        }
+        searchFields={BOARD_SEARCH_FIELDS[boardType].map((field) => ({
+          value: field.value,
+          label: field.label,
+        }))}
+        searchField={query.searchField}
+        keyword={query.keyword}
+        toolbarActions={
+          canCreate ? (
+            <FoxButton
+              type="primary"
+              size="md"
+              leadingIcon={<FoxPlusIcon />}
+              label="신규 등록"
+              onAction={() => setIsCreateOpen(true)}
+            />
+          ) : (
+            extraToolbar
+          )
+        }
+        filter={
+          <div className={styles.filter}>
+            <FoxIconButton
+              type="default"
+              size="md"
+              icon={<FoxArrowsClockwiseIcon />}
+              label="목록 새로고침"
+              onAction={() => startRefresh(() => router.refresh())}
+            />
+            <FoxTab
+              type="fill"
+              size="md"
+              label="노출 여부"
+              value={query.visibility}
+              onValueChange={(value) => go({ visibility: value })}
+            >
+              <FoxTabItem value={FILTER_ALL}>전체</FoxTabItem>
+              {VISIBILITY_FILTER_OPTIONS.map((option) => (
+                <FoxTabItem key={option.value} value={option.value}>
+                  {option.label}
+                </FoxTabItem>
+              ))}
+            </FoxTab>
+          </div>
+        }
+        page={currentPage}
+        totalPages={totalPages}
+        buildHref={(patch) =>
+          buildBoardPostHref(boardType, query, { page: Number(patch.page ?? 1) })
+        }
+        onQueryChange={(patch) => {
+          if (patch.keyword !== undefined || patch.searchField !== undefined) {
+            go({
+              keyword: String(patch.keyword ?? ''),
+              searchField: String(patch.searchField ?? query.searchField),
+            });
+          }
+        }}
+      />
+
+      {isCreateOpen && (
+        <BoardPostFormModal
+          boardType={boardType}
+          mode="create"
+          onClose={() => setIsCreateOpen(false)}
+        />
+      )}
+    </>
+  );
+}
app/(protected)/(basic)/boards/_components/board-row-actions.tsx
--- app/(protected)/(basic)/boards/_components/board-row-actions.tsx
+++ app/(protected)/(basic)/boards/_components/board-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';
@@ -52,14 +53,10 @@
       title: '게시물을 삭제하시겠습니까?',
       message: `"${post.title}" 게시물을 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
       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="primary" size="md" label="삭제" onAction={runDelete} />
+        </FoxButtonGroup>
       ),
     });
   }
@@ -67,14 +64,14 @@
   return (
     <div className="flex items-center gap-1">
       <FoxIconButton
-        type="ghost"
+        type="default"
         size="sm"
         icon={<FoxPencilSimpleIcon />}
         label="수정"
         onAction={() => setIsEditOpen(true)}
       />
       <FoxIconButton
-        type="ghost"
+        type="default"
         size="sm"
         icon={<FoxTrashIcon />}
         label={isDeleting ? '삭제 중' : '삭제'}
 
app/(protected)/(basic)/boards/faqs/_components/faq-table.tsx (deleted)
--- app/(protected)/(basic)/boards/faqs/_components/faq-table.tsx
@@ -1,73 +0,0 @@
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeaderCell,
-  TableRow,
-} from '@/components/ui/table';
-import {
-  formatBoardCategoryLabel,
-  formatFaqTypeLabel,
-  formatOptionalValue,
-  formatVisibilityLabel,
-  type BoardPost,
-} from '@/lib/domain/board-post';
-import { BoardRowActions } from '../../_components/board-row-actions';
-
-interface FaqTableProps {
-  items: BoardPost[];
-  page: number;
-  pageSize: number;
-  totalCount: number;
-}
-
-const COLUMN_HEADERS = [
-  '번호',
-  '구분',
-  '유형',
-  '제목',
-  '작성자',
-  '등록일',
-  '사용여부',
-  '관리',
-];
-
-/**
- * FAQ 목록 테이블 — 시안 A_BOA_014 ③.
- *
- * "유형"은 현재 항상 `-`다 — 백엔드에 유형 저장 필드가 아직 없어 조회 응답에 값이 없다
- * (`board-post.ts`의 `BOARD_TYPE_CODE_PARAM` 주석). 열은 유지해 두었으므로 백엔드에 필드가
- * 추가되면 Repository 매핑만 늘리면 그대로 채워진다.
- */
-export function FaqTable({ items, page, pageSize, totalCount }: FaqTableProps) {
-  const offset = (page - 1) * pageSize;
-
-  return (
-    <Table>
-      <TableHead>
-        <TableRow>
-          {COLUMN_HEADERS.map((header) => (
-            <TableHeaderCell key={header}>{header}</TableHeaderCell>
-          ))}
-        </TableRow>
-      </TableHead>
-      <TableBody>
-        {items.map((post, index) => (
-          <TableRow key={post.id}>
-            <TableCell>{totalCount - offset - index}</TableCell>
-            <TableCell>{formatBoardCategoryLabel(post.categoryCode)}</TableCell>
-            <TableCell>{formatFaqTypeLabel(post.typeCode)}</TableCell>
-            <TableCell>{formatOptionalValue(post.title)}</TableCell>
-            <TableCell>{formatOptionalValue(post.authorName)}</TableCell>
-            <TableCell>{formatOptionalValue(post.createdAt)}</TableCell>
-            <TableCell>{formatVisibilityLabel(post.isVisible)}</TableCell>
-            <TableCell>
-              <BoardRowActions boardType="faq" post={post} />
-            </TableCell>
-          </TableRow>
-        ))}
-      </TableBody>
-    </Table>
-  );
-}
app/(protected)/(basic)/boards/faqs/page.tsx
--- app/(protected)/(basic)/boards/faqs/page.tsx
+++ app/(protected)/(basic)/boards/faqs/page.tsx
@@ -1,26 +1,17 @@
 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 { fetchBoardPosts } from '@/lib/data/repositories/board-post-repository';
-import {
-  buildBoardPostHref,
-  parseBoardPostQuery,
-} from '@/lib/domain/board-post-query';
-import { BoardFilterBar } from '../_components/board-filter-bar';
-import { BoardListToolbar } from '../_components/board-list-toolbar';
-import { FaqTable } from './_components/faq-table';
+import { parseBoardPostQuery } from '@/lib/domain/board-post-query';
+import { faqColumns } from '../_components/board-post-columns';
+import { BoardPostList } from '../_components/board-post-list';
 
-export const metadata: Metadata = {
-  title: 'FAQ',
-};
+export const metadata: Metadata = { title: 'FAQ' };
+export const dynamic = 'force-dynamic';
 
 interface PageProps {
   searchParams: Promise<Record<string, string | string[] | undefined>>;
 }
 
-/** FAQ 목록 — 시안 A_BOA_014. 구조는 공지사항과 같고 컬럼·필터만 다르다. */
 export default async function Page({ searchParams }: PageProps) {
   await verifySession();
 
@@ -31,48 +22,20 @@
   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">FAQ 목록</h1>
-
-      <BoardFilterBar boardType="faq" query={query} />
-
-      <BoardListToolbar boardType="faq" query={query} canCreate />
-
-      <p className="text-body-md text-foreground-muted">
-        총 {totalCount}개 | 현재페이지 {currentPage}/{totalPages}
-      </p>
-
-      {items.length === 0 ? (
-        <Alert
-          title="검색 결과가 없습니다."
-          actions={
-            <Link
-              href={buildBoardPostHref('faq', query, { keyword: '', page: 1 })}
-              className="bg-foreground px-4 py-2 text-background"
-            >
-              확인
-            </Link>
-          }
-        >
-          다른 검색어로 다시 시도해 주세요.
-        </Alert>
-      ) : (
-        <>
-          <FaqTable
-            items={items}
-            page={currentPage}
-            pageSize={query.pageSize}
-            totalCount={totalCount}
-          />
-          <Pagination
-            currentPage={currentPage}
-            totalPages={totalPages}
-            buildHref={(page: number) =>
-              buildBoardPostHref('faq', query, { page })
-            }
-          />
-        </>
-      )}
-    </section>
+    <BoardPostList
+      boardType="faq"
+      title="FAQ 목록"
+      breadcrumb={[
+        { label: '홈', href: '/' },
+        { label: '게시판 관리(고객센터)' },
+        { label: 'FAQ' },
+      ]}
+      columns={faqColumns(currentPage, query.pageSize)}
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+    />
   );
 }
app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
@@ -20,7 +20,7 @@
 import { saveInquiryAnswerAction } from '../../_actions';
 import { BoardAttachmentField } from '../../_components/board-attachment-field';
 import { BoardContentField } from '../../_components/board-content-field';
-import { FieldError } from '../../_components/board-post-form-modal';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
 import { submitFormAction } from '@/app/_hooks/submit-form-action';
 
 interface InquiryAnswerModalProps {
@@ -139,7 +139,9 @@
         <p className="text-body-sm text-foreground-muted">
           답변완료를 선택한 경우에만 사용자 화면에 답변 내용이 노출됩니다.
         </p>
-        <FieldError message={errors.answerStatusCode} />
+        {errors.answerStatusCode && (
+          <FoxHelperText type="danger" message={errors.answerStatusCode} />
+        )}
 
         <BoardContentField
           name="answerContent"
@@ -148,7 +150,9 @@
           maxLength={ANSWER_MAX_LENGTH}
           placeholder="답변 내용을 입력하세요."
         />
-        <FieldError message={errors.answerContent} />
+        {errors.answerContent && (
+          <FoxHelperText type="danger" message={errors.answerContent} />
+        )}
 
         <BoardAttachmentField
           label="답변 첨부파일"
app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
@@ -1,6 +1,8 @@
 'use client';
 
 import { useState, useTransition } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { Button } from '@/components/ui/button';
 import { useFeedback } from '@/app/_hooks/use-feedback';
 import type { BoardPost } from '@/lib/domain/board-post';
@@ -43,14 +45,10 @@
       title: '문의를 삭제하시겠습니까?',
       message: `"${post.title}" 문의를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
       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="primary" size="md" label="삭제" onAction={runDelete} />
+        </FoxButtonGroup>
       ),
     });
   }
 
app/(protected)/(basic)/boards/inquiries/_components/inquiry-table.tsx (deleted)
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-table.tsx
@@ -1,84 +0,0 @@
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeaderCell,
-  TableRow,
-} from '@/components/ui/table';
-import {
-  formatAnswerStatusLabel,
-  formatInquiryTypeLabel,
-  formatOptionalValue,
-  maskPhoneNumber,
-  type BoardPost,
-} from '@/lib/domain/board-post';
-import { InquiryRowActions } from './inquiry-row-actions';
-
-interface InquiryTableProps {
-  items: BoardPost[];
-  page: number;
-  pageSize: number;
-  totalCount: number;
-}
-
-const COLUMN_HEADERS = [
-  '번호',
-  '질문유형',
-  '질문제목',
-  '질문자명',
-  '전화번호',
-  '질문일',
-  '상태',
-  '답변일',
-  '관리',
-];
-
-/**
- * 1:1문의 목록 테이블 — 시안 A_BOA_012 ②.
- *
- * **전화번호는 마스킹해서 표시한다**(사용자 확인 사항 — 시안의 `010-12**-**78`). 상세 팝업에서는
- * 답변을 위해 연락처를 확인해야 하므로 원본을 보여준다.
- *
- * "답변일"은 답변이 실제로 달린 경우에만 값이 있고 그 외에는 `-`다(시안 ② "답변일은 답변완료
- * 시에만 표시").
- */
-export function InquiryTable({
-  items,
-  page,
-  pageSize,
-  totalCount,
-}: InquiryTableProps) {
-  const offset = (page - 1) * pageSize;
-
-  return (
-    <Table>
-      <TableHead>
-        <TableRow>
-          {COLUMN_HEADERS.map((header) => (
-            <TableHeaderCell key={header}>{header}</TableHeaderCell>
-          ))}
-        </TableRow>
-      </TableHead>
-      <TableBody>
-        {items.map((post, index) => (
-          <TableRow key={post.id}>
-            <TableCell>{totalCount - offset - index}</TableCell>
-            <TableCell>{formatInquiryTypeLabel(post.typeCode)}</TableCell>
-            <TableCell>{formatOptionalValue(post.title)}</TableCell>
-            <TableCell>{formatOptionalValue(post.authorName)}</TableCell>
-            <TableCell>{maskPhoneNumber(post.authorPhoneNumber)}</TableCell>
-            <TableCell>{formatOptionalValue(post.createdAt)}</TableCell>
-            <TableCell>
-              {formatAnswerStatusLabel(post.answerStatusCode)}
-            </TableCell>
-            <TableCell>{formatOptionalValue(post.answeredAt)}</TableCell>
-            <TableCell>
-              <InquiryRowActions post={post} />
-            </TableCell>
-          </TableRow>
-        ))}
-      </TableBody>
-    </Table>
-  );
-}
app/(protected)/(basic)/boards/inquiries/page.tsx
--- app/(protected)/(basic)/boards/inquiries/page.tsx
+++ app/(protected)/(basic)/boards/inquiries/page.tsx
@@ -1,31 +1,17 @@
 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 { fetchBoardPosts } from '@/lib/data/repositories/board-post-repository';
-import {
-  buildBoardPostHref,
-  parseBoardPostQuery,
-} from '@/lib/domain/board-post-query';
-import { BoardFilterBar } from '../_components/board-filter-bar';
-import { BoardListToolbar } from '../_components/board-list-toolbar';
-import { InquiryTable } from './_components/inquiry-table';
+import { parseBoardPostQuery } from '@/lib/domain/board-post-query';
+import { inquiryColumns } from '../_components/board-post-columns';
+import { BoardPostList } from '../_components/board-post-list';
 
-export const metadata: Metadata = {
-  title: '1:1문의',
-};
+export const metadata: Metadata = { title: '1:1문의' };
+export const dynamic = 'force-dynamic';
 
 interface PageProps {
   searchParams: Promise<Record<string, string | string[] | undefined>>;
 }
 
-/**
- * 1:1문의 목록 — 시안 A_BOA_012.
- *
- * 관리자는 글을 등록하지 않고 **조회·답변·삭제만** 한다(사용자 확인 사항) — 그래서 툴바의
- * [신규 등록]을 끈다. 등록을 막는 것은 화면뿐 아니라 Server Action도 마찬가지다.
- */
 export default async function Page({ searchParams }: PageProps) {
   await verifySession();
 
@@ -36,55 +22,21 @@
   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">1:1문의 목록</h1>
-
-      <BoardFilterBar boardType="inquiry" query={query} />
-
-      <BoardListToolbar
-        boardType="inquiry"
-        query={query}
-        canCreate={false}
-      />
-
-      <p className="text-body-md text-foreground-muted">
-        총 {totalCount}개 | 현재페이지 {currentPage}/{totalPages}
-      </p>
-
-      {items.length === 0 ? (
-        <Alert
-          title="검색 결과가 없습니다."
-          actions={
-            <Link
-              href={buildBoardPostHref('inquiry', query, {
-                keyword: '',
-                page: 1,
-              })}
-              className="bg-foreground px-4 py-2 text-background"
-            >
-              확인
-            </Link>
-          }
-        >
-          다른 검색어로 다시 시도해 주세요.
-        </Alert>
-      ) : (
-        <>
-          <InquiryTable
-            items={items}
-            page={currentPage}
-            pageSize={query.pageSize}
-            totalCount={totalCount}
-          />
-          <Pagination
-            currentPage={currentPage}
-            totalPages={totalPages}
-            buildHref={(page: number) =>
-              buildBoardPostHref('inquiry', query, { page })
-            }
-          />
-        </>
-      )}
-    </section>
+    <BoardPostList
+      boardType="inquiry"
+      title="1:1문의 목록"
+      breadcrumb={[
+        { label: '홈', href: '/' },
+        { label: '게시판 관리(고객센터)' },
+        { label: '1:1문의' },
+      ]}
+      columns={inquiryColumns(currentPage, query.pageSize)}
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+      canCreate={false}
+    />
   );
 }
 
app/(protected)/(basic)/boards/notices/_components/notice-table.tsx (deleted)
--- app/(protected)/(basic)/boards/notices/_components/notice-table.tsx
@@ -1,83 +0,0 @@
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeaderCell,
-  TableRow,
-} from '@/components/ui/table';
-import {
-  EMPTY_FIELD_PLACEHOLDER,
-  formatBoardCategoryLabel,
-  formatOptionalValue,
-  formatVisibilityLabel,
-  type BoardPost,
-} from '@/lib/domain/board-post';
-import { BoardRowActions } from '../../_components/board-row-actions';
-
-interface NoticeTableProps {
-  items: BoardPost[];
-  page: number;
-  pageSize: number;
-  totalCount: number;
-}
-
-const COLUMN_HEADERS = [
-  '번호',
-  '구분',
-  '제목',
-  '첨부',
-  '작성자',
-  '사용여부',
-  '작성일',
-  '관리',
-];
-
-/**
- * 공지사항 목록 테이블 — 시안 A_BOA_006 ③.
- *
- * "번호"는 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다(시안이 2·1처럼 내림차순으로
- * 표기하고 목록이 최신순이라 가장 최근 글이 가장 큰 번호가 된다). 정확한 전체 건수를 전제하는데,
- * Repository가 전체를 받아 세기 때문에 그 전제가 성립한다.
- *
- * "첨부"는 파일 유무만 표시한다 — 백엔드에 atchFileId로 파일 정보를 조회하는 API가 없어 파일명·
- * 용량을 알 수 없다(`board-attachment-field.tsx` 주석 참조).
- */
-export function NoticeTable({
-  items,
-  page,
-  pageSize,
-  totalCount,
-}: NoticeTableProps) {
-  const offset = (page - 1) * pageSize;
-
-  return (
-    <Table>
-      <TableHead>
-        <TableRow>
-          {COLUMN_HEADERS.map((header) => (
-            <TableHeaderCell key={header}>{header}</TableHeaderCell>
-          ))}
-        </TableRow>
-      </TableHead>
-      <TableBody>
-        {items.map((post, index) => (
-          <TableRow key={post.id}>
-            <TableCell>{totalCount - offset - index}</TableCell>
-            <TableCell>{formatBoardCategoryLabel(post.categoryCode)}</TableCell>
-            <TableCell>{formatOptionalValue(post.title)}</TableCell>
-            <TableCell>
-              {post.attachmentId ? '있음' : EMPTY_FIELD_PLACEHOLDER}
-            </TableCell>
-            <TableCell>{formatOptionalValue(post.authorName)}</TableCell>
-            <TableCell>{formatVisibilityLabel(post.isVisible)}</TableCell>
-            <TableCell>{formatOptionalValue(post.createdAt)}</TableCell>
-            <TableCell>
-              <BoardRowActions boardType="notice" post={post} />
-            </TableCell>
-          </TableRow>
-        ))}
-      </TableBody>
-    </Table>
-  );
-}
app/(protected)/(basic)/boards/notices/page.tsx
--- app/(protected)/(basic)/boards/notices/page.tsx
+++ app/(protected)/(basic)/boards/notices/page.tsx
@@ -1,31 +1,17 @@
 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 { fetchBoardPosts } from '@/lib/data/repositories/board-post-repository';
-import {
-  buildBoardPostHref,
-  parseBoardPostQuery,
-} from '@/lib/domain/board-post-query';
-import { BoardFilterBar } from '../_components/board-filter-bar';
-import { BoardListToolbar } from '../_components/board-list-toolbar';
-import { NoticeTable } from './_components/notice-table';
+import { parseBoardPostQuery } from '@/lib/domain/board-post-query';
+import { noticeColumns } from '../_components/board-post-columns';
+import { BoardPostList } from '../_components/board-post-list';
 
-export const metadata: Metadata = {
-  title: '공지사항',
-};
+export const metadata: Metadata = { title: '공지사항' };
+export const dynamic = 'force-dynamic';
 
 interface PageProps {
   searchParams: Promise<Record<string, string | string[] | undefined>>;
 }
 
-/**
- * 공지사항 목록 — 시안 A_BOA_006.
- *
- * 전체 건수가 **확정값**이라 페이지 수를 그대로 계산한다 — Repository가 백엔드 페이징을 쓰지 않고
- * 전체를 받아 직접 세기 때문이다(백엔드 `totalCount`는 현재 페이지 행 수라 신뢰할 수 없다).
- */
 export default async function Page({ searchParams }: PageProps) {
   await verifySession();
 
@@ -33,56 +19,23 @@
   const { items, totalCount } = await fetchBoardPosts('notice', query);
 
   const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
-  // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐) 마지막 페이지로 맞춘다 —
-  // 표의 순번 계산도 이 값을 기준으로 해야 헤더의 "현재페이지"와 어긋나지 않는다.
   const currentPage = Math.min(query.page, totalPages);
 
   return (
-    <section className="flex flex-1 flex-col gap-6 p-8">
-      <h1 className="text-headline-sm text-foreground">공지사항 목록</h1>
-
-      <BoardFilterBar boardType="notice" query={query} />
-
-      <BoardListToolbar boardType="notice" query={query} canCreate />
-
-      <p className="text-body-md text-foreground-muted">
-        총 {totalCount}개 | 현재페이지 {currentPage}/{totalPages}
-      </p>
-
-      {items.length === 0 ? (
-        <Alert
-          title="검색 결과가 없습니다."
-          actions={
-            <Link
-              href={buildBoardPostHref('notice', query, {
-                keyword: '',
-                page: 1,
-              })}
-              className="bg-foreground px-4 py-2 text-background"
-            >
-              확인
-            </Link>
-          }
-        >
-          다른 검색어로 다시 시도해 주세요.
-        </Alert>
-      ) : (
-        <>
-          <NoticeTable
-            items={items}
-            page={currentPage}
-            pageSize={query.pageSize}
-            totalCount={totalCount}
-          />
-          <Pagination
-            currentPage={currentPage}
-            totalPages={totalPages}
-            buildHref={(page: number) =>
-              buildBoardPostHref('notice', query, { page })
-            }
-          />
-        </>
-      )}
-    </section>
+    <BoardPostList
+      boardType="notice"
+      title="공지사항 목록"
+      breadcrumb={[
+        { label: '홈', href: '/' },
+        { label: '게시판 관리(고객센터)' },
+        { label: '공지사항' },
+      ]}
+      columns={noticeColumns(currentPage, query.pageSize)}
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+    />
   );
 }
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
@@ -57,7 +57,7 @@
       actions: (
         <>
           <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
-          <FoxButton type="error" size="md" label="삭제" onAction={runDelete} />
+          <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
         </>
       ),
     });
app/(protected)/(basic)/system/codes/_components/code-management.tsx
--- app/(protected)/(basic)/system/codes/_components/code-management.tsx
+++ app/(protected)/(basic)/system/codes/_components/code-management.tsx
@@ -115,7 +115,7 @@
         <>
           <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
           <FoxButton
-            type="error"
+            type="primary"
             size="md"
             label="삭제"
             onAction={() => {
app/_components/feedback-host.tsx
--- app/_components/feedback-host.tsx
+++ app/_components/feedback-host.tsx
@@ -1,6 +1,7 @@
 'use client';
 
-import { Alert } from '@/components/ui/alert';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxModal } from '@fox/core/components/fox-modal';
 import { Spinner } from '@/components/ui/spinner';
 import { Toast } from '@/components/ui/toast';
 import { useFeedback } from '@/app/_hooks/use-feedback';
@@ -25,23 +26,24 @@
       )}
 
       {alert && (
-        <Alert
-          variant={alert.variant}
+        <FoxModal
+          open
+          size="sm"
           title={alert.title}
+          onClose={hideAlert}
           actions={
             alert.actions ?? (
-              <button
-                type="button"
-                onClick={hideAlert}
-                className="rounded-md bg-primary px-4 py-2 text-body-md text-on-primary"
-              >
-                확인
-              </button>
+              <FoxButton
+                type="primary"
+                size="md"
+                label="확인"
+                onAction={hideAlert}
+              />
             )
           }
         >
           {alert.message}
-        </Alert>
+        </FoxModal>
       )}
 
       {spinnerCount > 0 && (
app/layout.tsx
--- app/layout.tsx
+++ app/layout.tsx
@@ -2,27 +2,7 @@
 import { Geist_Mono } from "next/font/google";
 import { FeedbackHost } from "@/app/_components/feedback-host";
 import { FeedbackProvider } from "@/app/_components/feedback-provider";
-import {
-  THEME_ATTRIBUTE_NAME,
-  THEME_STORAGE_KEY,
-} from "@/lib/constants/theme";
 import "./globals.scss";
-
-// 첫 페인트 전 테마 적용 — <head>는 <body>보다 먼저 파싱되므로 <html>만 조작 가능하다
-// (preventing-flash-before-hydration.md의 Themes 패턴). localStorage에 명시 선택("light"
-// /"dark")이 저장돼 있으면 첫 페인트 전에 <html data-theme>을 부여해 FOUC를 막는다. 저장값이
-// 없거나("system") 손상된 값이면 속성을 부여하지 않는다 — globals.css의
-// `prefers-color-scheme` 미디어 쿼리가 그대로 OS 설정을 따르는 fail-safe 경로다.
-//
-// THEME_STORAGE_KEY/THEME_ATTRIBUTE_NAME은 반드시 'use client' 파일이 아닌
-// lib/constants/theme에서 import한다 — 'use client' 모듈의 일반 값(문자열) export를
-// Server Component가 직접 import하면 클라이언트 레퍼런스 프록시 특성상 undefined로 평가된다
-// (컴포넌트 export만 정상적으로 프록시된다).
-const THEME_BOOTSTRAP_SCRIPT = `(function(){try{var t=localStorage.getItem(${JSON.stringify(
-  THEME_STORAGE_KEY
-)});if(t==="light"||t==="dark")document.documentElement.setAttribute(${JSON.stringify(
-  THEME_ATTRIBUTE_NAME
-)},t)}catch(e){}})()`;
 
 // 앱 기본 서체는 여기서 선언하지 않는다 — @fox 토큰이 패밀리명을 리터럴 `Pretendard`로 못
 // 박고 있어 `next/font`(패밀리명을 해시로 바꾼다)를 쓸 수 없고, globals.scss의 `@font-face`가
@@ -63,11 +43,15 @@
   return (
     <html
       lang="ko"
+      // 테마를 바꾸는 기획이 아직 없어 라이트로 못박는다. @fox 토큰은 `color-scheme`으로
+      // 갈리므로(`@fox/styles/_root.scss`) 이 속성 하나가 OS 다크 설정까지 덮는다.
+      // 전환이 기획되면 이 값을 저장된 선택으로 되돌리고 `_hooks/use-theme-preference`와
+      // `_components/theme-toggle-button`을 헤더에 다시 붙이면 된다(둘 다 그대로 남아 있다).
+      data-theme="light"
       suppressHydrationWarning
       className={geistMono.variable}
     >
       <head>
-        <script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
         {/* @font-face는 CSS를 다 읽은 뒤에야 발견돼 본문이 한 번 폴백 서체로 그려진다.
             거의 모든 화면이 쓰는 regular만 미리 받아 그 깜빡임을 없앤다 — 나머지 굵기는
             빈도가 낮아 선점 비용이 이득보다 크다. */}
lib/domain/board-post.ts
--- lib/domain/board-post.ts
+++ lib/domain/board-post.ts
@@ -17,19 +17,13 @@
 export type BoardType = 'notice' | 'inquiry' | 'faq';
 
 /**
- * 백엔드 `stngId`(게시판 설정 ID) — **임시값이다.**
- *
- * 백엔드에 게시판 설정 목록 API도 테이블도 없고 소스에는 예시값 하나
- * (`5e9ae040df1b41c1b03ce11d1bafcc60`)만 있어 세 게시판 중 무엇인지 알 수 없다. 사용자 확인 결과
- * **"추후 백엔드에 추가 예정, 지금은 임의값으로 개발"** 이라 아래 값을 쓴다.
- *
- * 실제 값이 나오면 이 표만 고치면 된다 — 나머지 코드는 `BoardType`만 다루고 이 값을 직접 알지
- * 못한다. 그때까지 목록 조회는 빈 결과가 정상이다(존재하지 않는 stngId라 매칭되는 행이 없다).
+ * TODO: 추후 메뉴 데이터에 실려 오면 이 상수를 지우고 그 값을 쓴다.
+ * `GET /api/v1/common/bbs/stng/list`의 실제 값(typeSe는 셋 다 'NOT'이라 구분에 쓸 수 없다).
  */
 export const BOARD_SETTING_IDS: Record<BoardType, string> = {
-  notice: 'TEMP_STNG_ID_NOTICE',
-  inquiry: 'TEMP_STNG_ID_INQUIRY',
-  faq: 'TEMP_STNG_ID_FAQ',
+  notice: '5e9ae040df1b41c1b03ce11d1bafcc60',
+  inquiry: '4538fa2b88cc458da5cae972f39a1ebd',
+  faq: '9f3d4c7b2a3e4b7d8c1f6a9e2b5c7d10',
 };
 
 export type BoardPost = {
lib/domain/file-module.ts
--- lib/domain/file-module.ts
+++ lib/domain/file-module.ts
@@ -2,7 +2,7 @@
 // 이슈: 틀린 값이어도 업로드가 실패하지 않는다. 백엔드가 저장 설정을 못 찾으면 기본 저장소로
 // 폴백해 조용히 다른 곳에 저장한다.
 export const FILE_MODULE_ID = {
-  decorationItem: 'MODULE_ITEM',
+  decorationItem: 'MODULE_USER_ITEM',
   board: 'MODULE_BBS',
 } as const;
 
Add a comment
List