'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 { formatPhoneNumber } from '@/lib/domain/phone-number';
import {
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: '이름', width: 120 },
{ key: 'loginId', header: '아이디', width: 160 },
{
key: 'phoneNumber',
header: '휴대전화번호',
width: 160,
render: (row) => text(formatPhoneNumber(row.phoneNumber)),
},
{ key: 'email', header: '이메일', width: 200, render: (row) => text(row.email) },
{
key: 'role',
header: '역할',
width: 120,
render: (row) => (
{row.role}
),
},
{
key: 'schoolName',
header: '학교',
width: 160,
render: (row) => text(row.schoolName),
},
{ key: 'classInfo', header: '학년/반/번호', width: 160, render: formatGradeClassNumber },
{
key: 'guardianName',
header: '보호자이름',
width: 120,
render: (row) => lines(row.guardians.map((guardian) => guardian.name)),
},
{
key: 'guardianPhoneNumber',
header: '보호자연락처',
width: 160,
render: (row) =>
lines(
row.guardians.map((guardian) => formatPhoneNumber(guardian.phoneNumber))
),
},
{
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={
<>
}
options={STUDENT_MEMBER_SORT_OPTIONS.map((option) => ({
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)이라 라우터
// 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다.
// 검색·페이징 값을 싣지 않는 것이 사양이다(파일은 항상 전체 데이터).
}
onRowSelect={(row) => setDetailMember(row)}
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,
});
}
}}
/>
{detailMember && (
setDetailMember(null)}
/>
)}
>
);
}