임동욱 임동욱 08-18
feat: 학생 회원 목록을 시안대로 재구성 (FoxListContainer 적용)
시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container 5199:9014.

@fox에 없는 조각은 없었다 — 지난 작업의 FoxListContainer가 머리말·도구 줄·표·페이지네이션을
한 번에 덮고, 정렬 셀렉트는 FoxSelectText, 검색은 FoxListSearch, 버튼은 FoxButton이다.
데이터도 이미 맞물려 있었다: StudentMember에 시안의 13열이 전부 있고 StudentMemberQuery에
검색필드·정렬·페이지크기·buildStudentMemberHref가 있다.

- students/_components/student-list.tsx 신설. 이 화면 고유의 것만 갖는다 — 13열 정의,
  정렬·페이지 크기 셀렉트, 엑셀 다운로드, URL 갱신 방법.
- page.tsx는 조회만 하고 넘긴다(Server Component 유지).
- student-search-bar · student-list-toolbar · student-table 3종을 지웠다. 셋이 하던 일을
  FoxListContainer가 대신한다.

이동은 두 갈래다. 페이지네이션은 `buildHref`로 링크가 되고(주소가 곧 상태), 검색·정렬·페이지
크기는 `router.replace`로 같은 주소 규칙을 태운다. 조건이 바뀌면 늘 1페이지로 되돌린다.

사용자 확정 2건
- 열 폭 합계가 1880px로 컨테이너(1552)보다 넓어 FoxListContainer에 가로 스크롤 래퍼를 넣었다.
  좁은 화면에서는 목록표가 카드로 바뀌므로(FoxTable의 mobile 규칙) PC에서만 건다.
- 관리 열을 "학생 회원 조회" 텍스트 버튼으로 바꿨다. 앞서 기획 시안을 근거로 돋보기 아이콘을
  넣었으나 이 Figma 시안이 더 최신이다. 누르면 상세 팝업이 열리는 동작은 그대로다.

지킨 것 — 순번은 저장된 값이 아니라 현재 페이지 기준 표시 순번((page-1)*pageSize+index+1)으로
종전 표와 같은 규칙이다. 엑셀은 네이티브 GET 폼이라야 첨부파일 응답을 브라우저가 받는다.

검증 — 실측이 시안과 일치한다. 제목·총 123건·정렬 셀렉트("가입일순"·"10개씩 보기")·검색 대상
"회원명"·엑셀 버튼, 13열 이름 전부, 관리 버튼 "학생 회원 조회", 표 1880/래퍼 1552로 가로
스크롤, 페이지 링크 `/students?page=2`, 모바일용 셀 라벨은 PC에서 display:none.

Co-Authored-By: Claude Opus 5 
@85519d36ae4066f3f5ac7dfd965c0501ae8ee6c3
@fox/core/components/fox-list-container/fox-list-container.tsx
--- @fox/core/components/fox-list-container/fox-list-container.tsx
+++ @fox/core/components/fox-list-container/fox-list-container.tsx
@@ -181,6 +181,8 @@
           />
         )}
 
+        {/* 열이 많으면 표가 화면보다 넓어진다. 눌러 담지 않고 이 래퍼가 가로로 스크롤한다. */}
+        <div className="fox-list-container__table">
         {/* `thead`/`tbody`로 감싸지 않는다 — FoxTable이 스스로 만들고, 머리행은 `head`만 보고
             제자리로 보낸다. 감싸면 표 안에 표 구조가 이중으로 생긴다. */}
         <FoxTable type="list" caption={caption}>
@@ -227,7 +229,8 @@
               </FoxTableRow>
             ))
           )}
-        </FoxTable>
+          </FoxTable>
+        </div>
 
         {totalPages > 1 && (
           <FoxPagination
@fox/styles/_fox-list-container.scss
--- @fox/styles/_fox-list-container.scss
+++ @fox/styles/_fox-list-container.scss
@@ -23,4 +23,19 @@
     inline-size: 100%;
     flex-direction: column;
   }
+
+  // 열이 많은 표는 화면보다 넓다. 눌러 담으면 시안 폭이 무너지므로 여기서 가로로 스크롤한다.
+  &__table {
+    inline-size: 100%;
+    overflow-x: auto;
+  }
+}
+
+// 표가 지정된 열 폭 아래로 눌리지 않게 한다 — 넓을 때만 넘치고, 좁으면 100%로 앉는다.
+// 좁은 화면에서는 목록표가 카드로 바뀌어(FoxTable의 mobile 규칙) 스크롤이 필요 없으므로
+// PC에서만 건다.
+@include fox.pc {
+  .fox-list-container__table > .fox-table {
+    min-inline-size: max-content;
+  }
 }
 
app/(protected)/(basic)/students/_components/student-list-toolbar.tsx (deleted)
--- app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
@@ -1,86 +0,0 @@
-'use client';
-
-import { useRouter } from 'next/navigation';
-import type { ChangeEvent } from 'react';
-import { Button } from '@/components/ui/button';
-import { Select } from '@/components/ui/select';
-import {
-  STUDENT_MEMBERS_EXCEL_PATH,
-  STUDENT_MEMBER_PAGE_SIZE_OPTIONS,
-  STUDENT_MEMBER_SORT_OPTIONS,
-  buildStudentMemberHref,
-  type StudentMemberPageSize,
-  type StudentMemberQuery,
-  type StudentMemberSortOption,
-} from '@/lib/domain/student-member-query';
-
-interface StudentListToolbarProps {
-  query: StudentMemberQuery;
-}
-
-/**
- * 목록 툴바 — 정렬/페이지 크기 select는 변경 즉시 URL을 갱신해 서버 데이터를 다시 조회하게
- * 한다(select의 onChange가 필요해 이 컴포넌트만 최말단 클라이언트 경계로 분리했다). 두 값 모두
- * 바뀌면 1페이지로 되돌린다(기존 페이지 번호가 새 정렬·크기 기준으로는 의미가 달라지므로).
- * 엑셀다운로드는 이번 범위에서 버튼 배치만 하고 동작은 구현하지 않는다(사용자 지시) —
- * disabled로 두어 클릭해도 아무 일도 일어나지 않게 한다.
- *
- * 정렬 select는 URL의 `sort`만 바꾸고, 그 값을 어떻게 만족시킬지는 Repository가 정한다 —
- * 가입일순은 백엔드의 고정 순서와 같아 그대로 받아오고, 이름순은 백엔드에 정렬 파라미터가 없어
- * 전체를 받아 서버에서 정렬한다. 화면은 그 차이를 알 필요가 없다.
- */
-export function StudentListToolbar({ query }: StudentListToolbarProps) {
-  const router = useRouter();
-
-  function handleSortChange(event: ChangeEvent<HTMLSelectElement>) {
-    const sort = event.target.value as StudentMemberSortOption;
-    router.replace(buildStudentMemberHref(query, { sort, page: 1 }));
-  }
-
-  function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) {
-    const pageSize = Number(event.target.value) as StudentMemberPageSize;
-    router.replace(buildStudentMemberHref(query, { pageSize, page: 1 }));
-  }
-
-  return (
-    <div className="flex flex-wrap items-center justify-between gap-3">
-      {/* 네이티브 GET 폼 제출 — 응답이 첨부파일(Content-Disposition: attachment)이라 브라우저가
-          현재 화면을 그대로 둔 채 파일만 내려받는다. 라우터 내비게이션(router.push)으로는
-          첨부파일 응답을 처리할 수 없고, Button은 <button>이라 링크로 쓸 수 없어 폼으로 감쌌다.
-          검색·페이징 값을 hidden으로 싣지 않는 것이 사양이다 — 파일은 항상 전체 데이터다. */}
-      <form action={STUDENT_MEMBERS_EXCEL_PATH} method="get">
-        <Button type="submit" variant="primary">
-          엑셀다운로드
-        </Button>
-      </form>
-
-      <div className="flex items-center gap-3">
-        <Select
-          name="sort"
-          aria-label="정렬"
-          defaultValue={query.sort}
-          onChange={handleSortChange}
-        >
-          {STUDENT_MEMBER_SORT_OPTIONS.map((option) => (
-            <option key={option.value} value={option.value}>
-              {option.label}
-            </option>
-          ))}
-        </Select>
-
-        <Select
-          name="pageSize"
-          aria-label="페이지 크기"
-          defaultValue={query.pageSize}
-          onChange={handlePageSizeChange}
-        >
-          {STUDENT_MEMBER_PAGE_SIZE_OPTIONS.map((size) => (
-            <option key={size} value={size}>
-              {size}개씩
-            </option>
-          ))}
-        </Select>
-      </div>
-    </div>
-  );
-}
 
app/(protected)/(basic)/students/_components/student-list.tsx (added)
+++ app/(protected)/(basic)/students/_components/student-list.tsx
@@ -0,0 +1,202 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { FoxButton } from '@fox/core/components/fox-button';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { FoxSelectText } from '@fox/core/components/fox-select-text';
+import type { StudentMember } from '@/lib/domain/student-member';
+import {
+  STUDENT_MEMBERS_EXCEL_PATH,
+  STUDENT_MEMBER_PAGE_SIZE_OPTIONS,
+  STUDENT_MEMBER_SEARCH_FIELD_OPTIONS,
+  STUDENT_MEMBER_SORT_OPTIONS,
+  buildStudentMemberHref,
+  type StudentMemberPageSize,
+  type StudentMemberQuery,
+  type StudentMemberSearchField,
+  type StudentMemberSortOption,
+} from '@/lib/domain/student-member-query';
+import { StudentRowActions } from './student-row-actions';
+
+interface StudentListProps {
+  items: StudentMember[];
+  query: StudentMemberQuery;
+  /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+}
+
+/** 값이 없으면 시안처럼 자리를 비우지 않고 `-`로 채운다. */
+function text(value: string | number | null | undefined) {
+  return value === null || value === undefined || value === '' ? '-' : String(value);
+}
+
+/** 학년·반·번호는 시안이 "2학년 5반 7번" 한 칸으로 묶는다. */
+function formatClass(member: StudentMember) {
+  const parts = [
+    member.grade === null ? null : `${member.grade}학년`,
+    member.classNumber === null ? null : `${member.classNumber}반`,
+    member.studentNumber === null ? null : `${member.studentNumber}번`,
+  ].filter(Boolean);
+  return parts.length > 0 ? parts.join(' ') : '-';
+}
+
+/**
+ * 학생 회원 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container 5199:9014
+ *
+ * 뼈대는 `FoxListContainer`가 전부 그린다. 이 파일이 갖는 것은 **이 화면 고유의 것**뿐이다 —
+ * 13개 열의 정의, 정렬·페이지 크기 셀렉트, 엑셀 다운로드, 그리고 URL을 갱신하는 방법.
+ *
+ * 이동은 두 갈래로 나뉜다. 페이지네이션은 `buildHref`로 **링크**가 되고(주소가 곧 상태라
+ * 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간
+ * `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다.
+ *
+ * 순번은 `query.page`가 아니라 `currentPage`를 기준으로 센다 — 백엔드가 범위를 벗어난 페이지
+ * 요청을 잘라서 응답하면 둘이 어긋나, "1/1 페이지"인데 순번은 31부터 시작하는 상태가 된다.
+ */
+export function StudentList({
+  items,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+}: StudentListProps) {
+  const router = useRouter();
+
+  /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */
+  function go(patch: Partial<StudentMemberQuery>) {
+    router.replace(buildStudentMemberHref(query, { ...patch, page: 1 }));
+  }
+
+  const columns: FoxListColumn<StudentMember>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      // 저장된 값이 아니라 현재 페이지 기준 표시 순번이다(종전 표와 같은 규칙).
+      render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
+    },
+    { key: 'memberCode', header: '회원코드', width: 120 },
+    { key: 'name', header: '이름', width: 120, emphasis: true },
+    { key: 'loginId', header: '아이디', width: 160 },
+    {
+      key: 'phoneNumber',
+      header: '휴대전화번호',
+      width: 160,
+      render: (row) => text(row.phoneNumber),
+    },
+    { key: 'email', header: '이메일', width: 200, render: (row) => text(row.email) },
+    { key: 'role', header: '역할', width: 120 },
+    {
+      key: 'schoolName',
+      header: '학교',
+      width: 160,
+      render: (row) => text(row.schoolName),
+    },
+    { key: 'classInfo', header: '학년/반/번호', width: 160, render: formatClass },
+    {
+      key: 'guardianName',
+      header: '보호자이름',
+      width: 120,
+      render: (row) => text(row.guardianName),
+    },
+    {
+      key: 'guardianPhoneNumber',
+      header: '보호자연락처',
+      width: 160,
+      render: (row) => text(row.guardianPhoneNumber),
+    },
+    {
+      key: 'joinedAt',
+      header: '가입일',
+      width: 160,
+      render: (row) => text(row.joinedAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 160,
+      render: (row) => <StudentRowActions member={row} />,
+    },
+  ];
+
+  return (
+    <FoxListContainer<StudentMember>
+      title="학생 회원 목록"
+      breadcrumb={[
+        { label: '홈', href: '/' },
+        { label: '회원정보관리' },
+        { label: '학생 회원' },
+      ]}
+      caption="학생 회원 목록"
+      columns={columns}
+      rows={items}
+      rowKey={(row) => row.id}
+      totalCount={totalCount}
+      emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요."
+      sorts={
+        <>
+          <FoxSelectText
+            size="sm"
+            ariaLabel="정렬"
+            options={STUDENT_MEMBER_SORT_OPTIONS.map((option) => ({
+              value: option.value,
+              label: option.label,
+            }))}
+            value={query.sort}
+            onValueChange={(value) => go({ sort: value as StudentMemberSortOption })}
+          />
+          <FoxSelectText
+            size="sm"
+            ariaLabel="페이지 크기"
+            options={STUDENT_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ({
+              value: String(size),
+              label: `${size}개씩 보기`,
+            }))}
+            value={String(query.pageSize)}
+            onValueChange={(value) =>
+              go({ pageSize: Number(value) as StudentMemberPageSize })
+            }
+          />
+        </>
+      }
+      searchFields={STUDENT_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ({
+        value: option.value,
+        label: option.label,
+      }))}
+      searchField={query.searchField}
+      keyword={query.keyword}
+      toolbarActions={
+        // 네이티브 GET 폼이라야 한다 — 응답이 첨부파일(Content-Disposition)이라 라우터
+        // 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다.
+        // 검색·페이징 값을 싣지 않는 것이 사양이다(파일은 항상 전체 데이터).
+        <form action={STUDENT_MEMBERS_EXCEL_PATH} method="get">
+          <FoxButton
+            type="primary"
+            size="md"
+            htmlType="submit"
+            label="엑셀 다운로드"
+          />
+        </form>
+      }
+      page={currentPage}
+      totalPages={totalPages}
+      buildHref={(patch) =>
+        buildStudentMemberHref(query, { page: Number(patch.page ?? 1) })
+      }
+      onQueryChange={(patch) => {
+        if (patch.keyword !== undefined || patch.searchField !== undefined) {
+          go({
+            keyword: String(patch.keyword ?? ''),
+            searchField: (patch.searchField ??
+              query.searchField) as StudentMemberSearchField,
+          });
+        }
+      }}
+    />
+  );
+}
app/(protected)/(basic)/students/_components/student-row-actions.tsx
--- app/(protected)/(basic)/students/_components/student-row-actions.tsx
+++ app/(protected)/(basic)/students/_components/student-row-actions.tsx
@@ -1,8 +1,7 @@
 'use client';
 
 import { useState } from 'react';
-import { FoxIconButton } from '@fox/core/components/fox-icon-button';
-import { FoxMagnifyingGlassIcon } from '@fox/core/icons';
+import { FoxButton } from '@fox/core/components/fox-button';
 import type { StudentMember } from '@/lib/domain/student-member';
 import { StudentDetailModal } from './student-detail-modal';
 
@@ -15,20 +14,19 @@
  * 클라이언트로 내리지 않기 위해 이 셀만 분리했다 — §2.4 "'use client'는 최말단에만"). 팝업에
  * 필요한 데이터는 이미 이 행이 갖고 있는 member를 그대로 전달한다(재조회 없음).
  *
- * 시안의 "돋보기 아이콘 버튼"이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을
- * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로
- * 정의하는 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다).
+ * 시안(관리자페이지 QsFVFFUKGH68xAPVkF3SJ9 5199:9014)의 관리 열은 "학생 회원 조회" 텍스트
+ * 버튼이다 — 앞서 기획 시안을 근거로 돋보기 아이콘을 넣었으나, 이 Figma 시안이 더 최신이라
+ * 그쪽으로 맞췄다(사용자 확정). 누르면 상세 팝업이 열리는 동작은 그대로다.
  */
 export function StudentRowActions({ member }: StudentRowActionsProps) {
   const [isDetailOpen, setIsDetailOpen] = useState(false);
 
   return (
     <>
-      <FoxIconButton
-        type="ghost"
+      <FoxButton
+        type="default"
         size="sm"
-        icon={<FoxMagnifyingGlassIcon />}
-        label="조회"
+        label="학생 회원 조회"
         onAction={() => setIsDetailOpen(true)}
       />
       {isDetailOpen && (
 
app/(protected)/(basic)/students/_components/student-search-bar.tsx (deleted)
--- app/(protected)/(basic)/students/_components/student-search-bar.tsx
@@ -1,72 +0,0 @@
-import Link from 'next/link';
-import { Button } from '@/components/ui/button';
-import { Field } from '@/components/ui/field';
-import { Input } from '@/components/ui/input';
-import { Select } from '@/components/ui/select';
-import {
-  DEFAULT_STUDENT_MEMBER_SEARCH_FIELD,
-  STUDENT_MEMBERS_PATH,
-  STUDENT_MEMBER_SEARCH_FIELD_OPTIONS,
-  buildStudentMemberHref,
-  type StudentMemberQuery,
-} from '@/lib/domain/student-member-query';
-
-interface StudentSearchBarProps {
-  query: StudentMemberQuery;
-}
-
-/**
- * 검색 영역 — 순수 `<form method="get">` GET 제출로 URL을 갱신한다(자바스크립트 없이도
- * 동작). 현재 `sort`·`pageSize`는 hidden input으로 함께 제출해 검색 후에도 유지되게 하고,
- * 검색 자체는 항상 1페이지로 돌아간다(hidden page 필드를 두지 않아 parseStudentMemberQuery의
- * 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 제출이 아니라 기본값으로 되돌린 링크라
- * 현재 입력값과 무관하게 동작한다. 상호작용이 전부 네이티브 폼 제출/링크 내비게이션이라
- * 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다.
- */
-export function StudentSearchBar({ query }: StudentSearchBarProps) {
-  const resetHref = buildStudentMemberHref(query, {
-    searchField: DEFAULT_STUDENT_MEMBER_SEARCH_FIELD,
-    keyword: '',
-    page: 1,
-  });
-
-  return (
-    <form
-      method="get"
-      action={STUDENT_MEMBERS_PATH}
-      className="flex flex-wrap items-end gap-3"
-    >
-      <input type="hidden" name="sort" value={query.sort} />
-      <input type="hidden" name="pageSize" value={query.pageSize} />
-
-      <Field label="검색 대상">
-        <Select name="searchField" defaultValue={query.searchField}>
-          {STUDENT_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => (
-            <option key={option.value} value={option.value}>
-              {option.label}
-            </option>
-          ))}
-        </Select>
-      </Field>
-
-      <Field label="검색어">
-        <Input
-          type="text"
-          name="keyword"
-          defaultValue={query.keyword}
-          placeholder="검색어를 입력하세요"
-        />
-      </Field>
-
-      <Button type="submit" variant="secondary">
-        검색
-      </Button>
-      <Link
-        href={resetHref}
-        className="text-body-md text-foreground-muted underline-offset-2 hover:underline"
-      >
-        초기화
-      </Link>
-    </form>
-  );
-}
 
app/(protected)/(basic)/students/_components/student-table.tsx (deleted)
--- app/(protected)/(basic)/students/_components/student-table.tsx
@@ -1,86 +0,0 @@
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeaderCell,
-  TableRow,
-} from '@/components/ui/table';
-import {
-  formatGradeClassNumber,
-  formatOptionalValue,
-  type StudentMember,
-} from '@/lib/domain/student-member';
-import { StudentRowActions } from './student-row-actions';
-
-interface StudentTableProps {
-  items: StudentMember[];
-  page: number;
-  pageSize: number;
-}
-
-const COLUMN_HEADERS = [
-  '번호',
-  '회원코드',
-  '이름',
-  '아이디',
-  '휴대전화번호',
-  '이메일',
-  '역할',
-  '학교',
-  '학년/반/번호',
-  '보호자이름',
-  '보호자연락처',
-  '가입일',
-  '관리',
-];
-
-/**
- * 학생 회원 목록 테이블 — 조회 전용(등록/수정/삭제 없음). "번호"는 저장된 값이 아니라
- * 현재 페이지 기준 표시 순번(= (page-1)*pageSize + 행 인덱스 + 1)이다. 개인정보 마스킹은
- * 기획 시안에서 명시적으로 해제 지시가 있어 원본 값을 그대로 노출한다.
- *
- * 백엔드가 아직 주지 않는 항목(휴대전화·이메일·학교·학년/반/번호·보호자·가입일)은 열을 그대로
- * 유지한 채 `-`로 표시한다 — 백엔드가 필드를 추가하면 Repository 매핑만 늘리면 이 파일은
- * 그대로 값이 채워진다.
- *
- * "관리" 열은 행별 조회 팝업 트리거(StudentRowActions)에 위임한다 — 상호작용이 필요한 것은
- * 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 경계로 뗀다
- * (§2.4 "'use client'는 최말단에만").
- */
-export function StudentTable({ items, page, pageSize }: StudentTableProps) {
-  return (
-    <Table>
-      <TableHead>
-        <TableRow>
-          {COLUMN_HEADERS.map((header) => (
-            <TableHeaderCell key={header}>{header}</TableHeaderCell>
-          ))}
-        </TableRow>
-      </TableHead>
-      <TableBody>
-        {items.map((member, index) => (
-          <TableRow key={member.id}>
-            <TableCell>{(page - 1) * pageSize + index + 1}</TableCell>
-            <TableCell>{member.memberCode}</TableCell>
-            <TableCell>{member.name}</TableCell>
-            <TableCell>{member.loginId}</TableCell>
-            <TableCell>{formatOptionalValue(member.phoneNumber)}</TableCell>
-            <TableCell>{formatOptionalValue(member.email)}</TableCell>
-            <TableCell>{member.role}</TableCell>
-            <TableCell>{formatOptionalValue(member.schoolName)}</TableCell>
-            <TableCell>{formatGradeClassNumber(member)}</TableCell>
-            <TableCell>{formatOptionalValue(member.guardianName)}</TableCell>
-            <TableCell>
-              {formatOptionalValue(member.guardianPhoneNumber)}
-            </TableCell>
-            <TableCell>{formatOptionalValue(member.joinedAt)}</TableCell>
-            <TableCell>
-              <StudentRowActions member={member} />
-            </TableCell>
-          </TableRow>
-        ))}
-      </TableBody>
-    </Table>
-  );
-}
app/(protected)/(basic)/students/page.tsx
--- app/(protected)/(basic)/students/page.tsx
+++ app/(protected)/(basic)/students/page.tsx
@@ -1,17 +1,11 @@
-import type { Metadata } from 'next';
-import Link from 'next/link';
-import { Alert } from '@/components/ui/alert';
-import { Pagination } from '@/components/ui/pagination';
 import { verifySession } from '@/lib/auth/dal';
 import { fetchStudentMembers } from '@/lib/data/repositories/student-member-repository';
-import { buildStudentMemberHref, parseStudentMemberQuery } from '@/lib/domain/student-member-query';
-import { StudentListToolbar } from './_components/student-list-toolbar';
-import { StudentSearchBar } from './_components/student-search-bar';
-import { StudentTable } from './_components/student-table';
+import { parseStudentMemberQuery } from '@/lib/domain/student-member-query';
+import { StudentList } from './_components/student-list';
 
-export const metadata: Metadata = {
-  title: '학생 회원',
-};
+// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를
+// 원천 차단하기 위해 명시적으로 강제한다.
+export const dynamic = 'force-dynamic';
 
 interface PageProps {
   searchParams: Promise<Record<string, string | string[] | undefined>>;
@@ -32,50 +26,12 @@
   const currentPage = Math.min(query.page, totalPages);
 
   return (
-    <section className="flex flex-1 flex-col gap-6 p-8">
-      <h1 className="text-headline-sm text-foreground">학생 회원 목록</h1>
-
-      <StudentSearchBar query={query} />
-
-      <StudentListToolbar query={query} />
-
-      <p className="text-body-md text-foreground-muted">
-        총 {totalCount}명{isTotalCountExact ? '' : ' 이상'} | 현재페이지{' '}
-        {currentPage}
-        {isTotalCountExact ? `/${totalPages}` : ''}
-      </p>
-
-      {items.length === 0 ? (
-        <Alert
-          title="검색 결과가 없습니다."
-          actions={
-            <Link
-              href={buildStudentMemberHref(query, { keyword: '', page: 1 })}
-              className="bg-foreground px-4 py-2 text-background"
-            >
-              확인
-            </Link>
-          }
-        >
-          다른 검색어로 다시 시도해 주세요.
-        </Alert>
-      ) : (
-        <>
-          {/* 표의 순번 기준은 요청 페이지(query.page)가 아니라 화면이 실제로 보여주는
-              페이지(currentPage)다 — 백엔드가 범위를 벗어난 페이지 요청을 clamp해서 응답하면
-              둘이 어긋나 "현재페이지 1/1"인데 순번은 31부터 시작하는 상태가 된다. */}
-          <StudentTable
-            items={items}
-            page={currentPage}
-            pageSize={query.pageSize}
-          />
-          <Pagination
-            currentPage={currentPage}
-            totalPages={totalPages}
-            buildHref={(page: number) => buildStudentMemberHref(query, { page })}
-          />
-        </>
-      )}
-    </section>
+    <StudentList
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+    />
   );
 }
Add a comment
List