--- @fox/core/components/fox-list-container/fox-list-container.tsx
+++ @fox/core/components/fox-list-container/fox-list-container.tsx
... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 |
"use client"; |
| 2 | 2 |
|
| 3 |
-import type { ReactNode } from "react";
|
|
| 3 |
+import { useState, type ReactNode } from "react";
|
|
| 4 | 4 |
import { cx } from "../../utils";
|
| 5 | 5 |
import { FoxPageHeader } from "../fox-page-header";
|
| 6 | 6 |
import type { FoxBreadcrumbItem } from "../fox-breadcrumb";
|
... | ... | @@ -64,6 +64,7 @@ |
| 64 | 64 |
|
| 65 | 65 |
/** 검색 대상 목록. 주면 검색 상자가 나온다. 대상이 하나뿐이면 빈 배열로 두면 된다. */ |
| 66 | 66 |
searchFields?: FoxSelectItem[]; |
| 67 |
+ /** 지금 **적용된** 검색 조건이다(입력 중인 값이 아니다). 초안은 컨테이너가 따로 든다. */ |
|
| 67 | 68 |
searchField?: string; |
| 68 | 69 |
keyword?: string; |
| 69 | 70 |
searchPlaceholder?: string; |
... | ... | @@ -129,6 +130,24 @@ |
| 129 | 130 |
hidden = false, |
| 130 | 131 |
className, |
| 131 | 132 |
}: FoxListContainerProps<T>) {
|
| 133 |
+ // 검색 상자는 **누르기 전까지의 입력 초안**을 여기서 든다. `keyword`·`searchField`는 이미 |
|
| 134 |
+ // 검색이 반영된 결과(주소·서버 응답)라, 그대로 FoxListSearch에 제어 값으로 내려보내면 |
|
| 135 |
+ // 변경 핸들러가 없어 글자도 검색 대상도 바뀌지 않는다. |
|
| 136 |
+ const applied = { keyword: keyword ?? "", field: searchField };
|
|
| 137 |
+ const [draft, setDraft] = useState(applied); |
|
| 138 |
+ const [lastApplied, setLastApplied] = useState(applied); |
|
| 139 |
+ |
|
| 140 |
+ // 검색이 끝나 밖의 값이 바뀌면 초안을 그 값으로 되돌린다 — 뒤로가기·초기화처럼 화면이 아니라 |
|
| 141 |
+ // 주소가 검색어를 바꾸는 경로에서도 입력창이 따라간다. effect가 아니라 렌더 중에 맞추는 것은 |
|
| 142 |
+ // React가 권하는 방식이다(한 번 더 그리지 않고 이 렌더에 반영된다). |
|
| 143 |
+ if ( |
|
| 144 |
+ lastApplied.keyword !== applied.keyword || |
|
| 145 |
+ lastApplied.field !== applied.field |
|
| 146 |
+ ) {
|
|
| 147 |
+ setLastApplied(applied); |
|
| 148 |
+ setDraft(applied); |
|
| 149 |
+ } |
|
| 150 |
+ |
|
| 132 | 151 |
if (hidden) {
|
| 133 | 152 |
return null; |
| 134 | 153 |
} |
... | ... | @@ -165,8 +184,14 @@ |
| 165 | 184 |
searchHidden ? undefined : ( |
| 166 | 185 |
<FoxListSearch |
| 167 | 186 |
fields={searchFields}
|
| 168 |
- field={searchField}
|
|
| 169 |
- keyword={keyword}
|
|
| 187 |
+ field={draft.field}
|
|
| 188 |
+ onFieldChange={(next) =>
|
|
| 189 |
+ setDraft((current) => ({ ...current, field: next }))
|
|
| 190 |
+ } |
|
| 191 |
+ keyword={draft.keyword}
|
|
| 192 |
+ onKeywordChange={(next) =>
|
|
| 193 |
+ setDraft((current) => ({ ...current, keyword: next }))
|
|
| 194 |
+ } |
|
| 170 | 195 |
placeholder={searchPlaceholder}
|
| 171 | 196 |
onSearch={(nextKeyword, nextField) =>
|
| 172 | 197 |
onQueryChange?.({
|
--- app/(protected)/(basic)/admins/_components/admin-member-list-toolbar.tsx
... | ... | @@ -1,99 +0,0 @@ |
| 1 | -'use client'; | |
| 2 | - | |
| 3 | -import { useRouter } from 'next/navigation'; | |
| 4 | -import { useState, type ChangeEvent } from 'react'; | |
| 5 | -import { Button } from '@/components/ui/button'; | |
| 6 | -import { Select } from '@/components/ui/select'; | |
| 7 | -import { | |
| 8 | - ADMIN_MEMBER_PAGE_SIZE_OPTIONS, | |
| 9 | - ADMIN_MEMBER_SORT_OPTIONS, | |
| 10 | - buildAdminMemberHref, | |
| 11 | - type AdminMemberPageSize, | |
| 12 | - type AdminMemberQuery, | |
| 13 | - type AdminMemberSortOption, | |
| 14 | -} from '@/lib/domain/admin-member-query'; | |
| 15 | -import { AdminMemberCreateModal } from './admin-member-create-modal'; | |
| 16 | - | |
| 17 | -interface AdminMemberListToolbarProps { | |
| 18 | - query: AdminMemberQuery; | |
| 19 | -} | |
| 20 | - | |
| 21 | -const EXCEL_UNSUPPORTED_NOTE = '엑셀 다운로드는 추후 제공됩니다.'; | |
| 22 | - | |
| 23 | -/** | |
| 24 | - * 목록 툴바(시안 ADM_ADM_101 ②③) — 정렬/페이지 크기 select는 변경 즉시 URL을 갱신해 서버 | |
| 25 | - * 데이터를 다시 조회하게 한다. 두 값 모두 바뀌면 1페이지로 되돌린다(기존 페이지 번호가 새 정렬· | |
| 26 | - * 크기 기준으로는 의미가 달라지므로). | |
| 27 | - * | |
| 28 | - * **엑셀다운로드는 버튼만 배치하고 동작은 구현하지 않는다**(사용자 지시 — 추후 진행). | |
| 29 | - * disabled로 두어 클릭해도 아무 일도 일어나지 않게 한다. 학생 회원 목록처럼 백엔드에 엑셀 API가 | |
| 30 | - * 있으면 라우트 핸들러로 중계하면 되지만, 관리자 쪽에는 그 API가 아직 없다 | |
| 31 | - * (`/api/v1/mngr/admin/**`에 목록 조회 하나뿐). | |
| 32 | - * | |
| 33 | - * [신규 등록]은 시안대로 등록 팝업을 연다 — 팝업 열림 상태를 이 컴포넌트가 소유한다. | |
| 34 | - */ | |
| 35 | -export function AdminMemberListToolbar({ query }: AdminMemberListToolbarProps) { | |
| 36 | - const router = useRouter(); | |
| 37 | - const [isCreateOpen, setIsCreateOpen] = useState(false); | |
| 38 | - | |
| 39 | - function handleSortChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 40 | - const sort = event.target.value as AdminMemberSortOption; | |
| 41 | - router.replace(buildAdminMemberHref(query, { sort, page: 1 })); | |
| 42 | - } | |
| 43 | - | |
| 44 | - function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 45 | - const pageSize = Number(event.target.value) as AdminMemberPageSize; | |
| 46 | - router.replace(buildAdminMemberHref(query, { pageSize, page: 1 })); | |
| 47 | - } | |
| 48 | - | |
| 49 | - return ( | |
| 50 | - <div className="flex flex-wrap items-center justify-end gap-3"> | |
| 51 | - <Button | |
| 52 | - type="button" | |
| 53 | - variant="success" | |
| 54 | - disabled | |
| 55 | - title={EXCEL_UNSUPPORTED_NOTE} | |
| 56 | - > | |
| 57 | - 엑셀다운로드 | |
| 58 | - </Button> | |
| 59 | - | |
| 60 | - <Button | |
| 61 | - type="button" | |
| 62 | - variant="primary" | |
| 63 | - onClick={() => setIsCreateOpen(true)} | |
| 64 | - > | |
| 65 | - 신규 등록 | |
| 66 | - </Button> | |
| 67 | - | |
| 68 | - <Select | |
| 69 | - name="sort" | |
| 70 | - aria-label="정렬" | |
| 71 | - defaultValue={query.sort} | |
| 72 | - onChange={handleSortChange} | |
| 73 | - > | |
| 74 | - {ADMIN_MEMBER_SORT_OPTIONS.map((option) => ( | |
| 75 | - <option key={option.value} value={option.value}> | |
| 76 | - {option.label} | |
| 77 | - </option> | |
| 78 | - ))} | |
| 79 | - </Select> | |
| 80 | - | |
| 81 | - <Select | |
| 82 | - name="pageSize" | |
| 83 | - aria-label="페이지 크기" | |
| 84 | - defaultValue={query.pageSize} | |
| 85 | - onChange={handlePageSizeChange} | |
| 86 | - > | |
| 87 | - {ADMIN_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ( | |
| 88 | - <option key={size} value={size}> | |
| 89 | - {size}개씩 | |
| 90 | - </option> | |
| 91 | - ))} | |
| 92 | - </Select> | |
| 93 | - | |
| 94 | - {isCreateOpen && ( | |
| 95 | - <AdminMemberCreateModal onClose={() => setIsCreateOpen(false)} /> | |
| 96 | - )} | |
| 97 | - </div> | |
| 98 | - ); | |
| 99 | -} |
+++ app/(protected)/(basic)/admins/_components/admin-member-list.tsx
... | ... | @@ -0,0 +1,241 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useState } from 'react'; | |
| 5 | +import { FoxBadge, type FoxBadgeColor } from '@fox/core/components/fox-badge'; | |
| 6 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 7 | +import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; | |
| 8 | +import { | |
| 9 | + FoxListContainer, | |
| 10 | + type FoxListColumn, | |
| 11 | +} from '@fox/core/components/fox-list-container'; | |
| 12 | +import { FoxSelectText } from '@fox/core/components/fox-select-text'; | |
| 13 | +import { | |
| 14 | + FoxArrowsDownUpIcon, | |
| 15 | + FoxDownloadSimpleIcon, | |
| 16 | + FoxPlusIcon, | |
| 17 | +} from '@fox/core/icons'; | |
| 18 | +import { | |
| 19 | + formatAdminRoleLabel, | |
| 20 | + formatOptionalValue, | |
| 21 | + type AdminMember, | |
| 22 | +} from '@/lib/domain/admin-member'; | |
| 23 | +import { | |
| 24 | + ADMIN_MEMBER_PAGE_SIZE_OPTIONS, | |
| 25 | + ADMIN_MEMBER_SEARCH_FIELD_OPTIONS, | |
| 26 | + ADMIN_MEMBER_SORT_OPTIONS, | |
| 27 | + buildAdminMemberHref, | |
| 28 | + type AdminMemberPageSize, | |
| 29 | + type AdminMemberQuery, | |
| 30 | + type AdminMemberSearchField, | |
| 31 | + type AdminMemberSortOption, | |
| 32 | +} from '@/lib/domain/admin-member-query'; | |
| 33 | +import { AdminMemberCreateModal } from './admin-member-create-modal'; | |
| 34 | +import { AdminMemberRowActions } from './admin-member-row-actions'; | |
| 35 | + | |
| 36 | +interface AdminMemberListProps { | |
| 37 | + items: AdminMember[]; | |
| 38 | + query: AdminMemberQuery; | |
| 39 | + /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */ | |
| 40 | + currentPage: number; | |
| 41 | + totalPages: number; | |
| 42 | + totalCount: number; | |
| 43 | + /** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */ | |
| 44 | + currentAdminId: string; | |
| 45 | +} | |
| 46 | + | |
| 47 | +const EXCEL_UNSUPPORTED_NOTE = '엑셀 다운로드는 추후 제공됩니다.'; | |
| 48 | + | |
| 49 | +/** 폭이 고정되지 않은 세 열이 나눠 갖는 몫 — 시안 1552px 기준 (1552 - 고정열 720) / 3. */ | |
| 50 | +const FILL_COLUMN_WIDTH = 277; | |
| 51 | + | |
| 52 | +/** | |
| 53 | + * 역할 배지의 색. 시안이 최고관리자는 accent(붉은 계열), 그 외는 primary(파란 계열)로 | |
| 54 | + * 구분한다 — 색은 시각 결정이라 도메인(`admin-member.ts`)이 아니라 여기가 갖는다. | |
| 55 | + * 모르는 코드는 이름을 지어내지 않듯 색도 입히지 않는다(neutral). | |
| 56 | + */ | |
| 57 | +const BADGE_COLOR_BY_ROLE: Record<string, FoxBadgeColor> = { | |
| 58 | + ROLE_SYSTEM: 'accent', | |
| 59 | + ROLE_ADMIN: 'primary', | |
| 60 | +}; | |
| 61 | + | |
| 62 | +/** | |
| 63 | + * 관리자 회원 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_101 (5227:1545) | |
| 64 | + * | |
| 65 | + * 뼈대는 `FoxListContainer`가 전부 그린다(학생 회원 목록과 같은 구성). 이 파일이 갖는 것은 | |
| 66 | + * 이 화면 고유의 것뿐이다 — 8개 열의 정의, 정렬·페이지 크기 셀렉트, 툴바 버튼 두 개, | |
| 67 | + * 그리고 URL을 갱신하는 방법. | |
| 68 | + * | |
| 69 | + * 이동은 두 갈래로 나뉜다. 페이지네이션은 `buildHref`로 **링크**가 되고(주소가 곧 상태라 | |
| 70 | + * 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간 | |
| 71 | + * `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다. | |
| 72 | + * | |
| 73 | + * **[엑셀 다운로드]는 자리만 잡고 비활성이다**(사용자 확정). 백엔드에 관리자 엑셀 API가 없다 | |
| 74 | + * — `/api/v1/mngr/admin/**`에는 목록 조회 하나뿐이라, 학생 목록처럼 라우트 핸들러로 중계할 | |
| 75 | + * 대상이 없다. 비활성 버튼은 스스로 hover 이벤트를 받지 못해 안내는 `<span title>`이 든다. | |
| 76 | + */ | |
| 77 | +export function AdminMemberList({ | |
| 78 | + items, | |
| 79 | + query, | |
| 80 | + currentPage, | |
| 81 | + totalPages, | |
| 82 | + totalCount, | |
| 83 | + currentAdminId, | |
| 84 | +}: AdminMemberListProps) { | |
| 85 | + const router = useRouter(); | |
| 86 | + const [isCreateOpen, setIsCreateOpen] = useState(false); | |
| 87 | + | |
| 88 | + /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */ | |
| 89 | + function go(patch: Partial<AdminMemberQuery>) { | |
| 90 | + router.replace(buildAdminMemberHref(query, { ...patch, page: 1 })); | |
| 91 | + } | |
| 92 | + | |
| 93 | + const offset = (currentPage - 1) * query.pageSize; | |
| 94 | + | |
| 95 | + const columns: FoxListColumn<AdminMember>[] = [ | |
| 96 | + { | |
| 97 | + key: 'no', | |
| 98 | + header: '번호', | |
| 99 | + width: 80, | |
| 100 | + // 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다 — 기본 정렬이 생성일 | |
| 101 | + // 최신순이라 "가장 최근에 만든 계정이 가장 큰 번호"가 된다. | |
| 102 | + render: (_row, index) => totalCount - offset - index, | |
| 103 | + }, | |
| 104 | + // 이름·아이디·이메일은 시안에서 남는 폭을 똑같이 나눠 갖는다(1552 기준 각 277). 폭을 | |
| 105 | + // 비워 두면 표가 글자 길이대로 나눠 이메일 열만 넓어져 시안과 어긋난다. | |
| 106 | + { key: 'name', header: '이름', width: FILL_COLUMN_WIDTH }, | |
| 107 | + { key: 'loginId', header: '아이디', width: FILL_COLUMN_WIDTH }, | |
| 108 | + { | |
| 109 | + key: 'phoneNumber', | |
| 110 | + header: '휴대전화번호', | |
| 111 | + width: 200, | |
| 112 | + render: (row) => formatOptionalValue(row.phoneNumber), | |
| 113 | + }, | |
| 114 | + { | |
| 115 | + key: 'email', | |
| 116 | + header: '이메일', | |
| 117 | + width: FILL_COLUMN_WIDTH, | |
| 118 | + render: (row) => formatOptionalValue(row.email), | |
| 119 | + }, | |
| 120 | + { | |
| 121 | + key: 'role', | |
| 122 | + header: '역할', | |
| 123 | + width: 120, | |
| 124 | + render: (row) => ( | |
| 125 | + <FoxBadge | |
| 126 | + type="pastel" | |
| 127 | + color={BADGE_COLOR_BY_ROLE[row.roleCode] ?? 'neutral'} | |
| 128 | + size="md" | |
| 129 | + > | |
| 130 | + {formatAdminRoleLabel(row.roleCode)} | |
| 131 | + </FoxBadge> | |
| 132 | + ), | |
| 133 | + }, | |
| 134 | + { | |
| 135 | + key: 'createdAt', | |
| 136 | + header: '생성일', | |
| 137 | + width: 160, | |
| 138 | + render: (row) => formatOptionalValue(row.createdAt), | |
| 139 | + }, | |
| 140 | + { | |
| 141 | + key: 'actions', | |
| 142 | + header: '관리', | |
| 143 | + width: 160, | |
| 144 | + render: (row) => ( | |
| 145 | + <AdminMemberRowActions | |
| 146 | + member={row} | |
| 147 | + isSelf={row.id === currentAdminId} | |
| 148 | + /> | |
| 149 | + ), | |
| 150 | + }, | |
| 151 | + ]; | |
| 152 | + | |
| 153 | + return ( | |
| 154 | + <> | |
| 155 | + <FoxListContainer<AdminMember> | |
| 156 | + title="관리자 회원 목록" | |
| 157 | + breadcrumb={[ | |
| 158 | + { label: '홈', href: '/' }, | |
| 159 | + { label: '관리자정보관리' }, | |
| 160 | + { label: '관리자 회원' }, | |
| 161 | + ]} | |
| 162 | + caption="관리자 회원 목록" | |
| 163 | + columns={columns} | |
| 164 | + rows={items} | |
| 165 | + rowKey={(row) => row.id} | |
| 166 | + totalCount={totalCount} | |
| 167 | + emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요." | |
| 168 | + sorts={ | |
| 169 | + <> | |
| 170 | + <FoxSelectText | |
| 171 | + size="sm" | |
| 172 | + ariaLabel="정렬" | |
| 173 | + icon={<FoxArrowsDownUpIcon />} | |
| 174 | + options={ADMIN_MEMBER_SORT_OPTIONS.map((option) => ({ | |
| 175 | + value: option.value, | |
| 176 | + label: option.label, | |
| 177 | + }))} | |
| 178 | + value={query.sort} | |
| 179 | + onValueChange={(value) => go({ sort: value as AdminMemberSortOption })} | |
| 180 | + /> | |
| 181 | + <FoxSelectText | |
| 182 | + size="sm" | |
| 183 | + ariaLabel="페이지 크기" | |
| 184 | + options={ADMIN_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ({ | |
| 185 | + value: String(size), | |
| 186 | + label: `${size}개씩 보기`, | |
| 187 | + }))} | |
| 188 | + value={String(query.pageSize)} | |
| 189 | + onValueChange={(value) => | |
| 190 | + go({ pageSize: Number(value) as AdminMemberPageSize }) | |
| 191 | + } | |
| 192 | + /> | |
| 193 | + </> | |
| 194 | + } | |
| 195 | + searchFields={ADMIN_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ({ | |
| 196 | + value: option.value, | |
| 197 | + label: option.label, | |
| 198 | + }))} | |
| 199 | + searchField={query.searchField} | |
| 200 | + keyword={query.keyword} | |
| 201 | + toolbarActions={ | |
| 202 | + <FoxButtonGroup size="md"> | |
| 203 | + <span title={EXCEL_UNSUPPORTED_NOTE}> | |
| 204 | + <FoxButton | |
| 205 | + type="secondary" | |
| 206 | + size="md" | |
| 207 | + disabled | |
| 208 | + leadingIcon={<FoxDownloadSimpleIcon />} | |
| 209 | + label="엑셀 다운로드" | |
| 210 | + /> | |
| 211 | + </span> | |
| 212 | + <FoxButton | |
| 213 | + type="primary" | |
| 214 | + size="md" | |
| 215 | + leadingIcon={<FoxPlusIcon />} | |
| 216 | + label="신규 등록" | |
| 217 | + onAction={() => setIsCreateOpen(true)} | |
| 218 | + /> | |
| 219 | + </FoxButtonGroup> | |
| 220 | + } | |
| 221 | + page={currentPage} | |
| 222 | + totalPages={totalPages} | |
| 223 | + buildHref={(patch) => | |
| 224 | + buildAdminMemberHref(query, { page: Number(patch.page ?? 1) }) | |
| 225 | + } | |
| 226 | + onQueryChange={(patch) => { | |
| 227 | + if (patch.keyword !== undefined || patch.searchField !== undefined) { | |
| 228 | + go({ | |
| 229 | + keyword: String(patch.keyword ?? ''), | |
| 230 | + searchField: (patch.searchField ?? | |
| 231 | + query.searchField) as AdminMemberSearchField, | |
| 232 | + }); | |
| 233 | + } | |
| 234 | + }} | |
| 235 | + /> | |
| 236 | + {isCreateOpen && ( | |
| 237 | + <AdminMemberCreateModal onClose={() => setIsCreateOpen(false)} /> | |
| 238 | + )} | |
| 239 | + </> | |
| 240 | + ); | |
| 241 | +} |
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.module.scss
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +// 비활성 삭제 버튼을 감싸 네이티브 툴팁을 다는 자리(그 이유는 컴포넌트 주석 참조). | |
| 2 | +// 감싸기만 하면 안쪽 버튼이 글자 기준선에 앉아 2px가 더 붙고, 그만큼 행이 시안(44px)보다 | |
| 3 | +// 높아진다. 래퍼를 flex로 두어 버튼 높이가 그대로 래퍼 높이가 되게 한다. | |
| 4 | +.hint { | |
| 5 | + display: flex; | |
| 6 | +} |
--- app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
... | ... | @@ -2,10 +2,12 @@ |
| 2 | 2 |
|
| 3 | 3 |
import { useState, useTransition } from 'react';
|
| 4 | 4 |
import { Button } from '@/components/ui/button';
|
| 5 |
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
|
| 5 | 6 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 6 | 7 |
import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
|
| 7 | 8 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 8 | 9 |
import type { AdminMember } from '@/lib/domain/admin-member';
|
| 10 |
+import styles from './admin-member-row-actions.module.scss'; |
|
| 9 | 11 |
import { deleteAdminMemberAction } from '../_actions';
|
| 10 | 12 |
import { AdminMemberEditModal } from './admin-member-edit-modal';
|
| 11 | 13 |
|
... | ... | @@ -27,9 +29,9 @@ |
| 27 | 29 |
* **본인 계정 삭제는 막는다**(시안 ⑤). 화면에서 버튼을 비활성으로 두되, 실제 차단은 Server |
| 28 | 30 |
* Action이 세션의 adminId와 대조해 수행한다 — 화면 비활성은 안내일 뿐 신뢰 경계가 아니다. |
| 29 | 31 |
* |
| 30 |
- * 시안의 연필·휴지통 아이콘 버튼이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을 |
|
| 31 |
- * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로 정의하는 |
|
| 32 |
- * 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다). |
|
| 32 |
+ * 시안의 연필·휴지통 아이콘 버튼이다. `FoxIconButton`의 default·sm이 시안 값과 그대로 맞는다 |
|
| 33 |
+ * (form/height/sm=28, form/radius/sm=4, 테두리 #b1b8be). 두 버튼의 4px 간격도 시안이 btn-group |
|
| 34 |
+ * 인스턴스라 `FoxButtonGroup`(gap/2=4)이 갖는다 — 여기서 직접 여백을 주지 않는다. |
|
| 33 | 35 |
* |
| 34 | 36 |
* 본인 계정 안내는 `<span title>`로 감싸 남긴다 — FoxIconButton은 `label`을 `aria-label`로만 |
| 35 | 37 |
* 쓰고 네이티브 툴팁을 렌더하지 않으며, 비활성 버튼은 스스로 hover 이벤트를 받지 못한다. |
... | ... | @@ -76,17 +78,17 @@ |
| 76 | 78 |
} |
| 77 | 79 |
|
| 78 | 80 |
return ( |
| 79 |
- <div className="flex items-center gap-1"> |
|
| 81 |
+ <FoxButtonGroup size="sm" align="center"> |
|
| 80 | 82 |
<FoxIconButton |
| 81 |
- type="ghost" |
|
| 83 |
+ type="default" |
|
| 82 | 84 |
size="sm" |
| 83 | 85 |
icon={<FoxPencilSimpleIcon />}
|
| 84 | 86 |
label="수정" |
| 85 | 87 |
onAction={() => setIsEditOpen(true)}
|
| 86 | 88 |
/> |
| 87 |
- <span title={isSelf ? SELF_DELETE_HINT : undefined}>
|
|
| 89 |
+ <span className={styles.hint} title={isSelf ? SELF_DELETE_HINT : undefined}>
|
|
| 88 | 90 |
<FoxIconButton |
| 89 |
- type="ghost" |
|
| 91 |
+ type="default" |
|
| 90 | 92 |
size="sm" |
| 91 | 93 |
icon={<FoxTrashIcon />}
|
| 92 | 94 |
label={isDeleting ? '삭제 중' : '삭제'}
|
... | ... | @@ -101,6 +103,6 @@ |
| 101 | 103 |
onClose={() => setIsEditOpen(false)}
|
| 102 | 104 |
/> |
| 103 | 105 |
)} |
| 104 |
- </div> |
|
| 106 |
+ </FoxButtonGroup> |
|
| 105 | 107 |
); |
| 106 | 108 |
} |
--- app/(protected)/(basic)/admins/_components/admin-member-search-bar.tsx
... | ... | @@ -1,74 +0,0 @@ |
| 1 | -import Link from 'next/link'; | |
| 2 | -import { Button } from '@/components/ui/button'; | |
| 3 | -import { Field } from '@/components/ui/field'; | |
| 4 | -import { Input } from '@/components/ui/input'; | |
| 5 | -import { Select } from '@/components/ui/select'; | |
| 6 | -import { | |
| 7 | - ADMIN_MEMBERS_PATH, | |
| 8 | - ADMIN_MEMBER_SEARCH_FIELD_OPTIONS, | |
| 9 | - DEFAULT_ADMIN_MEMBER_SEARCH_FIELD, | |
| 10 | - buildAdminMemberHref, | |
| 11 | - type AdminMemberQuery, | |
| 12 | -} from '@/lib/domain/admin-member-query'; | |
| 13 | - | |
| 14 | -interface AdminMemberSearchBarProps { | |
| 15 | - query: AdminMemberQuery; | |
| 16 | -} | |
| 17 | - | |
| 18 | -/** | |
| 19 | - * 검색 영역(시안 ADM_ADM_101 ①) — 순수 `<form method="get">` GET 제출로 URL을 갱신한다 | |
| 20 | - * (자바스크립트 없이도 동작). 현재 `sort`·`pageSize`는 hidden input으로 함께 제출해 검색 후에도 | |
| 21 | - * 유지되게 하고, 검색 자체는 항상 1페이지로 돌아간다(hidden page 필드를 두지 않아 | |
| 22 | - * `parseAdminMemberQuery`의 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 제출이 아니라 | |
| 23 | - * 기본값으로 되돌린 링크라 현재 입력값과 무관하게 동작한다. 상호작용이 전부 네이티브 폼 | |
| 24 | - * 제출/링크 내비게이션이라 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다. | |
| 25 | - * | |
| 26 | - * 검색 대상이 시안의 셋이 아니라 둘인 이유는 `admin-member-query.ts` 주석에 적어 두었다. | |
| 27 | - */ | |
| 28 | -export function AdminMemberSearchBar({ query }: AdminMemberSearchBarProps) { | |
| 29 | - const resetHref = buildAdminMemberHref(query, { | |
| 30 | - searchField: DEFAULT_ADMIN_MEMBER_SEARCH_FIELD, | |
| 31 | - keyword: '', | |
| 32 | - page: 1, | |
| 33 | - }); | |
| 34 | - | |
| 35 | - return ( | |
| 36 | - <form | |
| 37 | - method="get" | |
| 38 | - action={ADMIN_MEMBERS_PATH} | |
| 39 | - className="flex flex-wrap items-end gap-3" | |
| 40 | - > | |
| 41 | - <input type="hidden" name="sort" value={query.sort} /> | |
| 42 | - <input type="hidden" name="pageSize" value={query.pageSize} /> | |
| 43 | - | |
| 44 | - <Field label="검색 대상"> | |
| 45 | - <Select name="searchField" defaultValue={query.searchField}> | |
| 46 | - {ADMIN_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ( | |
| 47 | - <option key={option.value} value={option.value}> | |
| 48 | - {option.label} | |
| 49 | - </option> | |
| 50 | - ))} | |
| 51 | - </Select> | |
| 52 | - </Field> | |
| 53 | - | |
| 54 | - <Field label="검색어"> | |
| 55 | - <Input | |
| 56 | - type="text" | |
| 57 | - name="keyword" | |
| 58 | - defaultValue={query.keyword} | |
| 59 | - placeholder="검색어를 입력하세요." | |
| 60 | - /> | |
| 61 | - </Field> | |
| 62 | - | |
| 63 | - <Button type="submit" variant="secondary"> | |
| 64 | - 검색 | |
| 65 | - </Button> | |
| 66 | - <Link | |
| 67 | - href={resetHref} | |
| 68 | - className="text-body-md text-foreground-muted underline-offset-2 hover:underline" | |
| 69 | - > | |
| 70 | - 초기화 | |
| 71 | - </Link> | |
| 72 | - </form> | |
| 73 | - ); | |
| 74 | -} |
--- app/(protected)/(basic)/admins/_components/admin-member-table.tsx
... | ... | @@ -1,90 +0,0 @@ |
| 1 | -import { | |
| 2 | - Table, | |
| 3 | - TableBody, | |
| 4 | - TableCell, | |
| 5 | - TableHead, | |
| 6 | - TableHeaderCell, | |
| 7 | - TableRow, | |
| 8 | -} from '@/components/ui/table'; | |
| 9 | -import { | |
| 10 | - formatAdminRoleLabel, | |
| 11 | - formatOptionalValue, | |
| 12 | - type AdminMember, | |
| 13 | -} from '@/lib/domain/admin-member'; | |
| 14 | -import { AdminMemberRowActions } from './admin-member-row-actions'; | |
| 15 | - | |
| 16 | -interface AdminMemberTableProps { | |
| 17 | - items: AdminMember[]; | |
| 18 | - page: number; | |
| 19 | - pageSize: number; | |
| 20 | - totalCount: number; | |
| 21 | - /** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */ | |
| 22 | - currentAdminId: string; | |
| 23 | -} | |
| 24 | - | |
| 25 | -const COLUMN_HEADERS = [ | |
| 26 | - '번호', | |
| 27 | - '이름', | |
| 28 | - '아이디', | |
| 29 | - '휴대전화번호', | |
| 30 | - '이메일', | |
| 31 | - '역할', | |
| 32 | - '생성일', | |
| 33 | - '관리', | |
| 34 | -]; | |
| 35 | - | |
| 36 | -/** | |
| 37 | - * 관리자 회원 목록 테이블(시안 ADM_ADM_101 ④). 개인정보 마스킹은 시안에 "마스킹 처리 없음"으로 | |
| 38 | - * 명시돼 있어 원본 값을 그대로 노출한다. | |
| 39 | - * | |
| 40 | - * **"번호"는 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다** — 시안이 6·5·4처럼 | |
| 41 | - * 내림차순으로 표기하고, 목록의 기본 정렬이 생성일 최신순이라 "가장 최근에 만들어진 계정이 가장 | |
| 42 | - * 큰 번호"가 된다. 이 계산은 정확한 전체 건수를 전제하는데, Repository가 전체를 받아 세기 때문에 | |
| 43 | - * 그 전제가 성립한다(백엔드 `totalCount`는 신뢰할 수 없다 — Repository 주석 참조). | |
| 44 | - * | |
| 45 | - * 백엔드가 아직 주지 않는 항목(휴대전화번호·이메일·생성일)은 열을 그대로 유지한 채 `-`로 표시한다 | |
| 46 | - * — 백엔드가 필드를 추가하면 Repository 매핑만 늘리면 이 파일은 그대로 값이 채워진다. | |
| 47 | - * | |
| 48 | - * "관리" 열은 행별 수정/삭제 트리거(AdminMemberRowActions)에 위임한다 — 상호작용이 필요한 것은 | |
| 49 | - * 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 경계로 뗀다. | |
| 50 | - */ | |
| 51 | -export function AdminMemberTable({ | |
| 52 | - items, | |
| 53 | - page, | |
| 54 | - pageSize, | |
| 55 | - totalCount, | |
| 56 | - currentAdminId, | |
| 57 | -}: AdminMemberTableProps) { | |
| 58 | - const offset = (page - 1) * pageSize; | |
| 59 | - | |
| 60 | - return ( | |
| 61 | - <Table> | |
| 62 | - <TableHead> | |
| 63 | - <TableRow> | |
| 64 | - {COLUMN_HEADERS.map((header) => ( | |
| 65 | - <TableHeaderCell key={header}>{header}</TableHeaderCell> | |
| 66 | - ))} | |
| 67 | - </TableRow> | |
| 68 | - </TableHead> | |
| 69 | - <TableBody> | |
| 70 | - {items.map((member, index) => ( | |
| 71 | - <TableRow key={member.id}> | |
| 72 | - <TableCell>{totalCount - offset - index}</TableCell> | |
| 73 | - <TableCell>{member.name}</TableCell> | |
| 74 | - <TableCell>{member.loginId}</TableCell> | |
| 75 | - <TableCell>{formatOptionalValue(member.phoneNumber)}</TableCell> | |
| 76 | - <TableCell>{formatOptionalValue(member.email)}</TableCell> | |
| 77 | - <TableCell>{formatAdminRoleLabel(member.roleCode)}</TableCell> | |
| 78 | - <TableCell>{formatOptionalValue(member.createdAt)}</TableCell> | |
| 79 | - <TableCell> | |
| 80 | - <AdminMemberRowActions | |
| 81 | - member={member} | |
| 82 | - isSelf={member.id === currentAdminId} | |
| 83 | - /> | |
| 84 | - </TableCell> | |
| 85 | - </TableRow> | |
| 86 | - ))} | |
| 87 | - </TableBody> | |
| 88 | - </Table> | |
| 89 | - ); | |
| 90 | -} |
--- app/(protected)/(basic)/admins/page.tsx
+++ app/(protected)/(basic)/admins/page.tsx
... | ... | @@ -1,16 +1,8 @@ |
| 1 | 1 |
import type { Metadata } from 'next';
|
| 2 |
-import Link from 'next/link'; |
|
| 3 |
-import { Alert } from '@/components/ui/alert';
|
|
| 4 |
-import { Pagination } from '@/components/ui/pagination';
|
|
| 5 | 2 |
import { verifySession } from '@/lib/auth/dal';
|
| 6 | 3 |
import { fetchAdminMembers } from '@/lib/data/repositories/admin-member-repository';
|
| 7 |
-import {
|
|
| 8 |
- buildAdminMemberHref, |
|
| 9 |
- parseAdminMemberQuery, |
|
| 10 |
-} from '@/lib/domain/admin-member-query'; |
|
| 11 |
-import { AdminMemberListToolbar } from './_components/admin-member-list-toolbar';
|
|
| 12 |
-import { AdminMemberSearchBar } from './_components/admin-member-search-bar';
|
|
| 13 |
-import { AdminMemberTable } from './_components/admin-member-table';
|
|
| 4 |
+import { parseAdminMemberQuery } from '@/lib/domain/admin-member-query';
|
|
| 5 |
+import { AdminMemberList } from './_components/admin-member-list';
|
|
| 14 | 6 |
|
| 15 | 7 |
export const metadata: Metadata = {
|
| 16 | 8 |
title: '관리자 회원', |
... | ... | @@ -37,51 +29,17 @@ |
| 37 | 29 |
|
| 38 | 30 |
const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); |
| 39 | 31 |
// 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐) 마지막 페이지로 맞춘다 — |
| 40 |
- // 표의 순번 계산도 이 값을 기준으로 해야 헤더의 "현재페이지"와 어긋나지 않는다. |
|
| 32 |
+ // 표의 순번 계산도 이 값을 기준으로 해야 페이지네이션의 현재 위치와 어긋나지 않는다. |
|
| 41 | 33 |
const currentPage = Math.min(query.page, totalPages); |
| 42 | 34 |
|
| 43 | 35 |
return ( |
| 44 |
- <section className="flex flex-1 flex-col gap-6 p-8"> |
|
| 45 |
- <h1 className="text-headline-sm text-foreground">관리자 회원 목록</h1> |
|
| 46 |
- |
|
| 47 |
- <AdminMemberSearchBar query={query} />
|
|
| 48 |
- |
|
| 49 |
- <AdminMemberListToolbar query={query} />
|
|
| 50 |
- |
|
| 51 |
- <p className="text-body-md text-foreground-muted"> |
|
| 52 |
- 총 {totalCount}명 | 현재페이지 {currentPage}/{totalPages}
|
|
| 53 |
- </p> |
|
| 54 |
- |
|
| 55 |
- {items.length === 0 ? (
|
|
| 56 |
- <Alert |
|
| 57 |
- title="검색 결과가 없습니다." |
|
| 58 |
- actions={
|
|
| 59 |
- <Link |
|
| 60 |
- href={buildAdminMemberHref(query, { keyword: '', page: 1 })}
|
|
| 61 |
- className="bg-foreground px-4 py-2 text-background" |
|
| 62 |
- > |
|
| 63 |
- 확인 |
|
| 64 |
- </Link> |
|
| 65 |
- } |
|
| 66 |
- > |
|
| 67 |
- 다른 검색어로 다시 시도해 주세요. |
|
| 68 |
- </Alert> |
|
| 69 |
- ) : ( |
|
| 70 |
- <> |
|
| 71 |
- <AdminMemberTable |
|
| 72 |
- items={items}
|
|
| 73 |
- page={currentPage}
|
|
| 74 |
- pageSize={query.pageSize}
|
|
| 75 |
- totalCount={totalCount}
|
|
| 76 |
- currentAdminId={admin.id}
|
|
| 77 |
- /> |
|
| 78 |
- <Pagination |
|
| 79 |
- currentPage={currentPage}
|
|
| 80 |
- totalPages={totalPages}
|
|
| 81 |
- buildHref={(page: number) => buildAdminMemberHref(query, { page })}
|
|
| 82 |
- /> |
|
| 83 |
- </> |
|
| 84 |
- )} |
|
| 85 |
- </section> |
|
| 36 |
+ <AdminMemberList |
|
| 37 |
+ items={items}
|
|
| 38 |
+ query={query}
|
|
| 39 |
+ currentPage={currentPage}
|
|
| 40 |
+ totalPages={totalPages}
|
|
| 41 |
+ totalCount={totalCount}
|
|
| 42 |
+ currentAdminId={admin.id}
|
|
| 43 |
+ /> |
|
| 86 | 44 |
); |
| 87 | 45 |
} |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-list-toolbar.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-list-toolbar.tsx
... | ... | @@ -24,7 +24,7 @@ |
| 24 | 24 |
* 대응하는 항목이 없어 페이지 크기와 등록 버튼만 둔다. |
| 25 | 25 |
* |
| 26 | 26 |
* [+ 신규 등록]은 현재 유형 탭을 기본값으로 들고 등록 팝업을 연다 — 팝업 열림 상태를 이 |
| 27 |
- * 컴포넌트가 소유한다(admin-member-list-toolbar.tsx와 동일한 패턴). |
|
| 27 |
+ * 컴포넌트가 소유한다(관리자 회원 목록과 동일한 패턴). |
|
| 28 | 28 |
*/ |
| 29 | 29 |
export function DecorationItemListToolbar({
|
| 30 | 30 |
query, |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-search-bar.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-search-bar.tsx
... | ... | @@ -23,7 +23,7 @@ |
| 23 | 23 |
* 제출이 아니라 기본값(검색대상·검색어)으로 되돌린 링크라 현재 입력값과 무관하게 동작한다 |
| 24 | 24 |
* (유형 탭은 초기화 대상이 아니다 — 지금 보고 있는 목록은 유지). 상호작용이 전부 네이티브 폼 |
| 25 | 25 |
* 제출/링크 내비게이션이라 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다 |
| 26 |
- * (admin-member-search-bar.tsx와 동일한 설계). |
|
| 26 |
+ * (관리자 회원 목록 이전 검색바와 동일한 설계). |
|
| 27 | 27 |
*/ |
| 28 | 28 |
export function DecorationItemSearchBar({ query }: DecorationItemSearchBarProps) {
|
| 29 | 29 |
const resetHref = buildDecorationItemHref(query, {
|
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?