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
... | ... | @@ -181,6 +181,8 @@ |
| 181 | 181 |
/> |
| 182 | 182 |
)} |
| 183 | 183 |
|
| 184 |
+ {/* 열이 많으면 표가 화면보다 넓어진다. 눌러 담지 않고 이 래퍼가 가로로 스크롤한다. */}
|
|
| 185 |
+ <div className="fox-list-container__table"> |
|
| 184 | 186 |
{/* `thead`/`tbody`로 감싸지 않는다 — FoxTable이 스스로 만들고, 머리행은 `head`만 보고
|
| 185 | 187 |
제자리로 보낸다. 감싸면 표 안에 표 구조가 이중으로 생긴다. */} |
| 186 | 188 |
<FoxTable type="list" caption={caption}>
|
... | ... | @@ -227,7 +229,8 @@ |
| 227 | 229 |
</FoxTableRow> |
| 228 | 230 |
)) |
| 229 | 231 |
)} |
| 230 |
- </FoxTable> |
|
| 232 |
+ </FoxTable> |
|
| 233 |
+ </div> |
|
| 231 | 234 |
|
| 232 | 235 |
{totalPages > 1 && (
|
| 233 | 236 |
<FoxPagination |
--- @fox/styles/_fox-list-container.scss
+++ @fox/styles/_fox-list-container.scss
... | ... | @@ -23,4 +23,19 @@ |
| 23 | 23 |
inline-size: 100%; |
| 24 | 24 |
flex-direction: column; |
| 25 | 25 |
} |
| 26 |
+ |
|
| 27 |
+ // 열이 많은 표는 화면보다 넓다. 눌러 담으면 시안 폭이 무너지므로 여기서 가로로 스크롤한다. |
|
| 28 |
+ &__table {
|
|
| 29 |
+ inline-size: 100%; |
|
| 30 |
+ overflow-x: auto; |
|
| 31 |
+ } |
|
| 32 |
+} |
|
| 33 |
+ |
|
| 34 |
+// 표가 지정된 열 폭 아래로 눌리지 않게 한다 — 넓을 때만 넘치고, 좁으면 100%로 앉는다. |
|
| 35 |
+// 좁은 화면에서는 목록표가 카드로 바뀌어(FoxTable의 mobile 규칙) 스크롤이 필요 없으므로 |
|
| 36 |
+// PC에서만 건다. |
|
| 37 |
+@include fox.pc {
|
|
| 38 |
+ .fox-list-container__table > .fox-table {
|
|
| 39 |
+ min-inline-size: max-content; |
|
| 40 |
+ } |
|
| 26 | 41 |
} |
--- app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
... | ... | @@ -1,86 +0,0 @@ |
| 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_MEMBERS_EXCEL_PATH, | |
| 9 | - STUDENT_MEMBER_PAGE_SIZE_OPTIONS, | |
| 10 | - STUDENT_MEMBER_SORT_OPTIONS, | |
| 11 | - buildStudentMemberHref, | |
| 12 | - type StudentMemberPageSize, | |
| 13 | - type StudentMemberQuery, | |
| 14 | - type StudentMemberSortOption, | |
| 15 | -} from '@/lib/domain/student-member-query'; | |
| 16 | - | |
| 17 | -interface StudentListToolbarProps { | |
| 18 | - query: StudentMemberQuery; | |
| 19 | -} | |
| 20 | - | |
| 21 | -/** | |
| 22 | - * 목록 툴바 — 정렬/페이지 크기 select는 변경 즉시 URL을 갱신해 서버 데이터를 다시 조회하게 | |
| 23 | - * 한다(select의 onChange가 필요해 이 컴포넌트만 최말단 클라이언트 경계로 분리했다). 두 값 모두 | |
| 24 | - * 바뀌면 1페이지로 되돌린다(기존 페이지 번호가 새 정렬·크기 기준으로는 의미가 달라지므로). | |
| 25 | - * 엑셀다운로드는 이번 범위에서 버튼 배치만 하고 동작은 구현하지 않는다(사용자 지시) — | |
| 26 | - * disabled로 두어 클릭해도 아무 일도 일어나지 않게 한다. | |
| 27 | - * | |
| 28 | - * 정렬 select는 URL의 `sort`만 바꾸고, 그 값을 어떻게 만족시킬지는 Repository가 정한다 — | |
| 29 | - * 가입일순은 백엔드의 고정 순서와 같아 그대로 받아오고, 이름순은 백엔드에 정렬 파라미터가 없어 | |
| 30 | - * 전체를 받아 서버에서 정렬한다. 화면은 그 차이를 알 필요가 없다. | |
| 31 | - */ | |
| 32 | -export function StudentListToolbar({ query }: StudentListToolbarProps) { | |
| 33 | - const router = useRouter(); | |
| 34 | - | |
| 35 | - function handleSortChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 36 | - const sort = event.target.value as StudentMemberSortOption; | |
| 37 | - router.replace(buildStudentMemberHref(query, { sort, page: 1 })); | |
| 38 | - } | |
| 39 | - | |
| 40 | - function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 41 | - const pageSize = Number(event.target.value) as StudentMemberPageSize; | |
| 42 | - router.replace(buildStudentMemberHref(query, { pageSize, page: 1 })); | |
| 43 | - } | |
| 44 | - | |
| 45 | - return ( | |
| 46 | - <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 47 | - {/* 네이티브 GET 폼 제출 — 응답이 첨부파일(Content-Disposition: attachment)이라 브라우저가 | |
| 48 | - 현재 화면을 그대로 둔 채 파일만 내려받는다. 라우터 내비게이션(router.push)으로는 | |
| 49 | - 첨부파일 응답을 처리할 수 없고, Button은 <button>이라 링크로 쓸 수 없어 폼으로 감쌌다. | |
| 50 | - 검색·페이징 값을 hidden으로 싣지 않는 것이 사양이다 — 파일은 항상 전체 데이터다. */} | |
| 51 | - <form action={STUDENT_MEMBERS_EXCEL_PATH} method="get"> | |
| 52 | - <Button type="submit" variant="primary"> | |
| 53 | - 엑셀다운로드 | |
| 54 | - </Button> | |
| 55 | - </form> | |
| 56 | - | |
| 57 | - <div className="flex items-center gap-3"> | |
| 58 | - <Select | |
| 59 | - name="sort" | |
| 60 | - aria-label="정렬" | |
| 61 | - defaultValue={query.sort} | |
| 62 | - onChange={handleSortChange} | |
| 63 | - > | |
| 64 | - {STUDENT_MEMBER_SORT_OPTIONS.map((option) => ( | |
| 65 | - <option key={option.value} value={option.value}> | |
| 66 | - {option.label} | |
| 67 | - </option> | |
| 68 | - ))} | |
| 69 | - </Select> | |
| 70 | - | |
| 71 | - <Select | |
| 72 | - name="pageSize" | |
| 73 | - aria-label="페이지 크기" | |
| 74 | - defaultValue={query.pageSize} | |
| 75 | - onChange={handlePageSizeChange} | |
| 76 | - > | |
| 77 | - {STUDENT_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ( | |
| 78 | - <option key={size} value={size}> | |
| 79 | - {size}개씩 | |
| 80 | - </option> | |
| 81 | - ))} | |
| 82 | - </Select> | |
| 83 | - </div> | |
| 84 | - </div> | |
| 85 | - ); | |
| 86 | -} |
+++ app/(protected)/(basic)/students/_components/student-list.tsx
... | ... | @@ -0,0 +1,202 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 5 | +import { | |
| 6 | + FoxListContainer, | |
| 7 | + type FoxListColumn, | |
| 8 | +} from '@fox/core/components/fox-list-container'; | |
| 9 | +import { FoxSelectText } from '@fox/core/components/fox-select-text'; | |
| 10 | +import type { StudentMember } from '@/lib/domain/student-member'; | |
| 11 | +import { | |
| 12 | + STUDENT_MEMBERS_EXCEL_PATH, | |
| 13 | + STUDENT_MEMBER_PAGE_SIZE_OPTIONS, | |
| 14 | + STUDENT_MEMBER_SEARCH_FIELD_OPTIONS, | |
| 15 | + STUDENT_MEMBER_SORT_OPTIONS, | |
| 16 | + buildStudentMemberHref, | |
| 17 | + type StudentMemberPageSize, | |
| 18 | + type StudentMemberQuery, | |
| 19 | + type StudentMemberSearchField, | |
| 20 | + type StudentMemberSortOption, | |
| 21 | +} from '@/lib/domain/student-member-query'; | |
| 22 | +import { StudentRowActions } from './student-row-actions'; | |
| 23 | + | |
| 24 | +interface StudentListProps { | |
| 25 | + items: StudentMember[]; | |
| 26 | + query: StudentMemberQuery; | |
| 27 | + /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */ | |
| 28 | + currentPage: number; | |
| 29 | + totalPages: number; | |
| 30 | + totalCount: number; | |
| 31 | +} | |
| 32 | + | |
| 33 | +/** 값이 없으면 시안처럼 자리를 비우지 않고 `-`로 채운다. */ | |
| 34 | +function text(value: string | number | null | undefined) { | |
| 35 | + return value === null || value === undefined || value === '' ? '-' : String(value); | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** 학년·반·번호는 시안이 "2학년 5반 7번" 한 칸으로 묶는다. */ | |
| 39 | +function formatClass(member: StudentMember) { | |
| 40 | + const parts = [ | |
| 41 | + member.grade === null ? null : `${member.grade}학년`, | |
| 42 | + member.classNumber === null ? null : `${member.classNumber}반`, | |
| 43 | + member.studentNumber === null ? null : `${member.studentNumber}번`, | |
| 44 | + ].filter(Boolean); | |
| 45 | + return parts.length > 0 ? parts.join(' ') : '-'; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** | |
| 49 | + * 학생 회원 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container 5199:9014 | |
| 50 | + * | |
| 51 | + * 뼈대는 `FoxListContainer`가 전부 그린다. 이 파일이 갖는 것은 **이 화면 고유의 것**뿐이다 — | |
| 52 | + * 13개 열의 정의, 정렬·페이지 크기 셀렉트, 엑셀 다운로드, 그리고 URL을 갱신하는 방법. | |
| 53 | + * | |
| 54 | + * 이동은 두 갈래로 나뉜다. 페이지네이션은 `buildHref`로 **링크**가 되고(주소가 곧 상태라 | |
| 55 | + * 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간 | |
| 56 | + * `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다. | |
| 57 | + * | |
| 58 | + * 순번은 `query.page`가 아니라 `currentPage`를 기준으로 센다 — 백엔드가 범위를 벗어난 페이지 | |
| 59 | + * 요청을 잘라서 응답하면 둘이 어긋나, "1/1 페이지"인데 순번은 31부터 시작하는 상태가 된다. | |
| 60 | + */ | |
| 61 | +export function StudentList({ | |
| 62 | + items, | |
| 63 | + query, | |
| 64 | + currentPage, | |
| 65 | + totalPages, | |
| 66 | + totalCount, | |
| 67 | +}: StudentListProps) { | |
| 68 | + const router = useRouter(); | |
| 69 | + | |
| 70 | + /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */ | |
| 71 | + function go(patch: Partial<StudentMemberQuery>) { | |
| 72 | + router.replace(buildStudentMemberHref(query, { ...patch, page: 1 })); | |
| 73 | + } | |
| 74 | + | |
| 75 | + const columns: FoxListColumn<StudentMember>[] = [ | |
| 76 | + { | |
| 77 | + key: 'no', | |
| 78 | + header: '번호', | |
| 79 | + width: 80, | |
| 80 | + // 저장된 값이 아니라 현재 페이지 기준 표시 순번이다(종전 표와 같은 규칙). | |
| 81 | + render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1, | |
| 82 | + }, | |
| 83 | + { key: 'memberCode', header: '회원코드', width: 120 }, | |
| 84 | + { key: 'name', header: '이름', width: 120, emphasis: true }, | |
| 85 | + { key: 'loginId', header: '아이디', width: 160 }, | |
| 86 | + { | |
| 87 | + key: 'phoneNumber', | |
| 88 | + header: '휴대전화번호', | |
| 89 | + width: 160, | |
| 90 | + render: (row) => text(row.phoneNumber), | |
| 91 | + }, | |
| 92 | + { key: 'email', header: '이메일', width: 200, render: (row) => text(row.email) }, | |
| 93 | + { key: 'role', header: '역할', width: 120 }, | |
| 94 | + { | |
| 95 | + key: 'schoolName', | |
| 96 | + header: '학교', | |
| 97 | + width: 160, | |
| 98 | + render: (row) => text(row.schoolName), | |
| 99 | + }, | |
| 100 | + { key: 'classInfo', header: '학년/반/번호', width: 160, render: formatClass }, | |
| 101 | + { | |
| 102 | + key: 'guardianName', | |
| 103 | + header: '보호자이름', | |
| 104 | + width: 120, | |
| 105 | + render: (row) => text(row.guardianName), | |
| 106 | + }, | |
| 107 | + { | |
| 108 | + key: 'guardianPhoneNumber', | |
| 109 | + header: '보호자연락처', | |
| 110 | + width: 160, | |
| 111 | + render: (row) => text(row.guardianPhoneNumber), | |
| 112 | + }, | |
| 113 | + { | |
| 114 | + key: 'joinedAt', | |
| 115 | + header: '가입일', | |
| 116 | + width: 160, | |
| 117 | + render: (row) => text(row.joinedAt), | |
| 118 | + }, | |
| 119 | + { | |
| 120 | + key: 'actions', | |
| 121 | + header: '관리', | |
| 122 | + width: 160, | |
| 123 | + render: (row) => <StudentRowActions member={row} />, | |
| 124 | + }, | |
| 125 | + ]; | |
| 126 | + | |
| 127 | + return ( | |
| 128 | + <FoxListContainer<StudentMember> | |
| 129 | + title="학생 회원 목록" | |
| 130 | + breadcrumb={[ | |
| 131 | + { label: '홈', href: '/' }, | |
| 132 | + { label: '회원정보관리' }, | |
| 133 | + { label: '학생 회원' }, | |
| 134 | + ]} | |
| 135 | + caption="학생 회원 목록" | |
| 136 | + columns={columns} | |
| 137 | + rows={items} | |
| 138 | + rowKey={(row) => row.id} | |
| 139 | + totalCount={totalCount} | |
| 140 | + emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요." | |
| 141 | + sorts={ | |
| 142 | + <> | |
| 143 | + <FoxSelectText | |
| 144 | + size="sm" | |
| 145 | + ariaLabel="정렬" | |
| 146 | + options={STUDENT_MEMBER_SORT_OPTIONS.map((option) => ({ | |
| 147 | + value: option.value, | |
| 148 | + label: option.label, | |
| 149 | + }))} | |
| 150 | + value={query.sort} | |
| 151 | + onValueChange={(value) => go({ sort: value as StudentMemberSortOption })} | |
| 152 | + /> | |
| 153 | + <FoxSelectText | |
| 154 | + size="sm" | |
| 155 | + ariaLabel="페이지 크기" | |
| 156 | + options={STUDENT_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ({ | |
| 157 | + value: String(size), | |
| 158 | + label: `${size}개씩 보기`, | |
| 159 | + }))} | |
| 160 | + value={String(query.pageSize)} | |
| 161 | + onValueChange={(value) => | |
| 162 | + go({ pageSize: Number(value) as StudentMemberPageSize }) | |
| 163 | + } | |
| 164 | + /> | |
| 165 | + </> | |
| 166 | + } | |
| 167 | + searchFields={STUDENT_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ({ | |
| 168 | + value: option.value, | |
| 169 | + label: option.label, | |
| 170 | + }))} | |
| 171 | + searchField={query.searchField} | |
| 172 | + keyword={query.keyword} | |
| 173 | + toolbarActions={ | |
| 174 | + // 네이티브 GET 폼이라야 한다 — 응답이 첨부파일(Content-Disposition)이라 라우터 | |
| 175 | + // 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다. | |
| 176 | + // 검색·페이징 값을 싣지 않는 것이 사양이다(파일은 항상 전체 데이터). | |
| 177 | + <form action={STUDENT_MEMBERS_EXCEL_PATH} method="get"> | |
| 178 | + <FoxButton | |
| 179 | + type="primary" | |
| 180 | + size="md" | |
| 181 | + htmlType="submit" | |
| 182 | + label="엑셀 다운로드" | |
| 183 | + /> | |
| 184 | + </form> | |
| 185 | + } | |
| 186 | + page={currentPage} | |
| 187 | + totalPages={totalPages} | |
| 188 | + buildHref={(patch) => | |
| 189 | + buildStudentMemberHref(query, { page: Number(patch.page ?? 1) }) | |
| 190 | + } | |
| 191 | + onQueryChange={(patch) => { | |
| 192 | + if (patch.keyword !== undefined || patch.searchField !== undefined) { | |
| 193 | + go({ | |
| 194 | + keyword: String(patch.keyword ?? ''), | |
| 195 | + searchField: (patch.searchField ?? | |
| 196 | + query.searchField) as StudentMemberSearchField, | |
| 197 | + }); | |
| 198 | + } | |
| 199 | + }} | |
| 200 | + /> | |
| 201 | + ); | |
| 202 | +} |
--- app/(protected)/(basic)/students/_components/student-row-actions.tsx
+++ app/(protected)/(basic)/students/_components/student-row-actions.tsx
... | ... | @@ -1,8 +1,7 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useState } from 'react';
|
| 4 |
-import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
|
| 5 |
-import { FoxMagnifyingGlassIcon } from '@fox/core/icons';
|
|
| 4 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 6 | 5 |
import type { StudentMember } from '@/lib/domain/student-member';
|
| 7 | 6 |
import { StudentDetailModal } from './student-detail-modal';
|
| 8 | 7 |
|
... | ... | @@ -15,20 +14,19 @@ |
| 15 | 14 |
* 클라이언트로 내리지 않기 위해 이 셀만 분리했다 — §2.4 "'use client'는 최말단에만"). 팝업에 |
| 16 | 15 |
* 필요한 데이터는 이미 이 행이 갖고 있는 member를 그대로 전달한다(재조회 없음). |
| 17 | 16 |
* |
| 18 |
- * 시안의 "돋보기 아이콘 버튼"이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을 |
|
| 19 |
- * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로 |
|
| 20 |
- * 정의하는 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다). |
|
| 17 |
+ * 시안(관리자페이지 QsFVFFUKGH68xAPVkF3SJ9 5199:9014)의 관리 열은 "학생 회원 조회" 텍스트 |
|
| 18 |
+ * 버튼이다 — 앞서 기획 시안을 근거로 돋보기 아이콘을 넣었으나, 이 Figma 시안이 더 최신이라 |
|
| 19 |
+ * 그쪽으로 맞췄다(사용자 확정). 누르면 상세 팝업이 열리는 동작은 그대로다. |
|
| 21 | 20 |
*/ |
| 22 | 21 |
export function StudentRowActions({ member }: StudentRowActionsProps) {
|
| 23 | 22 |
const [isDetailOpen, setIsDetailOpen] = useState(false); |
| 24 | 23 |
|
| 25 | 24 |
return ( |
| 26 | 25 |
<> |
| 27 |
- <FoxIconButton |
|
| 28 |
- type="ghost" |
|
| 26 |
+ <FoxButton |
|
| 27 |
+ type="default" |
|
| 29 | 28 |
size="sm" |
| 30 |
- icon={<FoxMagnifyingGlassIcon />}
|
|
| 31 |
- label="조회" |
|
| 29 |
+ label="학생 회원 조회" |
|
| 32 | 30 |
onAction={() => setIsDetailOpen(true)}
|
| 33 | 31 |
/> |
| 34 | 32 |
{isDetailOpen && (
|
--- app/(protected)/(basic)/students/_components/student-search-bar.tsx
... | ... | @@ -1,72 +0,0 @@ |
| 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/_components/student-table.tsx
... | ... | @@ -1,86 +0,0 @@ |
| 1 | -import { | |
| 2 | - Table, | |
| 3 | - TableBody, | |
| 4 | - TableCell, | |
| 5 | - TableHead, | |
| 6 | - TableHeaderCell, | |
| 7 | - TableRow, | |
| 8 | -} from '@/components/ui/table'; | |
| 9 | -import { | |
| 10 | - formatGradeClassNumber, | |
| 11 | - formatOptionalValue, | |
| 12 | - type StudentMember, | |
| 13 | -} from '@/lib/domain/student-member'; | |
| 14 | -import { StudentRowActions } from './student-row-actions'; | |
| 15 | - | |
| 16 | -interface StudentTableProps { | |
| 17 | - items: StudentMember[]; | |
| 18 | - page: number; | |
| 19 | - pageSize: number; | |
| 20 | -} | |
| 21 | - | |
| 22 | -const COLUMN_HEADERS = [ | |
| 23 | - '번호', | |
| 24 | - '회원코드', | |
| 25 | - '이름', | |
| 26 | - '아이디', | |
| 27 | - '휴대전화번호', | |
| 28 | - '이메일', | |
| 29 | - '역할', | |
| 30 | - '학교', | |
| 31 | - '학년/반/번호', | |
| 32 | - '보호자이름', | |
| 33 | - '보호자연락처', | |
| 34 | - '가입일', | |
| 35 | - '관리', | |
| 36 | -]; | |
| 37 | - | |
| 38 | -/** | |
| 39 | - * 학생 회원 목록 테이블 — 조회 전용(등록/수정/삭제 없음). "번호"는 저장된 값이 아니라 | |
| 40 | - * 현재 페이지 기준 표시 순번(= (page-1)*pageSize + 행 인덱스 + 1)이다. 개인정보 마스킹은 | |
| 41 | - * 기획 시안에서 명시적으로 해제 지시가 있어 원본 값을 그대로 노출한다. | |
| 42 | - * | |
| 43 | - * 백엔드가 아직 주지 않는 항목(휴대전화·이메일·학교·학년/반/번호·보호자·가입일)은 열을 그대로 | |
| 44 | - * 유지한 채 `-`로 표시한다 — 백엔드가 필드를 추가하면 Repository 매핑만 늘리면 이 파일은 | |
| 45 | - * 그대로 값이 채워진다. | |
| 46 | - * | |
| 47 | - * "관리" 열은 행별 조회 팝업 트리거(StudentRowActions)에 위임한다 — 상호작용이 필요한 것은 | |
| 48 | - * 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 경계로 뗀다 | |
| 49 | - * (§2.4 "'use client'는 최말단에만"). | |
| 50 | - */ | |
| 51 | -export function StudentTable({ items, page, pageSize }: StudentTableProps) { | |
| 52 | - return ( | |
| 53 | - <Table> | |
| 54 | - <TableHead> | |
| 55 | - <TableRow> | |
| 56 | - {COLUMN_HEADERS.map((header) => ( | |
| 57 | - <TableHeaderCell key={header}>{header}</TableHeaderCell> | |
| 58 | - ))} | |
| 59 | - </TableRow> | |
| 60 | - </TableHead> | |
| 61 | - <TableBody> | |
| 62 | - {items.map((member, index) => ( | |
| 63 | - <TableRow key={member.id}> | |
| 64 | - <TableCell>{(page - 1) * pageSize + index + 1}</TableCell> | |
| 65 | - <TableCell>{member.memberCode}</TableCell> | |
| 66 | - <TableCell>{member.name}</TableCell> | |
| 67 | - <TableCell>{member.loginId}</TableCell> | |
| 68 | - <TableCell>{formatOptionalValue(member.phoneNumber)}</TableCell> | |
| 69 | - <TableCell>{formatOptionalValue(member.email)}</TableCell> | |
| 70 | - <TableCell>{member.role}</TableCell> | |
| 71 | - <TableCell>{formatOptionalValue(member.schoolName)}</TableCell> | |
| 72 | - <TableCell>{formatGradeClassNumber(member)}</TableCell> | |
| 73 | - <TableCell>{formatOptionalValue(member.guardianName)}</TableCell> | |
| 74 | - <TableCell> | |
| 75 | - {formatOptionalValue(member.guardianPhoneNumber)} | |
| 76 | - </TableCell> | |
| 77 | - <TableCell>{formatOptionalValue(member.joinedAt)}</TableCell> | |
| 78 | - <TableCell> | |
| 79 | - <StudentRowActions member={member} /> | |
| 80 | - </TableCell> | |
| 81 | - </TableRow> | |
| 82 | - ))} | |
| 83 | - </TableBody> | |
| 84 | - </Table> | |
| 85 | - ); | |
| 86 | -} |
--- app/(protected)/(basic)/students/page.tsx
+++ app/(protected)/(basic)/students/page.tsx
... | ... | @@ -1,17 +1,11 @@ |
| 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';
|
|
| 5 | 1 |
import { verifySession } from '@/lib/auth/dal';
|
| 6 | 2 |
import { fetchStudentMembers } from '@/lib/data/repositories/student-member-repository';
|
| 7 |
-import { buildStudentMemberHref, parseStudentMemberQuery } from '@/lib/domain/student-member-query';
|
|
| 8 |
-import { StudentListToolbar } from './_components/student-list-toolbar';
|
|
| 9 |
-import { StudentSearchBar } from './_components/student-search-bar';
|
|
| 10 |
-import { StudentTable } from './_components/student-table';
|
|
| 3 |
+import { parseStudentMemberQuery } from '@/lib/domain/student-member-query';
|
|
| 4 |
+import { StudentList } from './_components/student-list';
|
|
| 11 | 5 |
|
| 12 |
-export const metadata: Metadata = {
|
|
| 13 |
- title: '학생 회원', |
|
| 14 |
-}; |
|
| 6 |
+// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를 |
|
| 7 |
+// 원천 차단하기 위해 명시적으로 강제한다. |
|
| 8 |
+export const dynamic = 'force-dynamic'; |
|
| 15 | 9 |
|
| 16 | 10 |
interface PageProps {
|
| 17 | 11 |
searchParams: Promise<Record<string, string | string[] | undefined>>; |
... | ... | @@ -32,50 +26,12 @@ |
| 32 | 26 |
const currentPage = Math.min(query.page, totalPages); |
| 33 | 27 |
|
| 34 | 28 |
return ( |
| 35 |
- <section className="flex flex-1 flex-col gap-6 p-8"> |
|
| 36 |
- <h1 className="text-headline-sm text-foreground">학생 회원 목록</h1> |
|
| 37 |
- |
|
| 38 |
- <StudentSearchBar query={query} />
|
|
| 39 |
- |
|
| 40 |
- <StudentListToolbar query={query} />
|
|
| 41 |
- |
|
| 42 |
- <p className="text-body-md text-foreground-muted"> |
|
| 43 |
- 총 {totalCount}명{isTotalCountExact ? '' : ' 이상'} | 현재페이지{' '}
|
|
| 44 |
- {currentPage}
|
|
| 45 |
- {isTotalCountExact ? `/${totalPages}` : ''}
|
|
| 46 |
- </p> |
|
| 47 |
- |
|
| 48 |
- {items.length === 0 ? (
|
|
| 49 |
- <Alert |
|
| 50 |
- title="검색 결과가 없습니다." |
|
| 51 |
- actions={
|
|
| 52 |
- <Link |
|
| 53 |
- href={buildStudentMemberHref(query, { keyword: '', page: 1 })}
|
|
| 54 |
- className="bg-foreground px-4 py-2 text-background" |
|
| 55 |
- > |
|
| 56 |
- 확인 |
|
| 57 |
- </Link> |
|
| 58 |
- } |
|
| 59 |
- > |
|
| 60 |
- 다른 검색어로 다시 시도해 주세요. |
|
| 61 |
- </Alert> |
|
| 62 |
- ) : ( |
|
| 63 |
- <> |
|
| 64 |
- {/* 표의 순번 기준은 요청 페이지(query.page)가 아니라 화면이 실제로 보여주는
|
|
| 65 |
- 페이지(currentPage)다 — 백엔드가 범위를 벗어난 페이지 요청을 clamp해서 응답하면 |
|
| 66 |
- 둘이 어긋나 "현재페이지 1/1"인데 순번은 31부터 시작하는 상태가 된다. */} |
|
| 67 |
- <StudentTable |
|
| 68 |
- items={items}
|
|
| 69 |
- page={currentPage}
|
|
| 70 |
- pageSize={query.pageSize}
|
|
| 71 |
- /> |
|
| 72 |
- <Pagination |
|
| 73 |
- currentPage={currentPage}
|
|
| 74 |
- totalPages={totalPages}
|
|
| 75 |
- buildHref={(page: number) => buildStudentMemberHref(query, { page })}
|
|
| 76 |
- /> |
|
| 77 |
- </> |
|
| 78 |
- )} |
|
| 79 |
- </section> |
|
| 29 |
+ <StudentList |
|
| 30 |
+ items={items}
|
|
| 31 |
+ query={query}
|
|
| 32 |
+ currentPage={currentPage}
|
|
| 33 |
+ totalPages={totalPages}
|
|
| 34 |
+ totalCount={totalCount}
|
|
| 35 |
+ /> |
|
| 80 | 36 |
); |
| 81 | 37 |
} |
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?