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_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;
}
const EXCEL_UNSUPPORTED_NOTE = '엑셀 다운로드는 추후 제공됩니다.';
/** 폭이 고정되지 않은 세 열이 나눠 갖는 몫 — 시안 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`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다.
*
* **[엑셀 다운로드]는 자리만 잡고 비활성이다**(사용자 확정). 백엔드에 관리자 엑셀 API가 없다
* — `/api/v1/mngr/admin/**`에는 목록 조회 하나뿐이라, 학생 목록처럼 라우트 핸들러로 중계할
* 대상이 없다. 비활성 버튼은 스스로 hover 이벤트를 받지 못해 안내는 `<span title>`이 든다.
*/
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">
<span title={EXCEL_UNSUPPORTED_NOTE}>
<FoxButton
type="secondary"
size="md"
disabled
leadingIcon={<FoxDownloadSimpleIcon />}
label="엑셀 다운로드"
/>
</span>
<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)} />
)}
</>
);
}