+++ app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
... | ... | @@ -0,0 +1,75 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import type { ChangeEvent } from 'react'; | |
| 5 | +import { Button } from '@/components/ui/button'; | |
| 6 | +import { Select } from '@/components/ui/select'; | |
| 7 | +import { | |
| 8 | + STUDENT_MEMBER_PAGE_SIZE_OPTIONS, | |
| 9 | + STUDENT_MEMBER_SORT_OPTIONS, | |
| 10 | + buildStudentMemberHref, | |
| 11 | + type StudentMemberPageSize, | |
| 12 | + type StudentMemberQuery, | |
| 13 | + type StudentMemberSortOption, | |
| 14 | +} from '@/lib/domain/student-member-query'; | |
| 15 | + | |
| 16 | +interface StudentListToolbarProps { | |
| 17 | + query: StudentMemberQuery; | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * 목록 툴바 — 정렬/페이지 크기 select는 변경 즉시 URL을 갱신해 서버 데이터를 다시 조회하게 | |
| 22 | + * 한다(select의 onChange가 필요해 이 컴포넌트만 최말단 클라이언트 경계로 분리했다). 두 값 모두 | |
| 23 | + * 바뀌면 1페이지로 되돌린다(기존 페이지 번호가 새 정렬·크기 기준으로는 의미가 달라지므로). | |
| 24 | + * 엑셀다운로드는 이번 범위에서 버튼 배치만 하고 동작은 구현하지 않는다(사용자 지시) — | |
| 25 | + * disabled로 두어 클릭해도 아무 일도 일어나지 않게 한다. | |
| 26 | + */ | |
| 27 | +export function StudentListToolbar({ query }: StudentListToolbarProps) { | |
| 28 | + const router = useRouter(); | |
| 29 | + | |
| 30 | + function handleSortChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 31 | + const sort = event.target.value as StudentMemberSortOption; | |
| 32 | + router.replace(buildStudentMemberHref(query, { sort, page: 1 })); | |
| 33 | + } | |
| 34 | + | |
| 35 | + function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 36 | + const pageSize = Number(event.target.value) as StudentMemberPageSize; | |
| 37 | + router.replace(buildStudentMemberHref(query, { pageSize, page: 1 })); | |
| 38 | + } | |
| 39 | + | |
| 40 | + return ( | |
| 41 | + <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 42 | + <Button type="button" variant="primary" disabled title="준비 중입니다"> | |
| 43 | + 엑셀다운로드 | |
| 44 | + </Button> | |
| 45 | + | |
| 46 | + <div className="flex items-center gap-3"> | |
| 47 | + <Select | |
| 48 | + name="sort" | |
| 49 | + aria-label="정렬" | |
| 50 | + defaultValue={query.sort} | |
| 51 | + onChange={handleSortChange} | |
| 52 | + > | |
| 53 | + {STUDENT_MEMBER_SORT_OPTIONS.map((option) => ( | |
| 54 | + <option key={option.value} value={option.value}> | |
| 55 | + {option.label} | |
| 56 | + </option> | |
| 57 | + ))} | |
| 58 | + </Select> | |
| 59 | + | |
| 60 | + <Select | |
| 61 | + name="pageSize" | |
| 62 | + aria-label="페이지 크기" | |
| 63 | + defaultValue={query.pageSize} | |
| 64 | + onChange={handlePageSizeChange} | |
| 65 | + > | |
| 66 | + {STUDENT_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ( | |
| 67 | + <option key={size} value={size}> | |
| 68 | + {size}개씩 | |
| 69 | + </option> | |
| 70 | + ))} | |
| 71 | + </Select> | |
| 72 | + </div> | |
| 73 | + </div> | |
| 74 | + ); | |
| 75 | +} |
+++ app/(protected)/(basic)/students/_components/student-search-bar.tsx
... | ... | @@ -0,0 +1,72 @@ |
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Button } from '@/components/ui/button'; | |
| 3 | +import { Field } from '@/components/ui/field'; | |
| 4 | +import { Input } from '@/components/ui/input'; | |
| 5 | +import { Select } from '@/components/ui/select'; | |
| 6 | +import { | |
| 7 | + DEFAULT_STUDENT_MEMBER_SEARCH_FIELD, | |
| 8 | + STUDENT_MEMBERS_PATH, | |
| 9 | + STUDENT_MEMBER_SEARCH_FIELD_OPTIONS, | |
| 10 | + buildStudentMemberHref, | |
| 11 | + type StudentMemberQuery, | |
| 12 | +} from '@/lib/domain/student-member-query'; | |
| 13 | + | |
| 14 | +interface StudentSearchBarProps { | |
| 15 | + query: StudentMemberQuery; | |
| 16 | +} | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * 검색 영역 — 순수 `<form method="get">` GET 제출로 URL을 갱신한다(자바스크립트 없이도 | |
| 20 | + * 동작). 현재 `sort`·`pageSize`는 hidden input으로 함께 제출해 검색 후에도 유지되게 하고, | |
| 21 | + * 검색 자체는 항상 1페이지로 돌아간다(hidden page 필드를 두지 않아 parseStudentMemberQuery의 | |
| 22 | + * 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 제출이 아니라 기본값으로 되돌린 링크라 | |
| 23 | + * 현재 입력값과 무관하게 동작한다. 상호작용이 전부 네이티브 폼 제출/링크 내비게이션이라 | |
| 24 | + * 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다. | |
| 25 | + */ | |
| 26 | +export function StudentSearchBar({ query }: StudentSearchBarProps) { | |
| 27 | + const resetHref = buildStudentMemberHref(query, { | |
| 28 | + searchField: DEFAULT_STUDENT_MEMBER_SEARCH_FIELD, | |
| 29 | + keyword: '', | |
| 30 | + page: 1, | |
| 31 | + }); | |
| 32 | + | |
| 33 | + return ( | |
| 34 | + <form | |
| 35 | + method="get" | |
| 36 | + action={STUDENT_MEMBERS_PATH} | |
| 37 | + className="flex flex-wrap items-end gap-3" | |
| 38 | + > | |
| 39 | + <input type="hidden" name="sort" value={query.sort} /> | |
| 40 | + <input type="hidden" name="pageSize" value={query.pageSize} /> | |
| 41 | + | |
| 42 | + <Field label="검색 대상"> | |
| 43 | + <Select name="searchField" defaultValue={query.searchField}> | |
| 44 | + {STUDENT_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ( | |
| 45 | + <option key={option.value} value={option.value}> | |
| 46 | + {option.label} | |
| 47 | + </option> | |
| 48 | + ))} | |
| 49 | + </Select> | |
| 50 | + </Field> | |
| 51 | + | |
| 52 | + <Field label="검색어"> | |
| 53 | + <Input | |
| 54 | + type="text" | |
| 55 | + name="keyword" | |
| 56 | + defaultValue={query.keyword} | |
| 57 | + placeholder="검색어를 입력하세요" | |
| 58 | + /> | |
| 59 | + </Field> | |
| 60 | + | |
| 61 | + <Button type="submit" variant="secondary"> | |
| 62 | + 검색 | |
| 63 | + </Button> | |
| 64 | + <Link | |
| 65 | + href={resetHref} | |
| 66 | + className="text-body-md text-foreground-muted underline-offset-2 hover:underline" | |
| 67 | + > | |
| 68 | + 초기화 | |
| 69 | + </Link> | |
| 70 | + </form> | |
| 71 | + ); | |
| 72 | +} |
--- app/(protected)/(basic)/students/page.tsx
+++ app/(protected)/(basic)/students/page.tsx
... | ... | @@ -1,18 +1,39 @@ |
| 1 | 1 |
import type { Metadata } from 'next';
|
| 2 | 2 |
import { verifySession } from '@/lib/auth/dal';
|
| 3 |
+import { fetchStudentMembers } from '@/lib/data/repositories/student-member-repository';
|
|
| 4 |
+import { parseStudentMemberQuery } from '@/lib/domain/student-member-query';
|
|
| 5 |
+import { StudentListToolbar } from './_components/student-list-toolbar';
|
|
| 6 |
+import { StudentSearchBar } from './_components/student-search-bar';
|
|
| 3 | 7 |
|
| 4 | 8 |
export const metadata: Metadata = {
|
| 5 | 9 |
title: '학생 회원', |
| 6 | 10 |
}; |
| 7 | 11 |
|
| 8 |
-// 목록·상세는 이번 범위 밖 — 라우트 골격만 제공한다 (§4.1 승인 범위 한정). |
|
| 9 |
-export default async function Page() {
|
|
| 12 |
+interface PageProps {
|
|
| 13 |
+ searchParams: Promise<Record<string, string | string[] | undefined>>; |
|
| 14 |
+} |
|
| 15 |
+ |
|
| 16 |
+// 결과 테이블·페이지네이션은 별도 커밋(T3)에서 연결한다 — 여기서는 검색·정렬·표시개수의 |
|
| 17 |
+// searchParams 배선과 요약 카운트까지만 담당한다(§4.1 승인 범위 한정). |
|
| 18 |
+export default async function Page({ searchParams }: PageProps) {
|
|
| 10 | 19 |
await verifySession(); |
| 11 | 20 |
|
| 21 |
+ const query = parseStudentMemberQuery(await searchParams); |
|
| 22 |
+ const { totalCount } = await fetchStudentMembers(query);
|
|
| 23 |
+ const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); |
|
| 24 |
+ |
|
| 12 | 25 |
return ( |
| 13 |
- <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> |
|
| 14 |
- <h1 className="text-lg text-foreground">학생 회원</h1> |
|
| 15 |
- <p className="text-sm text-foreground/60">준비 중인 화면입니다.</p> |
|
| 26 |
+ <section className="flex flex-1 flex-col gap-6 p-8"> |
|
| 27 |
+ <h1 className="text-headline-sm text-foreground">학생 회원 목록</h1> |
|
| 28 |
+ |
|
| 29 |
+ <StudentSearchBar query={query} />
|
|
| 30 |
+ |
|
| 31 |
+ <StudentListToolbar query={query} />
|
|
| 32 |
+ |
|
| 33 |
+ <p className="text-body-md text-foreground-muted"> |
|
| 34 |
+ 총 {totalCount}명 | 현재페이지 {Math.min(query.page, totalPages)}/
|
|
| 35 |
+ {totalPages}
|
|
| 36 |
+ </p> |
|
| 16 | 37 |
</section> |
| 17 | 38 |
); |
| 18 | 39 |
} |
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?