File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'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<StudentMemberQuery>) {
router.replace(buildStudentMemberHref(query, { ...patch, page: 1 }));
}
const columns: FoxListColumn<StudentMember>[] = [
{
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) => <StudentRowActions member={row} />,
},
];
return (
<FoxListContainer<StudentMember>
title="학생 회원 목록"
breadcrumb={[
{ label: '홈', href: '/' },
{ label: '회원정보관리' },
{ label: '학생 회원' },
]}
caption="학생 회원 목록"
columns={columns}
rows={items}
rowKey={(row) => row.id}
totalCount={totalCount}
emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요."
sorts={
<>
<FoxSelectText
size="sm"
ariaLabel="정렬"
options={STUDENT_MEMBER_SORT_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={query.sort}
onValueChange={(value) => go({ sort: value as StudentMemberSortOption })}
/>
<FoxSelectText
size="sm"
ariaLabel="페이지 크기"
options={STUDENT_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ({
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)이라 라우터
// 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다.
// 검색·페이징 값을 싣지 않는 것이 사양이다(파일은 항상 전체 데이터).
<form action={STUDENT_MEMBERS_EXCEL_PATH} method="get">
<FoxButton
type="primary"
size="md"
htmlType="submit"
label="엑셀 다운로드"
/>
</form>
}
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,
});
}
}}
/>
);
}