'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 = { 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) { router.replace(buildAdminMemberHref(query, { ...patch, page: 1 })); } const offset = (currentPage - 1) * query.pageSize; const columns: FoxListColumn[] = [ { 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) => ( {formatAdminRoleLabel(row.roleCode)} ), }, { key: 'createdAt', header: '생성일', width: 160, render: (row) => formatOptionalValue(row.createdAt), }, { key: 'actions', header: '관리', width: 160, render: (row) => ( ), }, ]; return ( <> title="관리자 회원 목록" breadcrumb={[ { label: '홈', href: '/' }, { label: '관리자정보관리' }, { label: '관리자 회원' }, ]} caption="관리자 회원 목록" columns={columns} rows={items} rowKey={(row) => row.id} totalCount={totalCount} emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요." sorts={ <> } options={ADMIN_MEMBER_SORT_OPTIONS.map((option) => ({ value: option.value, label: option.label, }))} value={query.sort} onValueChange={(value) => go({ sort: value as AdminMemberSortOption })} /> ({ 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={
} label="엑셀 다운로드" /> } label="신규 등록" onAction={() => setIsCreateOpen(true)} />
} 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 && ( setIsCreateOpen(false)} /> )} ); }