ERP-node/docs/ycshin-node/PGN[계획]-페이징-단락이동.md

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에 유지 (세션 간 보존).