15 KiB
15 KiB
[계획서] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트
개요
페이지네이션의 핵심 컨트롤(<< < [번호들] > >>)을 **재사용 가능한 공통 컴포넌트 PageGroupNav**로 분리합니다.
현재의 단순 1 / n 텍스트 표시를 10개 단위 페이지 번호 버튼 그룹으로 교체하고, < > 버튼을 단락(그룹) 이동으로, << >> 버튼을 첫/끝 단락 이동으로 변경합니다.
접근 전략: C안 (핵심 컨트롤 분리 + 단계적 적용)
- 1단계 (이번 작업):
PageGroupNav.tsx생성 + v2-table-list에 적용 - 2단계 (별도 작업): 나머지 페이징 사용처에 점진적 적용
이 전략을 선택한 이유:
- 레이아웃을 강제하지 않는 순수 컨트롤 컴포넌트 → 어디든 조합 가능
- v2-table-list에서 먼저 검증 후 확산 → 리스크 최소화
- 2단계는
import한 줄로 적용 가능 → 미래 작업 비용 최소
현재 동작
페이지네이션 UI
[<<] [<] 1 / 38 [>] [>>]
| 버튼 | 현재 동작 |
|---|---|
<< |
첫 페이지(1)로 이동 |
< |
이전 페이지(currentPage - 1)로 이동 |
| 중앙 | currentPage / totalPages 텍스트 표시 (클릭 불가) |
> |
다음 페이지(currentPage + 1)로 이동 |
>> |
마지막 페이지(totalPages)로 이동 |
비활성화 조건
<<<:currentPage === 1>>>:currentPage >= totalPages
현재 코드 (TableListComponent.tsx, 5139~5182행)
{/* 중앙 페이지네이션 컨트롤 */}
<div className="flex items-center gap-2 sm:gap-4">
<Button onClick={() => handlePageChange(1)}
disabled={currentPage === 1 || loading}> {/* << */}
<Button onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}> {/* < */}
<span>{currentPage} / {totalPages || 1}</span>
<Button onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || loading}> {/* > */}
<Button onClick={() => handlePageChange(totalPages)}
disabled={currentPage >= totalPages || loading}> {/* >> */}
</div>
변경 후 동작
페이지네이션 UI
[<<] [<] [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [>] [>>]
| 버튼 | 변경 후 동작 |
|---|---|
<< |
첫 번째 단락으로 이동 (1페이지 선택) |
< |
이전 단락의 첫 페이지로 이동 |
| 중앙 | 현재 단락의 페이지 번호 버튼 나열 (클릭으로 해당 페이지 이동) |
> |
다음 단락의 첫 페이지로 이동 |
>> |
마지막 단락의 첫 페이지로 이동 (마지막 페이지가 아님) |
비활성화 조건
<<<: 첫 번째 단락(1~10)을 보고 있을 때>>>: 마지막 단락을 보고 있을 때
단락(그룹) 개념
- 10개 단위로 페이지를 묶어 하나의 "단락"으로 취급
- 단락 1: 1~10, 단락 2: 11~20, 단락 3: 21~30, ...
- 마지막 단락은 10개 미만일 수 있음 (예: 31~38)
고정 슬롯 레이아웃 (핵심 제약)
페이지 번호 영역은 항상 10개 슬롯을 고정 렌더링한다.
- 각 슬롯은 동일한 고정 너비(
w-9등)를 가짐 - 1자리(
1)든 2자리(11)든 3자리(100)든 버튼 너비가 동일 - 마지막 단락이 10개 미만이면 남은 슬롯은 빈 공간(투명 placeholder)으로 채움
- 이로써
< >버튼을 연속 클릭해도 번호 버튼과 화살표 버튼의 위치가 절대 변하지 않음
단락 1~10: [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] ← 10개 모두 채움
단락 11~20: [11][12][13][14][15][16][17][18][19][20] ← 너비 동일
단락 31~38: [31][32][33][34][35][36][37][38][ ][ ] ← 빈 슬롯 2개로 위치 고정
시각적 동작 예시
총 38페이지 기준:
단락별 페이지 번호 표시
| 현재 페이지 | 표시 번호 | << < |
> >> |
|---|---|---|---|
| 1 | [1] [2] [3] ... [10] | 비활성 | 활성 |
| 5 | [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] | 비활성 | 활성 |
| 10 | [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] | 비활성 | 활성 |
| 11 | [11] [12] [13] ... [20] | 활성 | 활성 |
| 25 | [21] [22] [23] [24] [25] [26] [27] [28] [29] [30] | 활성 | 활성 |
| 31 | [31] [32] [33] ... [38] [ ] [ ] | 활성 | 비활성 |
| 38 | [31] [32] [33] [34] [35] [36] [37] [38] [ ] [ ] | 활성 | 비활성 |
버튼 클릭 시나리오
| 현재 상태 | 클릭 | 결과 |
|---|---|---|
| 5페이지 (단락 1~10) | > |
11페이지 선택, 단락 11~20 표시 |
| 15페이지 (단락 11~20) | < |
1페이지 선택, 단락 1~10 표시 |
| 15페이지 (단락 11~20) | >> |
31페이지 선택, 단락 31~38 표시 |
| 35페이지 (단락 31~38) | << |
1페이지 선택, 단락 1~10 표시 |
| 5페이지 (단락 1~10) | [7] |
7페이지 선택, 단락 1~10 유지 |
아키텍처
컴포넌트 구조 (C안)
flowchart TD
subgraph PageGroupNav ["PageGroupNav.tsx (새 공통 컴포넌트)"]
Props["props: currentPage, totalPages, onPageChange, disabled, groupSize"]
Logic["단락 계산 + 고정 슬롯 + 비활성화"]
UI["<< < [번호들] > >>"]
Props --> Logic --> UI
end
subgraph Phase1 ["1단계: 이번 작업"]
V2Table["v2-table-list paginationJSX"]
end
subgraph Phase2 ["2단계: 별도 작업 (미래)"]
TableList["table-list (구형)"]
PaginationTsx["Pagination.tsx (관리자)"]
DrillDown["DrillDown 모달"]
Mail["메일 수신/발송"]
Others["감사로그, 배치, DataTable 등"]
end
PageGroupNav --> V2Table
PageGroupNav -.-> TableList
PageGroupNav -.-> PaginationTsx
PageGroupNav -.-> DrillDown
PageGroupNav -.-> Mail
PageGroupNav -.-> Others
v2-table-list 내부 데이터 흐름
flowchart TD
A["currentPage, totalPages (state)"] --> B[PageGroupNav]
B -->|onPageChange| C[handlePageChange]
C --> D[setCurrentPage + onConfigChange]
D --> E[백엔드 API 호출]
E --> F[데이터 갱신]
F --> A
v2-table-list 페이징 바 레이아웃 (변경 없음)
┌─────────────────────────────────────────────────────────────────┐
│ [페이지크기 입력] │ << < [PageGroupNav] > >> │ [내보내기][새로고침] │
│ 좌측(유지) │ 중앙(교체) │ 우측(유지) │
└─────────────────────────────────────────────────────────────────┘
변경 대상 파일
1단계 (이번 작업)
| 구분 | 파일 | 변경 내용 | 변경 규모 |
|---|---|---|---|
| 생성 | frontend/components/common/PageGroupNav.tsx |
페이지 그룹 네비게이션 공통 컴포넌트 | 약 80줄 신규 |
| 수정 | frontend/lib/registry/components/v2-table-list/TableListComponent.tsx |
paginationJSX 중앙 영역을 PageGroupNav로 교체 (5139~5182행) |
약 40줄 → 5줄 |
handlePageChange함수는 기존 것을 그대로 사용 (동작 변경 없음)- 좌측(페이지크기 입력), 우측(내보내기/새로고침) 영역은 변경하지 않음
- 백엔드 변경 없음, DB 변경 없음
1단계 적용 범위
v2-table-list를 사용하는 모든 동적 화면에 자동 적용:
- 품목정보, 거래처관리, 판매품목정보, 설비정보 등
2단계 적용 대상 (별도 작업, 미래)
| 사용처 | 파일 | 현재 페이징 형태 |
|---|---|---|
| table-list (구형) | lib/registry/components/table-list/TableListComponent.tsx |
<< < 현재/총 > >> |
| 공통 Pagination | components/common/Pagination.tsx |
번호 ±2 + ... |
| 피벗 드릴다운 | lib/registry/components/pivot-grid/components/DrillDownModal.tsx |
<< < 현재/총 > >> |
| v2 피벗 드릴다운 | lib/registry/components/v2-pivot-grid/components/DrillDownModal.tsx |
동일 |
| 메일 수신함 | app/(main)/admin/automaticMng/mail/receive/page.tsx |
번호 5개 클릭 |
| 메일 발송함 | app/(main)/admin/automaticMng/mail/sent/page.tsx |
동일 |
| 감사 로그 | app/(main)/admin/audit-log/page.tsx |
< 현재/총 > |
| 배치 관리 | app/(main)/admin/automaticMng/batchmngList/page.tsx |
번호 5개 클릭 |
| DataTable | components/common/DataTable.tsx |
<< < > >> + 텍스트 |
| FlowWidget | components/screen/widgets/FlowWidget.tsx |
shadcn Pagination |
코드 설계
PageGroupNav.tsx 공통 컴포넌트
// frontend/components/common/PageGroupNav.tsx
"use client";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { Button } from "@/components/ui/button";
const DEFAULT_GROUP_SIZE = 10;
interface PageGroupNavProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
disabled?: boolean;
groupSize?: number;
}
export function PageGroupNav({
currentPage,
totalPages,
onPageChange,
disabled = false,
groupSize = DEFAULT_GROUP_SIZE,
}: PageGroupNavProps) {
const safeTotal = Math.max(1, totalPages);
const currentGroupIndex = Math.floor((currentPage - 1) / groupSize);
const groupStartPage = currentGroupIndex * groupSize + 1;
const lastGroupIndex = Math.floor((safeTotal - 1) / groupSize);
const lastGroupStartPage = lastGroupIndex * groupSize + 1;
const isFirstGroup = currentGroupIndex === 0;
const isLastGroup = currentGroupIndex === lastGroupIndex;
// 10개 고정 슬롯 배열
const slots: (number | null)[] = [];
for (let i = 0; i < groupSize; i++) {
const page = groupStartPage + i;
slots.push(page <= safeTotal ? page : null);
}
return (
<div className="flex items-center gap-1">
{/* << 첫 단락 */}
<Button variant="outline" size="sm"
onClick={() => onPageChange(1)}
disabled={isFirstGroup || disabled}
className="h-8 w-8 p-0 sm:h-9 sm:w-9">
<ChevronsLeft className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
{/* < 이전 단락 */}
<Button variant="outline" size="sm"
onClick={() => onPageChange((currentGroupIndex - 1) * groupSize + 1)}
disabled={isFirstGroup || disabled}
className="h-8 w-8 p-0 sm:h-9 sm:w-9">
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
{/* 페이지 번호 (고정 슬롯) */}
{slots.map((page, idx) =>
page !== null ? (
<Button key={idx} size="sm"
variant={page === currentPage ? "default" : "outline"}
onClick={() => onPageChange(page)}
disabled={disabled}
className="h-8 w-8 p-0 text-xs sm:h-9 sm:w-9 sm:text-sm">
{page}
</Button>
) : (
<div key={idx} className="h-8 w-8 sm:h-9 sm:w-9" />
)
)}
{/* > 다음 단락 */}
<Button variant="outline" size="sm"
onClick={() => onPageChange((currentGroupIndex + 1) * groupSize + 1)}
disabled={isLastGroup || disabled}
className="h-8 w-8 p-0 sm:h-9 sm:w-9">
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
{/* >> 마지막 단락 */}
<Button variant="outline" size="sm"
onClick={() => onPageChange(lastGroupStartPage)}
disabled={isLastGroup || disabled}
className="h-8 w-8 p-0 sm:h-9 sm:w-9">
<ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
);
}
v2-table-list 통합 (paginationJSX 중앙 영역 교체)
기존 5139~5182행의 <div className="flex items-center gap-2 sm:gap-4"> 블록을 다음으로 교체:
import { PageGroupNav } from "@/components/common/PageGroupNav";
// paginationJSX 내부 중앙 영역
<PageGroupNav
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
disabled={loading}
/>
좌측(페이지크기 입력), 우측(내보내기/새로고침)은 기존 코드 그대로 유지.
설계 원칙
- 레이아웃 무관 컴포넌트: PageGroupNav는 순수 컨트롤만 담당. 외부 레이아웃(좌측/우측 부가 기능)을 강제하지 않음
- 기존 동작 무변경:
handlePageChange함수는 수정하지 않음. 좌측/우측 영역도 변경하지 않음 - 고정 슬롯 레이아웃: 페이지 번호 영역은 항상
groupSize개(기본 10) 슬롯 고정. 마지막 단락에서 부족하면 빈 div로 채움 - 고정 너비 버튼: 모든 번호 버튼은
w-8 sm:w-9고정. 1자리/2자리/3자리에 관계없이 동일 - 위치 불변:
< ><< >>버튼을 연속 클릭해도 모든 버튼의 위치가 절대 변하지 않음 - 현재 페이지 강조:
variant="default"(primary) +ring-2 ring-primary font-bold, 나머지variant="outline" - 엣지 케이스: totalPages가 0이거나 1일 때도 정상 동작 (
safeTotal = Math.max(1, totalPages)) - 빈 슬롯 접근성: 빈 슬롯에
cursor-default적용 (클릭 가능한 것처럼 보이지 않게) - 단계적 적용: 1단계에서 v2-table-list로 검증 후, 2단계에서 나머지 사용처에 점진 적용
추가 구현: 표시갯수(pageSize) 캐시 정책
문제
기존 pageSize는 onConfigChange로 부모에 전파되어 DB에 저장되거나, localStorage에 저장되어 새로고침해도 사용자가 변경한 값이 남아있었음.
해결
| 항목 | 정책 |
|---|---|
| 저장소 | sessionStorage (탭 닫으면 자동 소멸) |
| 키 구조 | pageSize_{tabId}_{tableName} (탭별 격리) |
| 기본값 | 20 |
| DB 전파 | 안 함 (onConfigChange 제거) |
| F5 새로고침 | 활성 탭 캐시 삭제 → 기본값 20 |
| 탭 바 새로고침 | 활성 탭 캐시 삭제 → 기본값 20 |
| 비활성 탭 전환 | 캐시에서 복원 |
| 입력 UX | onChange는 표시만, onBlur/Enter로 실제 적용 |
테이블 캐시 탭 격리
동일한 정책을 테이블 관련 캐시 전체에 적용:
| 키 | 구조 |
|---|---|
tableState_{tabId}_{tableName} |
컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터 |
pageSize_{tabId}_{tableName} |
표시갯수 |
filterSettings_{tabId}_{base} |
검색 필터 설정 |
groupSettings_{tabId}_{base} |
그룹 설정 |
사용자 설정(컬럼 가시성/순서/정렬 상태)은 localStorage에 유지 (세션 간 보존).