File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
/**
* 관리자 회원 목록의 검색·정렬·페이징 조건 — 순수 규칙(허용 값·기본값·URL 직렬화)만 담는다.
* next/react 의존이 없다(`URLSearchParams`는 서버·브라우저 양쪽에서 쓸 수 있는 표준 Web API).
*
* `app/(protected)/(basic)/admins/page.tsx`가 `searchParams`를 `parseAdminMemberQuery`로
* 정규화하는 지점이자 단일 진실원천이며, 검색바·툴바·페이지네이션은 모두 `buildAdminMemberHref`로
* 같은 규칙에 따라 URL을 만들어 파라미터 이름·기본값이 여러 파일에 흩어져 드리프트하는 것을 막는다.
*
* 학생 회원 목록(`student-member-query.ts`)과 구조가 같지만 파일을 합치지 않았다 — 두 화면의
* 검색 대상·정렬 기준·기본값이 각자의 기획(ADM_MEM_101 / ADM_ADM_101)을 따라 서로 다르게
* 움직이고, 한쪽 기획 변경이 다른 화면을 건드리게 되는 결합이 공통화의 이득보다 크다.
*/
/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */
export const ADMIN_MEMBERS_PATH = '/admins';
/**
* 검색 대상 — 시안(ADM_ADM_101 ①)은 회원명/ID/휴대전화번호 셋을 요구하지만 **휴대전화번호는 뺐다.**
*
* 두 가지 이유가 겹친다:
* 1. 목록 응답에 휴대전화번호가 없다(`MngrAdminMapper.xml`의 select 목록에도, `MngrAdminVo`에도
* 없다). 화면에 보이지도 않는 값으로 걸러낸 결과는 사용자가 검증할 수 없다.
* 2. 백엔드의 휴대전화번호 검색 분기(`searchCondition == "3"`)는 `USER_TELNO` 컬럼을 참조하는데,
* 이는 학생 목록 쿼리(TB_COM_USER 대상)에서 그대로 복사된 것으로 보인다 — 관리자 테이블
* (TB_ADM_USER)의 select 목록에는 그런 컬럼이 없다. 컬럼이 실제로 없다면 이 검색은 SQL 오류로
* 떨어져 화면 전체가 에러 경계로 넘어간다.
*
* 백엔드가 휴대전화번호를 응답에 포함하면 여기와 `admin-member-repository.ts`의 필터에 함께
* 추가한다.
*/
export type AdminMemberSearchField = 'name' | 'loginId';
export const ADMIN_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
value: AdminMemberSearchField;
label: string;
}> = [
{ value: 'name', label: '회원명' },
{ value: 'loginId', label: 'ID' },
];
/**
* 정렬 기준 — 시안의 select는 "가입일순"이지만 관리자 회원의 해당 값은 생성일이라 이름을 맞췄다.
*
* `createdAt`은 백엔드가 응답에 담아 주지 않는데도 정렬이 가능한 이유는, 목록 SQL의 고정
* `ORDER BY rnum DESC`가 곧 생성일 최신순이기 때문이다(rnum은 `frst_reg_dt` 오름차순 행번호).
* 즉 이 정렬은 "백엔드가 준 순서를 그대로 쓴다"는 뜻이다 — 자세한 근거는 Repository 주석 참조.
*/
export type AdminMemberSortOption = 'createdAt' | 'name';
export const ADMIN_MEMBER_SORT_OPTIONS: ReadonlyArray<{
value: AdminMemberSortOption;
label: string;
}> = [
{ value: 'createdAt', label: '생성일순' },
{ value: 'name', label: '이름순' },
];
export const ADMIN_MEMBER_PAGE_SIZE_OPTIONS = [10, 30, 50] as const;
export type AdminMemberPageSize =
(typeof ADMIN_MEMBER_PAGE_SIZE_OPTIONS)[number];
export const DEFAULT_ADMIN_MEMBER_SEARCH_FIELD: AdminMemberSearchField = 'name';
export const DEFAULT_ADMIN_MEMBER_SORT: AdminMemberSortOption = 'createdAt';
export const DEFAULT_ADMIN_MEMBER_PAGE_SIZE: AdminMemberPageSize = 10;
const DEFAULT_PAGE = 1;
const MAX_KEYWORD_LENGTH = 100;
export type AdminMemberQuery = {
searchField: AdminMemberSearchField;
keyword: string;
sort: AdminMemberSortOption;
page: number;
pageSize: AdminMemberPageSize;
};
/** Next.js `page.tsx`의 `searchParams`가 리졸브하는 값 형태를 그대로 옮긴 구조 타입 —
* next 패키지를 import하지 않고도 같은 shape을 표현해 domain 계층의 무의존 규칙을 지킨다. */
type RawSearchParams = Record<string, string | string[] | undefined>;
function readParam(params: RawSearchParams, key: string): string | undefined {
const value = params[key];
return Array.isArray(value) ? value[0] : value;
}
function isAdminMemberSearchField(
value: string | undefined
): value is AdminMemberSearchField {
return (
value !== undefined &&
ADMIN_MEMBER_SEARCH_FIELD_OPTIONS.some((option) => option.value === value)
);
}
function isAdminMemberSortOption(
value: string | undefined
): value is AdminMemberSortOption {
return (
value !== undefined &&
ADMIN_MEMBER_SORT_OPTIONS.some((option) => option.value === value)
);
}
function isAdminMemberPageSize(value: number): value is AdminMemberPageSize {
return (ADMIN_MEMBER_PAGE_SIZE_OPTIONS as readonly number[]).includes(value);
}
/**
* URL의 searchParams를 검증된 `AdminMemberQuery`로 정규화한다. 값이 없거나 허용 목록을
* 벗어나면 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라
* 신뢰하지 않는다.
*/
export function parseAdminMemberQuery(
searchParams: RawSearchParams
): AdminMemberQuery {
const searchFieldRaw = readParam(searchParams, 'searchField');
const sortRaw = readParam(searchParams, 'sort');
const keywordRaw = readParam(searchParams, 'keyword');
const pageRaw = Number(readParam(searchParams, 'page'));
const pageSizeRaw = Number(readParam(searchParams, 'pageSize'));
return {
searchField: isAdminMemberSearchField(searchFieldRaw)
? searchFieldRaw
: DEFAULT_ADMIN_MEMBER_SEARCH_FIELD,
keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH),
sort: isAdminMemberSortOption(sortRaw)
? sortRaw
: DEFAULT_ADMIN_MEMBER_SORT,
page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE,
pageSize: isAdminMemberPageSize(pageSizeRaw)
? pageSizeRaw
: DEFAULT_ADMIN_MEMBER_PAGE_SIZE,
};
}
/**
* `AdminMemberQuery`(+ 부분 override)를 `/admins` 링크로 직렬화한다. `parseAdminMemberQuery`의
* 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다.
*/
export function buildAdminMemberHref(
query: AdminMemberQuery,
overrides: Partial<AdminMemberQuery> = {}
): string {
const merged = { ...query, ...overrides };
const params = new URLSearchParams();
if (merged.searchField !== DEFAULT_ADMIN_MEMBER_SEARCH_FIELD) {
params.set('searchField', merged.searchField);
}
if (merged.keyword) {
params.set('keyword', merged.keyword);
}
if (merged.sort !== DEFAULT_ADMIN_MEMBER_SORT) {
params.set('sort', merged.sort);
}
if (merged.pageSize !== DEFAULT_ADMIN_MEMBER_PAGE_SIZE) {
params.set('pageSize', String(merged.pageSize));
}
if (merged.page !== DEFAULT_PAGE) {
params.set('page', String(merged.page));
}
const queryString = params.toString();
return queryString
? `${ADMIN_MEMBERS_PATH}?${queryString}`
: ADMIN_MEMBERS_PATH;
}