390 lines
15 KiB
Markdown
390 lines
15 KiB
Markdown
# [계획서] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트
|
|
|
|
> 관련 문서: [맥락노트](./PGN[맥락]-페이징-단락이동.md) | [체크리스트](./PGN[체크]-페이징-단락이동.md)
|
|
|
|
## 개요
|
|
|
|
페이지네이션의 핵심 컨트롤(`<< < [번호들] > >>`)을 **재사용 가능한 공통 컴포넌트 `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행)
|
|
|
|
```tsx
|
|
{/* 중앙 페이지네이션 컨트롤 */}
|
|
<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안)
|
|
|
|
```mermaid
|
|
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 내부 데이터 흐름
|
|
|
|
```mermaid
|
|
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 공통 컴포넌트
|
|
|
|
```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">` 블록을 다음으로 교체:
|
|
|
|
```tsx
|
|
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에 유지 (세션 간 보존).
|