임동욱 임동욱 08-19
merge: 게시판 관리 3화면 시안 반영
@c3ff2d6531fc685d970280a0ac55b5a108e0c467
@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">
 
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
@@ -64,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-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}
+    />
   );
 }
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 = {
Add a comment
List