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 { useState } from 'react';
import { FoxBadge, type FoxBadgeColor } from '@fox/core/components/fox-badge';
import { FoxButton } from '@fox/core/components/fox-button';
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
import {
FoxListContainer,
type FoxListColumn,
} from '@fox/core/components/fox-list-container';
import { FoxSelectText } from '@fox/core/components/fox-select-text';
import {
FoxArrowsDownUpIcon,
FoxDownloadSimpleIcon,
FoxPlusIcon,
} from '@fox/core/icons';
import {
formatAdminRoleLabel,
formatOptionalValue,
type AdminMember,
} from '@/lib/domain/admin-member';
import {
ADMIN_MEMBERS_EXCEL_PATH,
ADMIN_MEMBER_PAGE_SIZE_OPTIONS,
ADMIN_MEMBER_SEARCH_FIELD_OPTIONS,
ADMIN_MEMBER_SORT_OPTIONS,
buildAdminMemberHref,
type AdminMemberPageSize,
type AdminMemberQuery,
type AdminMemberSearchField,
type AdminMemberSortOption,
} from '@/lib/domain/admin-member-query';
import { AdminMemberCreateModal } from './admin-member-create-modal';
import { AdminMemberRowActions } from './admin-member-row-actions';
interface AdminMemberListProps {
items: AdminMember[];
query: AdminMemberQuery;
/** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */
currentPage: number;
totalPages: number;
totalCount: number;
/** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */
currentAdminId: string;
}
/** 폭이 고정되지 않은 세 열이 나눠 갖는 몫 — 시안 1552px 기준 (1552 - 고정열 720) / 3. */
const FILL_COLUMN_WIDTH = 277;
/**
* 역할 배지의 색. 시안이 최고관리자는 accent(붉은 계열), 그 외는 primary(파란 계열)로
* 구분한다 — 색은 시각 결정이라 도메인(`admin-member.ts`)이 아니라 여기가 갖는다.
* 모르는 코드는 이름을 지어내지 않듯 색도 입히지 않는다(neutral).
*/
const BADGE_COLOR_BY_ROLE: Record<string, FoxBadgeColor> = {
ROLE_SYSTEM: 'accent',
ROLE_ADMIN: 'primary',
};
/**
* 관리자 회원 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_101 (5227:1545)
*
* 뼈대는 `FoxListContainer`가 전부 그린다(학생 회원 목록과 같은 구성). 이 파일이 갖는 것은
* 이 화면 고유의 것뿐이다 — 8개 열의 정의, 정렬·페이지 크기 셀렉트, 툴바 버튼 두 개,
* 그리고 URL을 갱신하는 방법.
*
* 이동은 두 갈래로 나뉜다. 페이지네이션은 `buildHref`로 **링크**가 되고(주소가 곧 상태라
* 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간
* `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다.
*
* [엑셀 다운로드]는 네이티브 GET 폼이다 — 응답이 첨부파일(Content-Disposition)이라 라우터
* 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다. 검색·페이징 값을
* 싣지 않는 것이 사양이다(파일은 항상 전체 데이터).
*/
export function AdminMemberList({
items,
query,
currentPage,
totalPages,
totalCount,
currentAdminId,
}: AdminMemberListProps) {
const router = useRouter();
const [isCreateOpen, setIsCreateOpen] = useState(false);
/** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */
function go(patch: Partial<AdminMemberQuery>) {
router.replace(buildAdminMemberHref(query, { ...patch, page: 1 }));
}
const offset = (currentPage - 1) * query.pageSize;
const columns: FoxListColumn<AdminMember>[] = [
{
key: 'no',
header: '번호',
width: 80,
// 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다 — 기본 정렬이 생성일
// 최신순이라 "가장 최근에 만든 계정이 가장 큰 번호"가 된다.
render: (_row, index) => totalCount - offset - index,
},
// 이름·아이디·이메일은 시안에서 남는 폭을 똑같이 나눠 갖는다(1552 기준 각 277). 폭을
// 비워 두면 표가 글자 길이대로 나눠 이메일 열만 넓어져 시안과 어긋난다.
{ key: 'name', header: '이름', width: FILL_COLUMN_WIDTH },
{ key: 'loginId', header: '아이디', width: FILL_COLUMN_WIDTH },
{
key: 'phoneNumber',
header: '휴대전화번호',
width: 200,
render: (row) => formatOptionalValue(row.phoneNumber),
},
{
key: 'email',
header: '이메일',
width: FILL_COLUMN_WIDTH,
render: (row) => formatOptionalValue(row.email),
},
{
key: 'role',
header: '역할',
width: 120,
render: (row) => (
<FoxBadge
type="pastel"
color={BADGE_COLOR_BY_ROLE[row.roleCode] ?? 'neutral'}
size="md"
>
{formatAdminRoleLabel(row.roleCode)}
</FoxBadge>
),
},
{
key: 'createdAt',
header: '생성일',
width: 160,
render: (row) => formatOptionalValue(row.createdAt),
},
{
key: 'actions',
header: '관리',
width: 160,
render: (row) => (
<AdminMemberRowActions
member={row}
isSelf={row.id === currentAdminId}
/>
),
},
];
return (
<>
<FoxListContainer<AdminMember>
title="관리자 회원 목록"
breadcrumb={[
{ label: '홈', href: '/' },
{ label: '관리자정보관리' },
{ label: '관리자 회원' },
]}
caption="관리자 회원 목록"
columns={columns}
rows={items}
rowKey={(row) => row.id}
totalCount={totalCount}
emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요."
sorts={
<>
<FoxSelectText
size="sm"
ariaLabel="정렬"
icon={<FoxArrowsDownUpIcon />}
options={ADMIN_MEMBER_SORT_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={query.sort}
onValueChange={(value) => go({ sort: value as AdminMemberSortOption })}
/>
<FoxSelectText
size="sm"
ariaLabel="페이지 크기"
options={ADMIN_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ({
value: String(size),
label: `${size}개씩 보기`,
}))}
value={String(query.pageSize)}
onValueChange={(value) =>
go({ pageSize: Number(value) as AdminMemberPageSize })
}
/>
</>
}
searchFields={ADMIN_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
searchField={query.searchField}
keyword={query.keyword}
toolbarActions={
<FoxButtonGroup size="md">
<form action={ADMIN_MEMBERS_EXCEL_PATH} method="get">
<FoxButton
type="secondary"
size="md"
htmlType="submit"
leadingIcon={<FoxDownloadSimpleIcon />}
label="엑셀 다운로드"
/>
</form>
<FoxButton
type="primary"
size="md"
leadingIcon={<FoxPlusIcon />}
label="신규 등록"
onAction={() => setIsCreateOpen(true)}
/>
</FoxButtonGroup>
}
page={currentPage}
totalPages={totalPages}
buildHref={(patch) =>
buildAdminMemberHref(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 AdminMemberSearchField,
});
}
}}
/>
{isCreateOpen && (
<AdminMemberCreateModal onClose={() => setIsCreateOpen(false)} />
)}
</>
);
}