'use client'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { FoxBadge } from '@fox/core/components/fox-badge'; 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 { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator'; import { formatPhoneNumber } from '@/lib/domain/phone-number'; import { formatActiveStatusLabel, formatGradeClassNumber, 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 { StudentDetailModal } from './student-detail-modal'; 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); } /** 보호자가 여럿이면 한 칸 안에서 줄을 나눈다. 없으면 `-` 한 줄이다. */ function lines(values: (string | null)[]) { if (values.length === 0) { return text(null); } return values.map((value, index) =>
{text(value)}
); } /** * 학생 회원 목록 — 시안: 관리자페이지(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(); const [detailMember, setDetailMember] = useState(null); /** 목록 조건이 바뀌면 늘 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: 'name', header: '이름', align: 'start', width: 120 }, { key: 'loginId', header: '아이디', align: 'start', width: 160 }, { key: 'phoneNumber', header: '휴대전화번호', align: 'start', width: 160, render: (row) => text(formatPhoneNumber(row.phoneNumber)), }, { key: 'email', header: '이메일', align: 'start', width: 200, render: (row) => text(row.email) }, { key: 'role', header: '역할', width: 120, render: (row) => ( {row.role} ), }, { key: 'schoolName', header: '학교', align: 'start', width: 160, render: (row) => text(row.schoolName), }, { key: 'classInfo', header: '학년/반/번호', width: 160, render: formatGradeClassNumber }, { key: 'guardianName', header: '보호자이름', align: 'start', width: 120, render: (row) => lines(row.guardians.map((guardian) => guardian.name)), }, { key: 'guardianPhoneNumber', header: '보호자연락처', align: 'start', width: 160, render: (row) => lines( row.guardians.map((guardian) => formatPhoneNumber(guardian.phoneNumber)) ), }, { key: 'isActive', header: '사용여부', width: 120, render: (row) => row.isActive === null ? ( formatActiveStatusLabel(row.isActive) ) : ( ), }, { key: 'joinedAt', header: '가입일', width: 160, render: (row) => text(row.joinedAt), }, ]; return ( <> title="학생 회원 목록" breadcrumb={[ { label: '홈', href: '/' }, { label: '회원관리' }, { label: '학생 회원' }, ]} caption="학생 회원 목록" columns={columns} rows={items} rowKey={(row) => row.id} totalCount={totalCount} emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요." sorts={ <>