/** * 학생 회원 목록의 검색·정렬·페이징 조건 — 순수 규칙(허용 값·기본값·URL 직렬화)만 담는다. * next/react 의존이 없다(`URLSearchParams`는 서버·브라우저 양쪽에서 쓸 수 있는 표준 Web API). * * `app/(protected)/(basic)/students/page.tsx`가 `searchParams`를 `parseStudentMemberQuery`로 * 정규화하는 지점이자 단일 진실원천이며, 검색바·툴바·페이지네이션은 모두 `buildStudentMemberHref`로 * 같은 규칙에 따라 URL을 만들어 파라미터 이름·기본값이 여러 파일에 흩어져 드리프트하는 것을 막는다. */ /** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */ export const STUDENT_MEMBERS_PATH = '/students'; /** * 엑셀 다운로드 라우트(파일 응답 전용 핸들러). 목록의 검색·페이징 조건을 싣지 않는 것이 * 사양이라(항상 전체 데이터) `buildStudentMemberHref`의 직렬화 대상이 아니다. */ export const STUDENT_MEMBERS_EXCEL_PATH = `${STUDENT_MEMBERS_PATH}/excel`; /** * 검색 대상 — 백엔드가 실제로 필터링해 주는 3종만 둔다. * * 백엔드 목록 쿼리(MngrUserMapper.xml)는 `searchCondition`이 "1"|"2"|"3"일 때만 조건을 붙이고, * 그 외 값이면 **조건 없이 전체를 반환한다**(검색어를 무시한 결과가 검색 결과인 척 나온다). * 그래서 지원되지 않는 학교명 검색은 화면에서 아예 제거했다 — 백엔드가 조건을 추가하면 * 여기와 Repository의 매핑 표에 같이 넣으면 된다. */ export type StudentMemberSearchField = 'name' | 'loginId' | 'phoneNumber'; export const STUDENT_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{ value: StudentMemberSearchField; label: string; }> = [ { value: 'name', label: '회원명' }, { value: 'loginId', label: 'ID' }, { value: 'phoneNumber', label: '휴대전화번호' }, ]; export type StudentMemberSortOption = 'joinedAt' | 'name'; export const STUDENT_MEMBER_SORT_OPTIONS: ReadonlyArray<{ value: StudentMemberSortOption; label: string; }> = [ { value: 'joinedAt', label: '가입일순' }, { value: 'name', label: '이름순' }, ]; export const STUDENT_MEMBER_PAGE_SIZE_OPTIONS = [10, 30, 50] as const; export type StudentMemberPageSize = (typeof STUDENT_MEMBER_PAGE_SIZE_OPTIONS)[number]; export const DEFAULT_STUDENT_MEMBER_SEARCH_FIELD: StudentMemberSearchField = 'name'; export const DEFAULT_STUDENT_MEMBER_SORT: StudentMemberSortOption = 'joinedAt'; export const DEFAULT_STUDENT_MEMBER_PAGE_SIZE: StudentMemberPageSize = 10; const DEFAULT_PAGE = 1; const MAX_KEYWORD_LENGTH = 100; export type StudentMemberQuery = { searchField: StudentMemberSearchField; keyword: string; sort: StudentMemberSortOption; page: number; pageSize: StudentMemberPageSize; }; /** 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 isStudentMemberSearchField( value: string | undefined ): value is StudentMemberSearchField { return ( value !== undefined && STUDENT_MEMBER_SEARCH_FIELD_OPTIONS.some((option) => option.value === value) ); } function isStudentMemberSortOption( value: string | undefined ): value is StudentMemberSortOption { return ( value !== undefined && STUDENT_MEMBER_SORT_OPTIONS.some((option) => option.value === value) ); } function isStudentMemberPageSize( value: number ): value is StudentMemberPageSize { return (STUDENT_MEMBER_PAGE_SIZE_OPTIONS as readonly number[]).includes( value ); } /** * URL의 searchParams를 검증된 `StudentMemberQuery`로 정규화한다. 값이 없거나 허용 목록을 * 벗어나면 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라 * 신뢰하지 않는다. */ export function parseStudentMemberQuery( searchParams: RawSearchParams ): StudentMemberQuery { 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: isStudentMemberSearchField(searchFieldRaw) ? searchFieldRaw : DEFAULT_STUDENT_MEMBER_SEARCH_FIELD, keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH), sort: isStudentMemberSortOption(sortRaw) ? sortRaw : DEFAULT_STUDENT_MEMBER_SORT, page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE, pageSize: isStudentMemberPageSize(pageSizeRaw) ? pageSizeRaw : DEFAULT_STUDENT_MEMBER_PAGE_SIZE, }; } /** * `StudentMemberQuery`(+ 부분 override)를 `/students` 링크로 직렬화한다. `parseStudentMemberQuery`의 * 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다. */ export function buildStudentMemberHref( query: StudentMemberQuery, overrides: Partial = {} ): string { const merged = { ...query, ...overrides }; const params = new URLSearchParams(); if (merged.searchField !== DEFAULT_STUDENT_MEMBER_SEARCH_FIELD) { params.set('searchField', merged.searchField); } if (merged.keyword) { params.set('keyword', merged.keyword); } if (merged.sort !== DEFAULT_STUDENT_MEMBER_SORT) { params.set('sort', merged.sort); } if (merged.pageSize !== DEFAULT_STUDENT_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 ? `${STUDENT_MEMBERS_PATH}?${queryString}` : STUDENT_MEMBERS_PATH; }