+++ app/(protected)/(basic)/students/_components/student-table.tsx
... | ... | @@ -0,0 +1,78 @@ |
| 1 | +import { | |
| 2 | + Table, | |
| 3 | + TableBody, | |
| 4 | + TableCell, | |
| 5 | + TableHead, | |
| 6 | + TableHeaderCell, | |
| 7 | + TableRow, | |
| 8 | +} from '@/components/ui/table'; | |
| 9 | +import { Button } from '@/components/ui/button'; | |
| 10 | +import type { StudentMember } from '@/lib/domain/student-member'; | |
| 11 | + | |
| 12 | +interface StudentTableProps { | |
| 13 | + items: StudentMember[]; | |
| 14 | + page: number; | |
| 15 | + pageSize: number; | |
| 16 | +} | |
| 17 | + | |
| 18 | +const COLUMN_HEADERS = [ | |
| 19 | + '번호', | |
| 20 | + '회원코드', | |
| 21 | + '이름', | |
| 22 | + '아이디', | |
| 23 | + '휴대전화번호', | |
| 24 | + '이메일', | |
| 25 | + '역할', | |
| 26 | + '학교', | |
| 27 | + '학년/반/번호', | |
| 28 | + '보호자이름', | |
| 29 | + '보호자연락처', | |
| 30 | + '가입일', | |
| 31 | + '관리', | |
| 32 | +]; | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * 학생 회원 목록 테이블 — 조회 전용(등록/수정/삭제 없음). "번호"는 저장된 값이 아니라 | |
| 36 | + * 현재 페이지 기준 표시 순번(= (page-1)*pageSize + 행 인덱스 + 1)이다. 개인정보 마스킹은 | |
| 37 | + * 기획 시안에서 명시적으로 해제 지시가 있어 원본 값을 그대로 노출한다. | |
| 38 | + * | |
| 39 | + * "관리" 열의 조회 버튼은 이 커밋에서는 자리만 잡고 비활성 상태다 — 클릭 시 팝업을 여는 | |
| 40 | + * 상호작용과 팝업 자체는 다음 커밋(T4)에서 함께 연결한다(그때 이 셀만 클라이언트 최말단 | |
| 41 | + * 컴포넌트로 교체된다). 이 테이블 자체는 Server Component로 유지한다. | |
| 42 | + */ | |
| 43 | +export function StudentTable({ items, page, pageSize }: StudentTableProps) { | |
| 44 | + return ( | |
| 45 | + <Table> | |
| 46 | + <TableHead> | |
| 47 | + <TableRow> | |
| 48 | + {COLUMN_HEADERS.map((header) => ( | |
| 49 | + <TableHeaderCell key={header}>{header}</TableHeaderCell> | |
| 50 | + ))} | |
| 51 | + </TableRow> | |
| 52 | + </TableHead> | |
| 53 | + <TableBody> | |
| 54 | + {items.map((member, index) => ( | |
| 55 | + <TableRow key={member.id}> | |
| 56 | + <TableCell>{(page - 1) * pageSize + index + 1}</TableCell> | |
| 57 | + <TableCell>{member.memberCode}</TableCell> | |
| 58 | + <TableCell>{member.name}</TableCell> | |
| 59 | + <TableCell>{member.loginId}</TableCell> | |
| 60 | + <TableCell>{member.phoneNumber}</TableCell> | |
| 61 | + <TableCell>{member.email}</TableCell> | |
| 62 | + <TableCell>{member.role}</TableCell> | |
| 63 | + <TableCell>{member.schoolName}</TableCell> | |
| 64 | + <TableCell>{`${member.grade}학년 ${member.classNumber}반 ${member.studentNumber}번`}</TableCell> | |
| 65 | + <TableCell>{member.guardianName}</TableCell> | |
| 66 | + <TableCell>{member.guardianPhoneNumber}</TableCell> | |
| 67 | + <TableCell>{member.joinedAt}</TableCell> | |
| 68 | + <TableCell> | |
| 69 | + <Button type="button" variant="ghost" size="sm" disabled> | |
| 70 | + 조회 | |
| 71 | + </Button> | |
| 72 | + </TableCell> | |
| 73 | + </TableRow> | |
| 74 | + ))} | |
| 75 | + </TableBody> | |
| 76 | + </Table> | |
| 77 | + ); | |
| 78 | +} |
--- app/(protected)/(basic)/students/page.tsx
+++ app/(protected)/(basic)/students/page.tsx
... | ... | @@ -1,9 +1,13 @@ |
| 1 | 1 |
import type { Metadata } from 'next';
|
| 2 |
+import Link from 'next/link'; |
|
| 3 |
+import { Alert } from '@/components/ui/alert';
|
|
| 4 |
+import { Pagination } from '@/components/ui/pagination';
|
|
| 2 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 3 | 6 |
import { fetchStudentMembers } from '@/lib/data/repositories/student-member-repository';
|
| 4 |
-import { parseStudentMemberQuery } from '@/lib/domain/student-member-query';
|
|
| 7 |
+import { buildStudentMemberHref, parseStudentMemberQuery } from '@/lib/domain/student-member-query';
|
|
| 5 | 8 |
import { StudentListToolbar } from './_components/student-list-toolbar';
|
| 6 | 9 |
import { StudentSearchBar } from './_components/student-search-bar';
|
| 10 |
+import { StudentTable } from './_components/student-table';
|
|
| 7 | 11 |
|
| 8 | 12 |
export const metadata: Metadata = {
|
| 9 | 13 |
title: '학생 회원', |
... | ... | @@ -13,14 +17,13 @@ |
| 13 | 17 |
searchParams: Promise<Record<string, string | string[] | undefined>>; |
| 14 | 18 |
} |
| 15 | 19 |
|
| 16 |
-// 결과 테이블·페이지네이션은 별도 커밋(T3)에서 연결한다 — 여기서는 검색·정렬·표시개수의 |
|
| 17 |
-// searchParams 배선과 요약 카운트까지만 담당한다(§4.1 승인 범위 한정). |
|
| 18 | 20 |
export default async function Page({ searchParams }: PageProps) {
|
| 19 | 21 |
await verifySession(); |
| 20 | 22 |
|
| 21 | 23 |
const query = parseStudentMemberQuery(await searchParams); |
| 22 |
- const { totalCount } = await fetchStudentMembers(query);
|
|
| 24 |
+ const { items, totalCount } = await fetchStudentMembers(query);
|
|
| 23 | 25 |
const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); |
| 26 |
+ const currentPage = Math.min(query.page, totalPages); |
|
| 24 | 27 |
|
| 25 | 28 |
return ( |
| 26 | 29 |
<section className="flex flex-1 flex-col gap-6 p-8"> |
... | ... | @@ -31,9 +34,37 @@ |
| 31 | 34 |
<StudentListToolbar query={query} />
|
| 32 | 35 |
|
| 33 | 36 |
<p className="text-body-md text-foreground-muted"> |
| 34 |
- 총 {totalCount}명 | 현재페이지 {Math.min(query.page, totalPages)}/
|
|
| 35 |
- {totalPages}
|
|
| 37 |
+ 총 {totalCount}명 | 현재페이지 {currentPage}/{totalPages}
|
|
| 36 | 38 |
</p> |
| 39 |
+ |
|
| 40 |
+ {items.length === 0 ? (
|
|
| 41 |
+ <Alert |
|
| 42 |
+ title="검색 결과가 없습니다." |
|
| 43 |
+ actions={
|
|
| 44 |
+ <Link |
|
| 45 |
+ href={buildStudentMemberHref(query, {
|
|
| 46 |
+ searchField: query.searchField, |
|
| 47 |
+ keyword: '', |
|
| 48 |
+ page: 1, |
|
| 49 |
+ })} |
|
| 50 |
+ className="bg-foreground px-4 py-2 text-background" |
|
| 51 |
+ > |
|
| 52 |
+ 확인 |
|
| 53 |
+ </Link> |
|
| 54 |
+ } |
|
| 55 |
+ > |
|
| 56 |
+ 다른 검색어로 다시 시도해 주세요. |
|
| 57 |
+ </Alert> |
|
| 58 |
+ ) : ( |
|
| 59 |
+ <> |
|
| 60 |
+ <StudentTable items={items} page={query.page} pageSize={query.pageSize} />
|
|
| 61 |
+ <Pagination |
|
| 62 |
+ currentPage={currentPage}
|
|
| 63 |
+ totalPages={totalPages}
|
|
| 64 |
+ buildHref={(page: number) => buildStudentMemberHref(query, { page })}
|
|
| 65 |
+ /> |
|
| 66 |
+ </> |
|
| 67 |
+ )} |
|
| 37 | 68 |
</section> |
| 38 | 69 |
); |
| 39 | 70 |
} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?