/** * 관리자 회원 목록의 검색·정렬·페이징 조건 — 순수 규칙(허용 값·기본값·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'; /** 엑셀 다운로드 라우트 핸들러의 경로 — 목록 툴바의 폼이 이 주소로 GET 제출한다. */ export const ADMIN_MEMBERS_EXCEL_PATH = `${ADMIN_MEMBERS_PATH}/excel`; /** * 검색 대상 — 시안(ADM_ADM_101 ①)의 회원명/ID/휴대전화번호 셋. * * 휴대전화번호는 한동안 빠져 있었다. 목록 응답에 그 값이 없어 결과를 눈으로 검증할 수 없었고, * 백엔드의 해당 분기가 관리자 테이블에 없는 `USER_TELNO`를 참조해 SQL 오류가 날 상태였다. * 백엔드가 `ADM_TEL_NO`를 select 목록과 검색 분기 양쪽에 넣으면서 두 이유가 모두 사라졌다. * * 실제 필터링은 Repository가 전체를 손에 쥐고 수행한다 — 이 값은 "어느 열로 거를지"만 정한다. */ export type AdminMemberSearchField = 'name' | 'loginId' | 'phoneNumber'; export const ADMIN_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{ value: AdminMemberSearchField; label: string; }> = [ { value: 'name', label: '회원명' }, { value: 'loginId', label: 'ID' }, { value: 'phoneNumber', label: '휴대전화번호' }, ]; /** * 정렬 기준 — 시안의 select는 "가입일순"이지만 관리자 회원의 해당 값은 생성일이라 이름을 맞췄다. * * 생성일순이 곧 "백엔드가 준 순서를 그대로 쓴다"는 뜻이다 — 목록 SQL의 고정 `ORDER BY rnum DESC`가 * 생성일 최신순이기 때문이다(rnum은 `frst_reg_dt` 오름차순 행번호). 정렬 파라미터가 없어 이름순만 * 우리가 정렬한다. */ 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; 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 = {} ): 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; }