임동욱 임동욱 08-10
feat: 학생 회원 목록 조회 화면 구현
@a1aabba7e8dce792f1ba03f47cad8aad61abc52c
 
app/(protected)/(basic)/students/_components/student-list-toolbar.tsx (added)
+++ app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
@@ -0,0 +1,75 @@
+'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_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로 두어 클릭해도 아무 일도 일어나지 않게 한다.
+ */
+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">
+      <Button type="button" variant="primary" disabled title="준비 중입니다">
+        엑셀다운로드
+      </Button>
+
+      <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-search-bar.tsx (added)
+++ app/(protected)/(basic)/students/_components/student-search-bar.tsx
@@ -0,0 +1,72 @@
+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/page.tsx
--- app/(protected)/(basic)/students/page.tsx
+++ app/(protected)/(basic)/students/page.tsx
@@ -1,18 +1,39 @@
 import type { Metadata } from 'next';
 import { verifySession } from '@/lib/auth/dal';
+import { fetchStudentMembers } from '@/lib/data/repositories/student-member-repository';
+import { parseStudentMemberQuery } from '@/lib/domain/student-member-query';
+import { StudentListToolbar } from './_components/student-list-toolbar';
+import { StudentSearchBar } from './_components/student-search-bar';
 
 export const metadata: Metadata = {
   title: '학생 회원',
 };
 
-// 목록·상세는 이번 범위 밖 — 라우트 골격만 제공한다 (§4.1 승인 범위 한정).
-export default async function Page() {
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+// 결과 테이블·페이지네이션은 별도 커밋(T3)에서 연결한다 — 여기서는 검색·정렬·표시개수의
+// searchParams 배선과 요약 카운트까지만 담당한다(§4.1 승인 범위 한정).
+export default async function Page({ searchParams }: PageProps) {
   await verifySession();
 
+  const query = parseStudentMemberQuery(await searchParams);
+  const { totalCount } = await fetchStudentMembers(query);
+  const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
+
   return (
-    <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8">
-      <h1 className="text-lg text-foreground">학생 회원</h1>
-      <p className="text-sm text-foreground/60">준비 중인 화면입니다.</p>
+    <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}명 | 현재페이지 {Math.min(query.page, totalPages)}/
+        {totalPages}
+      </p>
     </section>
   );
 }
Add a comment
List