임동욱 임동욱 08-10
feat: 학생 회원 목록 테이블 추가
@072224a789fea134d4d2d73ffea6197e1d9f99da
 
app/(protected)/(basic)/students/_components/student-table.tsx (added)
+++ app/(protected)/(basic)/students/_components/student-table.tsx
@@ -0,0 +1,78 @@
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeaderCell,
+  TableRow,
+} from '@/components/ui/table';
+import { Button } from '@/components/ui/button';
+import type { StudentMember } from '@/lib/domain/student-member';
+
+interface StudentTableProps {
+  items: StudentMember[];
+  page: number;
+  pageSize: number;
+}
+
+const COLUMN_HEADERS = [
+  '번호',
+  '회원코드',
+  '이름',
+  '아이디',
+  '휴대전화번호',
+  '이메일',
+  '역할',
+  '학교',
+  '학년/반/번호',
+  '보호자이름',
+  '보호자연락처',
+  '가입일',
+  '관리',
+];
+
+/**
+ * 학생 회원 목록 테이블 — 조회 전용(등록/수정/삭제 없음). "번호"는 저장된 값이 아니라
+ * 현재 페이지 기준 표시 순번(= (page-1)*pageSize + 행 인덱스 + 1)이다. 개인정보 마스킹은
+ * 기획 시안에서 명시적으로 해제 지시가 있어 원본 값을 그대로 노출한다.
+ *
+ * "관리" 열의 조회 버튼은 이 커밋에서는 자리만 잡고 비활성 상태다 — 클릭 시 팝업을 여는
+ * 상호작용과 팝업 자체는 다음 커밋(T4)에서 함께 연결한다(그때 이 셀만 클라이언트 최말단
+ * 컴포넌트로 교체된다). 이 테이블 자체는 Server Component로 유지한다.
+ */
+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>{member.phoneNumber}</TableCell>
+            <TableCell>{member.email}</TableCell>
+            <TableCell>{member.role}</TableCell>
+            <TableCell>{member.schoolName}</TableCell>
+            <TableCell>{`${member.grade}학년 ${member.classNumber}반 ${member.studentNumber}번`}</TableCell>
+            <TableCell>{member.guardianName}</TableCell>
+            <TableCell>{member.guardianPhoneNumber}</TableCell>
+            <TableCell>{member.joinedAt}</TableCell>
+            <TableCell>
+              <Button type="button" variant="ghost" size="sm" disabled>
+                조회
+              </Button>
+            </TableCell>
+          </TableRow>
+        ))}
+      </TableBody>
+    </Table>
+  );
+}
app/(protected)/(basic)/students/page.tsx
--- app/(protected)/(basic)/students/page.tsx
+++ app/(protected)/(basic)/students/page.tsx
@@ -1,9 +1,13 @@
 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 { parseStudentMemberQuery } from '@/lib/domain/student-member-query';
+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';
 
 export const metadata: Metadata = {
   title: '학생 회원',
@@ -13,14 +17,13 @@
   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 { items, totalCount } = await fetchStudentMembers(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">
@@ -31,9 +34,37 @@
       <StudentListToolbar query={query} />
 
       <p className="text-body-md text-foreground-muted">
-        총 {totalCount}명 | 현재페이지 {Math.min(query.page, totalPages)}/
-        {totalPages}
+        총 {totalCount}명 | 현재페이지 {currentPage}/{totalPages}
       </p>
+
+      {items.length === 0 ? (
+        <Alert
+          title="검색 결과가 없습니다."
+          actions={
+            <Link
+              href={buildStudentMemberHref(query, {
+                searchField: query.searchField,
+                keyword: '',
+                page: 1,
+              })}
+              className="bg-foreground px-4 py-2 text-background"
+            >
+              확인
+            </Link>
+          }
+        >
+          다른 검색어로 다시 시도해 주세요.
+        </Alert>
+      ) : (
+        <>
+          <StudentTable items={items} page={query.page} pageSize={query.pageSize} />
+          <Pagination
+            currentPage={currentPage}
+            totalPages={totalPages}
+            buildHref={(page: number) => buildStudentMemberHref(query, { page })}
+          />
+        </>
+      )}
     </section>
   );
 }
Add a comment
List