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

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