'use client'; import { useRouter } from 'next/navigation'; import { FoxButton } from '@fox/core/components/fox-button'; import { FoxListContainer, type FoxListColumn, } from '@fox/core/components/fox-list-container'; import { FoxSelectText } from '@fox/core/components/fox-select-text'; import type { StudentMember } from '@/lib/domain/student-member'; import { STUDENT_MEMBERS_EXCEL_PATH, STUDENT_MEMBER_PAGE_SIZE_OPTIONS, STUDENT_MEMBER_SEARCH_FIELD_OPTIONS, STUDENT_MEMBER_SORT_OPTIONS, buildStudentMemberHref, type StudentMemberPageSize, type StudentMemberQuery, type StudentMemberSearchField, type StudentMemberSortOption, } from '@/lib/domain/student-member-query'; import { StudentRowActions } from './student-row-actions'; interface StudentListProps { items: StudentMember[]; query: StudentMemberQuery; /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */ currentPage: number; totalPages: number; totalCount: number; } /** 값이 없으면 시안처럼 자리를 비우지 않고 `-`로 채운다. */ function text(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '-' : String(value); } /** 학년·반·번호는 시안이 "2학년 5반 7번" 한 칸으로 묶는다. */ function formatClass(member: StudentMember) { const parts = [ member.grade === null ? null : `${member.grade}학년`, member.classNumber === null ? null : `${member.classNumber}반`, member.studentNumber === null ? null : `${member.studentNumber}번`, ].filter(Boolean); return parts.length > 0 ? parts.join(' ') : '-'; } /** * 학생 회원 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container 5199:9014 * * 뼈대는 `FoxListContainer`가 전부 그린다. 이 파일이 갖는 것은 **이 화면 고유의 것**뿐이다 — * 13개 열의 정의, 정렬·페이지 크기 셀렉트, 엑셀 다운로드, 그리고 URL을 갱신하는 방법. * * 이동은 두 갈래로 나뉜다. 페이지네이션은 `buildHref`로 **링크**가 되고(주소가 곧 상태라 * 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간 * `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다. * * 순번은 `query.page`가 아니라 `currentPage`를 기준으로 센다 — 백엔드가 범위를 벗어난 페이지 * 요청을 잘라서 응답하면 둘이 어긋나, "1/1 페이지"인데 순번은 31부터 시작하는 상태가 된다. */ export function StudentList({ items, query, currentPage, totalPages, totalCount, }: StudentListProps) { const router = useRouter(); /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */ function go(patch: Partial) { router.replace(buildStudentMemberHref(query, { ...patch, page: 1 })); } const columns: FoxListColumn[] = [ { key: 'no', header: '번호', width: 80, // 저장된 값이 아니라 현재 페이지 기준 표시 순번이다(종전 표와 같은 규칙). render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1, }, { key: 'memberCode', header: '회원코드', width: 120 }, { key: 'name', header: '이름', width: 120, emphasis: true }, { key: 'loginId', header: '아이디', width: 160 }, { key: 'phoneNumber', header: '휴대전화번호', width: 160, render: (row) => text(row.phoneNumber), }, { key: 'email', header: '이메일', width: 200, render: (row) => text(row.email) }, { key: 'role', header: '역할', width: 120 }, { key: 'schoolName', header: '학교', width: 160, render: (row) => text(row.schoolName), }, { key: 'classInfo', header: '학년/반/번호', width: 160, render: formatClass }, { key: 'guardianName', header: '보호자이름', width: 120, render: (row) => text(row.guardianName), }, { key: 'guardianPhoneNumber', header: '보호자연락처', width: 160, render: (row) => text(row.guardianPhoneNumber), }, { key: 'joinedAt', header: '가입일', width: 160, render: (row) => text(row.joinedAt), }, { key: 'actions', header: '관리', width: 160, render: (row) => , }, ]; return ( title="학생 회원 목록" breadcrumb={[ { label: '홈', href: '/' }, { label: '회원정보관리' }, { label: '학생 회원' }, ]} caption="학생 회원 목록" columns={columns} rows={items} rowKey={(row) => row.id} totalCount={totalCount} emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요." sorts={ <> ({ value: option.value, label: option.label, }))} value={query.sort} onValueChange={(value) => go({ sort: value as StudentMemberSortOption })} /> ({ value: String(size), label: `${size}개씩 보기`, }))} value={String(query.pageSize)} onValueChange={(value) => go({ pageSize: Number(value) as StudentMemberPageSize }) } /> } searchFields={STUDENT_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ({ value: option.value, label: option.label, }))} searchField={query.searchField} keyword={query.keyword} toolbarActions={ // 네이티브 GET 폼이라야 한다 — 응답이 첨부파일(Content-Disposition)이라 라우터 // 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다. // 검색·페이징 값을 싣지 않는 것이 사양이다(파일은 항상 전체 데이터).
} page={currentPage} totalPages={totalPages} buildHref={(patch) => buildStudentMemberHref(query, { page: Number(patch.page ?? 1) }) } onQueryChange={(patch) => { if (patch.keyword !== undefined || patch.searchField !== undefined) { go({ keyword: String(patch.keyword ?? ''), searchField: (patch.searchField ?? query.searchField) as StudentMemberSearchField, }); } }} /> ); }