docs: Update pagination navigation documentation and remove obsolete components
- Deleted the outdated `PageGroupNav` component and its related documentation. - Introduced a new document for the direct input navigation feature in pagination, detailing the rationale for the change and the new user experience. - Updated the checklist to reflect the completion of the new pagination input feature and its implementation steps. These changes enhance the clarity and usability of the pagination system in the project.
This commit is contained in:
parent
51e1abee2b
commit
d9611f234e
|
|
@ -1,389 +0,0 @@
|
|||
# [계획서] 페이징 단락(그룹) 번호 네비게이션 - 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에 유지 (세션 간 보존).
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
# [계획서] 페이징 - 페이지 번호 직접 입력 네비게이션
|
||||
|
||||
> 관련 문서: [맥락노트](./PGN[맥락]-페이징-직접입력.md) | [체크리스트](./PGN[체크]-페이징-직접입력.md)
|
||||
|
||||
## 개요
|
||||
|
||||
v2-table-list 컴포넌트의 하단 페이지네이션 중앙 영역에서, 현재 페이지 번호를 **읽기 전용 텍스트**에서 **입력 가능한 필드**로 변경합니다.
|
||||
사용자가 원하는 페이지 번호를 키보드로 직접 입력하여 빠르게 이동할 수 있게 합니다.
|
||||
|
||||
### 이전 설계(10개 번호 버튼 그룹) 폐기 사유
|
||||
|
||||
- 10개 버튼은 공간을 많이 차지하고, 모바일에서 렌더링이 어려움
|
||||
- 고정 슬롯/고정 너비 등 복잡한 레이아웃 제약이 발생
|
||||
- 입력 필드 방식이 더 직관적이고 공간 효율적
|
||||
|
||||
---
|
||||
|
||||
## 변경 전 → 변경 후
|
||||
|
||||
### 페이지네이션 UI
|
||||
|
||||
```
|
||||
변경 전: [<<] [<] 1 / 38 [>] [>>] ← 읽기 전용 텍스트
|
||||
변경 후: [<<] [<] [ 15 ] / 49 [>] [>>] ← 입력 가능 필드
|
||||
```
|
||||
|
||||
| 버튼 | 동작 (변경 없음) |
|
||||
|------|-----------------|
|
||||
| `<<` | 첫 페이지(1)로 이동 |
|
||||
| `<` | 이전 페이지(`currentPage - 1`)로 이동 |
|
||||
| 중앙 | **입력 필드** `/` **총 페이지** — 사용자가 원하는 페이지 번호를 직접 입력 |
|
||||
| `>` | 다음 페이지(`currentPage + 1`)로 이동 |
|
||||
| `>>` | 마지막 페이지(`totalPages`)로 이동 |
|
||||
|
||||
### 입력 필드 동작 규칙
|
||||
|
||||
| 동작 | 설명 |
|
||||
|------|------|
|
||||
| 클릭 | 입력 필드에 포커스, 기존 숫자 전체 선택(select all) |
|
||||
| 숫자 입력 | 자유롭게 타이핑 가능 (입력 중에는 페이지 이동 안 함) |
|
||||
| Enter | 입력한 페이지로 이동 + 포커스 해제 |
|
||||
| 포커스 아웃 (blur) | 입력한 페이지로 이동 |
|
||||
| 유효 범위 보정 | 1 미만 → 1, totalPages 초과 → totalPages, 빈 값/비숫자 → 현재 페이지 유지 |
|
||||
| `< >` 클릭 | 기존대로 한 페이지씩 이동 (입력 필드 값도 갱신) |
|
||||
| `<< >>` 클릭 | 기존대로 첫/끝 페이지 이동 (입력 필드 값도 갱신) |
|
||||
|
||||
### 비활성화 조건 (기존과 동일)
|
||||
|
||||
- `<<` `<` : `currentPage === 1`
|
||||
- `>` `>>` : `currentPage >= totalPages`
|
||||
|
||||
---
|
||||
|
||||
## 시각적 동작 예시
|
||||
|
||||
총 49페이지 기준:
|
||||
|
||||
| 사용자 동작 | 입력 필드 표시 | 결과 |
|
||||
|------------|---------------|------|
|
||||
| 초기 상태 | `1 / 49` | 1페이지 표시 |
|
||||
| 입력 필드 클릭 | `[1]` 전체 선택됨 | 타이핑 대기 |
|
||||
| `28` 입력 후 Enter | `28 / 49` | 28페이지로 이동 |
|
||||
| `0` 입력 후 Enter | `1 / 49` | 1로 보정 |
|
||||
| `999` 입력 후 Enter | `49 / 49` | 49로 보정 |
|
||||
| 빈 값으로 blur | `28 / 49` | 이전 페이지(28) 유지 |
|
||||
| `abc` 입력 후 Enter | `28 / 49` | 이전 페이지(28) 유지 |
|
||||
| `>` 클릭 | `29 / 49` | 29페이지로 이동 |
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 데이터 흐름
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["currentPage (state, 단일 소스)"] --> B["입력 필드 표시값 (pageInputValue)"]
|
||||
B -->|"사용자 타이핑"| C["pageInputValue 갱신 (표시만)"]
|
||||
C -->|"Enter 또는 blur"| D["유효 범위 보정 (1~totalPages)"]
|
||||
D -->|"보정된 값"| E[handlePageChange]
|
||||
E --> F["setCurrentPage → useEffect → fetchTableDataDebounced"]
|
||||
F --> G[백엔드 API 호출]
|
||||
G --> H[데이터 갱신]
|
||||
H --> A
|
||||
|
||||
I["<< < > >> 클릭"] --> E
|
||||
J["페이지크기 변경"] --> K["setCurrentPage(1) + setLocalPageSize + onConfigChange"]
|
||||
K --> F
|
||||
```
|
||||
|
||||
### 페이징 바 레이아웃
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ [페이지크기 입력] │ << < [__입력__] / n > >> │ [내보내기][새로고침] │
|
||||
│ 좌측(유지) │ 중앙(입력필드 교체) │ 우측(유지) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상 파일
|
||||
|
||||
| 구분 | 파일 | 변경 내용 |
|
||||
|------|------|----------|
|
||||
| 수정 | `TableListComponent.tsx` | (1) `pageInputValue` 상태 + `useEffect` 동기화 + `commitPageInput` 핸들러 추가 |
|
||||
| | | (2) paginationJSX 중앙 `<span>` → `<input>` + `/` + `<span>` 교체 |
|
||||
| | | (3) `handlePageSizeChange`에 `onConfigChange` 호출 추가 |
|
||||
| | | (4) `fetchTableDataInternal`에서 `currentPage`를 단일 소스로 사용 |
|
||||
| | | (5) `useMemo` 의존성에 `pageInputValue` 추가 |
|
||||
| 삭제 | `PageGroupNav.tsx` | 이전 설계 산출물 삭제 (이미 삭제됨) |
|
||||
|
||||
- 신규 파일 생성 없음
|
||||
- 백엔드 변경 없음, DB 변경 없음
|
||||
- v2-table-list를 사용하는 **모든 동적 화면**에 자동 적용
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- **최소 변경**: `<span>` 1개를 `<input>` + 유효성 검증으로 교체. 나머지 전부 유지
|
||||
- **기존 버튼 동작 무변경**: `<< < > >>` 4개 버튼의 onClick/disabled 로직은 그대로
|
||||
- **`handlePageChange` 재사용**: 기존 함수를 그대로 호출
|
||||
- **입력 중 페이지 이동 안 함**: onChange는 표시만 변경, Enter/blur로 실제 적용
|
||||
- **유효 범위 자동 보정**: 1 미만 → 1, totalPages 초과 → totalPages, 비숫자 → 현재 값 유지
|
||||
- **포커스 시 전체 선택**: 클릭하면 바로 타이핑 가능
|
||||
- **`currentPage`가 단일 소스**: fetch 시 `tableConfig.pagination?.currentPage` 대신 로컬 `currentPage`만 사용 (비동기 전파 문제 방지)
|
||||
- **페이지크기 변경 시 1페이지로 리셋**: `handlePageSizeChange`가 `onConfigChange`를 호출하여 부모/백엔드 동기화
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
# [맥락노트] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트
|
||||
|
||||
> 관련 문서: [계획서](./PGN[계획]-페이징-단락이동.md) | [체크리스트](./PGN[체크]-페이징-단락이동.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 현재 페이지네이션은 `1 / 38` 텍스트만 표시하고 `< >`로 한 페이지씩 이동
|
||||
- 수십 페이지가 있을 때 원하는 페이지로 빠르게 이동할 수 없음
|
||||
- 페이지 번호를 직접 클릭할 수 있어야 UX가 개선됨
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. 공통 컴포넌트로 분리 (C안)
|
||||
|
||||
- **결정**: `PageGroupNav.tsx`라는 순수 컨트롤 컴포넌트를 별도 파일로 생성
|
||||
- **근거**: 프로젝트에 페이징이 15곳 이상 존재. 인라인 수정하면 같은 로직을 복사해야 함
|
||||
- **대안 검토 A**: v2-table-list 인라인만 수정 → 기각 (미래 확장 시 복사-붙여넣기 기술 부채)
|
||||
- **대안 검토 B**: 기존 `Pagination.tsx` 업그레이드 → 기각 (전체 행 레이아웃이 포함되어 v2-table-list와 레이아웃 충돌)
|
||||
- **대안 검토 D**: 전체 한번에 적용 → 기각 (12파일 동시 수정은 블래스트 반경이 큼)
|
||||
|
||||
### 2. 레이아웃 무관 설계
|
||||
|
||||
- **결정**: PageGroupNav는 `<< < [번호들] > >>`만 렌더링. 외부 레이아웃(페이지크기, 내보내기 등)을 포함하지 않음
|
||||
- **근거**: 사용처마다 레이아웃이 다름. v2-table-list는 좌측(페이지크기)+중앙(컨트롤)+우측(내보내기), Pagination.tsx는 좌측(페이지정보)+우측(크기선택+컨트롤). 레이아웃을 강제하면 props 분기가 증가하여 복잡해짐
|
||||
|
||||
### 3. 10개 단위 단락(그룹)
|
||||
|
||||
- **결정**: 페이지를 10개씩 묶어 하나의 단락으로 취급
|
||||
- **근거**: 사용자에게 익숙한 패턴 (네이버, 구글 등). 5개는 너무 적고, 20개는 너무 많음
|
||||
- **확장성**: `groupSize` props로 기본값 10을 변경 가능하게 설계
|
||||
|
||||
### 4. `< >` = 단락 이동, `<< >>` = 첫/끝 단락
|
||||
|
||||
- **결정**: `<`는 이전 단락 첫 페이지, `>`는 다음 단락 첫 페이지. `<<`는 1페이지, `>>`는 마지막 단락 첫 페이지
|
||||
- **근거**: 사용자 요청. 기존의 "한 페이지씩 이동"은 번호 클릭으로 대체됨
|
||||
- **주의**: `>>`는 마지막 **페이지**가 아닌 마지막 **단락의 첫 페이지**로 이동. 예: 총 38페이지일 때 `>>` 클릭 → 31페이지 선택 (38이 아님)
|
||||
|
||||
### 5. 고정 슬롯 + 고정 너비
|
||||
|
||||
- **결정**: 항상 10개 슬롯을 렌더링하고, 모든 버튼은 동일한 고정 너비(`w-8 sm:w-9`)
|
||||
- **근거**: `< >` 버튼을 연속 클릭할 때 번호 자릿수(1자리→2자리)나 페이지 수(10개→8개) 변화로 버튼 위치가 흔들리면 안 됨
|
||||
- **구현**: 마지막 단락에서 페이지가 10개 미만이면 남은 슬롯은 동일 크기의 빈 `<div>`로 채움
|
||||
|
||||
### 6. 단계적 적용 (1단계: v2-table-list만)
|
||||
|
||||
- **결정**: 이번 작업은 v2-table-list에만 적용. 나머지는 별도 작업으로 점진 적용
|
||||
- **근거**: 15곳 동시 수정은 리스크가 높음. v2-table-list가 가장 많이 사용되므로 여기서 검증 후 확산
|
||||
|
||||
### 7. 비활성화 기준은 단락 기준
|
||||
|
||||
- **결정**: `<< <`는 첫 번째 단락일 때 비활성화 (currentPage === 1이 아님). `> >>`는 마지막 단락일 때 비활성화
|
||||
- **근거**: 기존은 currentPage 기준이었지만, 단락 이동이므로 단락 기준으로 변경이 자연스러움. 첫 단락 안에서 5페이지에 있어도 `<`는 비활성화
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 생성 | `frontend/components/common/PageGroupNav.tsx` | 페이지 그룹 네비게이션 공통 컴포넌트 |
|
||||
| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | paginationJSX 중앙 영역 교체 (5139~5182행) |
|
||||
| 참고 | `frontend/components/common/Pagination.tsx` | 기존 공통 페이지네이션 (이번에 수정 안 함) |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### 단락 계산 공식
|
||||
|
||||
```
|
||||
groupSize = 10 (기본값)
|
||||
currentGroupIndex = Math.floor((currentPage - 1) / groupSize)
|
||||
groupStartPage = currentGroupIndex * groupSize + 1
|
||||
groupEndPage = Math.min(groupStartPage + groupSize - 1, totalPages)
|
||||
|
||||
lastGroupIndex = Math.floor((totalPages - 1) / groupSize)
|
||||
lastGroupStartPage = lastGroupIndex * groupSize + 1
|
||||
|
||||
isFirstGroup = currentGroupIndex === 0
|
||||
isLastGroup = currentGroupIndex === lastGroupIndex
|
||||
```
|
||||
|
||||
### 고정 슬롯 배열 생성
|
||||
|
||||
```
|
||||
slots = [groupStart, groupStart+1, ..., groupEnd, null, null, ...] (총 groupSize개)
|
||||
예: 단락 31~38 → [31, 32, 33, 34, 35, 36, 37, 38, null, null]
|
||||
```
|
||||
|
||||
### handlePageChange 호출 흐름
|
||||
|
||||
```
|
||||
PageGroupNav onPageChange(page)
|
||||
→ TableListComponent handlePageChange(newPage)
|
||||
→ setCurrentPage(newPage)
|
||||
→ useEffect 트리거 → 백엔드 API 재호출 (page 파라미터 변경)
|
||||
```
|
||||
|
||||
- handlePageChange는 `setCurrentPage`만 호출. `onConfigChange` 전파는 제거됨 (pageSize/currentPage는 세션 전용)
|
||||
- handlePageChange는 기존 함수 그대로 사용. PageGroupNav가 올바른 page 값을 전달하기만 하면 됨
|
||||
|
||||
---
|
||||
|
||||
## 추가 결정: 표시갯수(pageSize) 캐시 정책
|
||||
|
||||
### 8. pageSize는 세션 전용, DB에 저장 안 함
|
||||
|
||||
- **결정**: pageSize를 `onConfigChange`로 부모/DB에 전파하지 않음. sessionStorage에만 탭별로 저장
|
||||
- **근거**: pageSize는 일시적 탐색 설정이지 영구 화면 설정이 아님. DB에 저장하면 다른 사용자에게도 영향이 가고, 새로고침 시 의도치 않은 값이 남음
|
||||
- **F5 정책**: 활성 탭은 캐시 삭제 → 기본값 20으로 fresh start. 비활성 탭은 캐시 유지
|
||||
|
||||
### 9. 테이블 캐시는 탭별 격리 (탭 ID 스코프)
|
||||
|
||||
- **결정**: `tableState_*`, `pageSize_*`, `filterSettings_*`, `groupSettings_*` 키를 `{prefix}_{tabId}_{tableName}` 구조로 변경
|
||||
- **근거**: 같은 테이블이 여러 탭에서 열릴 수 있음. 탭 구분 없으면 "활성 탭 캐시만 삭제" 불가능
|
||||
- **구현**: `useTabId()` 훅으로 현재 탭 ID 접근. `clearTabCache(tabId)`에서 해당 탭의 모든 관련 키 일괄 삭제
|
||||
|
||||
### 10. localStorage vs sessionStorage 분류
|
||||
|
||||
- **결정**: 탭별 캐시는 sessionStorage, 사용자 설정은 localStorage
|
||||
- **근거**: 탭별 캐시(컬럼 너비 캐시, 필터, 그룹, pageSize)는 탭 닫으면 무의미. 사용자 설정(컬럼 가시성, 순서, 정렬)은 사용자가 의도적으로 변경한 환경설정이므로 세션 간 보존
|
||||
- **분류**:
|
||||
- sessionStorage: `tableState_*`, `pageSize_*`, `filterSettings_*`, `groupSettings_*`
|
||||
- localStorage: `table_column_visibility_*`, `table_sort_state_*`, `table_column_order_*`
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
# [맥락노트] 페이징 - 페이지 번호 직접 입력 네비게이션
|
||||
|
||||
> 관련 문서: [계획서](./PGN[계획]-페이징-직접입력.md) | [체크리스트](./PGN[체크]-페이징-직접입력.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 현재 페이지네이션은 `1 / 38` 읽기 전용 텍스트만 표시
|
||||
- 수십 페이지가 있을 때 원하는 페이지로 빠르게 이동할 수 없음 (`>` 연타 필요)
|
||||
- 페이지 번호를 직접 입력하여 즉시 이동할 수 있어야 UX가 개선됨
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. 10개 번호 버튼 그룹 → 입력 필드로 설계 변경
|
||||
|
||||
- **결정**: 이전 설계(10개 페이지 번호 버튼 나열)를 폐기하고, 기존 `현재/총` 텍스트에서 현재 부분을 입력 필드로 교체
|
||||
- **근거**: 10개 버튼은 공간을 많이 차지하고 고정 슬롯/고정 너비 등 복잡한 레이아웃 제약이 발생. 입력 필드 방식이 더 직관적이고 공간 효율적
|
||||
- **이전 산출물**: `PageGroupNav.tsx` → 삭제 완료
|
||||
|
||||
### 2. `<< < > >>` 버튼 동작 유지
|
||||
|
||||
- **결정**: 4개 화살표 버튼의 동작은 기존과 완전히 동일하게 유지
|
||||
- **근거**: 입력 필드가 "원하는 페이지로 점프" 역할을 하므로, 버튼은 기존의 순차 이동(+1/-1, 첫/끝) 그대로 유지하는 것이 자연스러움
|
||||
|
||||
### 3. 입력 중에는 페이지 이동 안 함
|
||||
|
||||
- **결정**: onChange는 입력 필드 표시만 변경. Enter 또는 blur로 실제 페이지 이동
|
||||
- **근거**: `28`을 입력하려면 `2`를 먼저 치는데, `2`에서 바로 이동하면 안 됨
|
||||
|
||||
### 4. 포커스 시 전체 선택 (select all)
|
||||
|
||||
- **결정**: 입력 필드 클릭 시 기존 숫자를 전체 선택
|
||||
- **근거**: 사용자가 "15페이지로 가고 싶다" → 클릭 → 바로 `15` 타이핑. 기존 값을 지우는 추가 동작 불필요
|
||||
|
||||
### 5. 유효 범위 자동 보정
|
||||
|
||||
- **결정**: 1 미만 → 1, totalPages 초과 → totalPages, 빈 값/비숫자 → 현재 페이지 유지
|
||||
- **근거**: 에러 메시지보다 자동 보정이 UX에 유리
|
||||
- **대안 검토**: 입력 자체를 숫자만 허용 → 기각 (백스페이스로 비울 때 불편)
|
||||
|
||||
### 6. `inputMode="numeric"` 사용
|
||||
|
||||
- **결정**: `type="text"` + `inputMode="numeric"`
|
||||
- **근거**: `type="number"`는 브라우저별 스피너 UI가 추가되고, 빈 값 처리가 어려움. `inputMode="numeric"`은 모바일에서 숫자 키보드를 띄우면서 text 입력의 유연성 유지
|
||||
|
||||
### 7. 신규 컴포넌트 분리 안 함
|
||||
|
||||
- **결정**: v2-table-list의 paginationJSX 내부에 인라인으로 구현
|
||||
- **근거**: 변경이 `<span>` → `<input>` + 핸들러 약 30줄 수준으로 매우 작음
|
||||
|
||||
### 8. `currentPage`를 fetch의 단일 소스로 사용
|
||||
|
||||
- **결정**: `fetchTableDataInternal`에서 `tableConfig.pagination?.currentPage || currentPage` 대신 `currentPage`만 사용
|
||||
- **근거**: `handlePageSizeChange`에서 `setCurrentPage(1)` + `onConfigChange(...)` 호출 시, `onConfigChange`를 통한 부모의 `tableConfig` 갱신은 다음 렌더 사이클에서 전파됨. fetch가 실행되는 시점에 `tableConfig.pagination?.currentPage`가 아직 이전 값(예: 4)이고 truthy이므로 로컬 `currentPage`(1) 대신 4를 사용하게 되는 문제 발생. 로컬 `currentPage`는 `setCurrentPage`로 즉시 갱신되므로 이 문제가 없음
|
||||
- **발견 과정**: 페이지 크기를 20→40으로 변경하면 1페이지로 설정되지만 리스트가 빈 상태로 표시되는 버그로 발견
|
||||
|
||||
### 9. `handlePageSizeChange`에서 `onConfigChange` 호출 필수
|
||||
|
||||
- **결정**: 페이지 크기 변경 시 `onConfigChange`로 `{ pageSize, currentPage: 1 }`을 부모에게 전달
|
||||
- **근거**: 기존 코드는 `setLocalPageSize` + `setCurrentPage(1)`만 호출하고 `onConfigChange`를 호출하지 않았음. 이로 인해 부모 컴포넌트의 `tableConfig.pagination`이 갱신되지 않아 후속 동작에서 stale 값 참조 가능
|
||||
- **발견 과정**: 위 8번과 같은 맥락에서 발견
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | paginationJSX 중앙 입력 필드 + fetch 소스 수정 |
|
||||
| 삭제 | `frontend/components/common/PageGroupNav.tsx` | 이전 설계 산출물 (삭제 완료) |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### 로컬 입력 상태와 실제 페이지 상태 분리
|
||||
|
||||
```
|
||||
pageInputValue (string) — 입력 필드에 표시되는 값 (사용자가 타이핑 중일 수 있음)
|
||||
currentPage (number) — 실제 현재 페이지 (API 호출의 단일 소스)
|
||||
|
||||
동기화:
|
||||
- currentPage 변경 시 → useEffect → setPageInputValue(String(currentPage))
|
||||
- Enter/blur 시 → commitPageInput → parseInt + clamp → handlePageChange(보정된 값)
|
||||
```
|
||||
|
||||
### handlePageChange 호출 흐름
|
||||
|
||||
```
|
||||
입력 필드 Enter/blur
|
||||
→ commitPageInput()
|
||||
→ parseInt + clamp(1, totalPages)
|
||||
→ handlePageChange(clampedPage)
|
||||
→ setCurrentPage(clampedPage) + onConfigChange
|
||||
→ useEffect 트리거 → fetchTableDataDebounced
|
||||
→ fetchTableDataInternal(page = currentPage)
|
||||
→ 백엔드 API 호출
|
||||
```
|
||||
|
||||
### handlePageSizeChange 호출 흐름
|
||||
|
||||
```
|
||||
좌측 페이지크기 입력 onChange/onBlur
|
||||
→ handlePageSizeChange(newSize)
|
||||
→ setLocalPageSize(newSize)
|
||||
→ setCurrentPage(1)
|
||||
→ sessionStorage 저장
|
||||
→ onConfigChange({ pageSize: newSize, currentPage: 1 })
|
||||
→ useEffect 트리거 → fetchTableDataDebounced
|
||||
→ fetchTableDataInternal(page = 1, pageSize = newSize)
|
||||
→ 백엔드 API 호출
|
||||
```
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
# [체크리스트] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트
|
||||
|
||||
> 관련 문서: [계획서](./PGN[계획]-페이징-단락이동.md) | [맥락노트](./PGN[맥락]-페이징-단락이동.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (완료)
|
||||
- 현재 단계: 4단계 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 1단계: PageGroupNav 공통 컴포넌트 생성
|
||||
|
||||
- [x] `frontend/components/common/PageGroupNav.tsx` 파일 생성
|
||||
- [x] `PageGroupNavProps` 인터페이스 정의 (currentPage, totalPages, onPageChange, disabled, groupSize)
|
||||
- [x] 단락 계산 로직 구현 (currentGroupIndex, groupStartPage, lastGroupIndex 등)
|
||||
- [x] 10개 고정 슬롯 배열 생성 (빈 슬롯은 null)
|
||||
- [x] `<<` 첫 단락 버튼 (isFirstGroup일 때 비활성화)
|
||||
- [x] `<` 이전 단락 버튼 (isFirstGroup일 때 비활성화)
|
||||
- [x] 페이지 번호 버튼 렌더링 (현재 페이지 variant="default", 나머지 variant="outline")
|
||||
- [x] 빈 슬롯 렌더링 (동일 크기 빈 div)
|
||||
- [x] `>` 다음 단락 버튼 (isLastGroup일 때 비활성화)
|
||||
- [x] `>>` 마지막 단락 버튼 (isLastGroup일 때 비활성화, 마지막 단락 첫 페이지로 이동)
|
||||
- [x] 고정 너비 스타일 적용 (h-8 w-8 sm:h-9 sm:w-9)
|
||||
- [x] totalPages가 0 또는 1일 때 엣지 케이스 처리
|
||||
|
||||
### 2단계: v2-table-list 통합
|
||||
|
||||
- [x] `TableListComponent.tsx`에 `PageGroupNav` import 추가
|
||||
- [x] `paginationJSX`의 중앙 컨트롤 영역(5139~5182행)을 `<PageGroupNav>` 호출로 교체
|
||||
- [x] props 연결: currentPage, totalPages, handlePageChange, loading
|
||||
- [x] 좌측(페이지크기 입력) 영역 변경 없음 확인
|
||||
- [x] 우측(내보내기/새로고침) 영역 변경 없음 확인
|
||||
|
||||
### 3단계: 검증
|
||||
|
||||
- [x] 품목정보 화면에서 페이지 번호 클릭 동작 확인
|
||||
- [x] `< >` 단락 이동 동작 확인 (1~10 → 11~20 → ...)
|
||||
- [x] `<< >>` 첫/끝 단락 이동 동작 확인
|
||||
- [x] `>>` 클릭 시 마지막 단락의 첫 페이지 선택 확인 (마지막 페이지가 아님)
|
||||
- [x] 첫 단락에서 `<< <` 비활성화 확인
|
||||
- [x] 마지막 단락에서 `> >>` 비활성화 확인
|
||||
- [x] 고정 슬롯: 단락 이동 시 버튼 위치 변동 없음 확인
|
||||
- [x] 고정 너비: 1자리/2자리 숫자에서 버튼 크기 동일 확인
|
||||
- [x] 마지막 단락이 10개 미만일 때 빈 슬롯으로 위치 고정 확인
|
||||
- [x] totalPages가 1일 때 정상 동작 확인 (단일 페이지)
|
||||
- [x] 로딩 중 모든 버튼 비활성화 확인
|
||||
- [x] 페이지 크기 변경 시 첫 페이지로 리셋 확인
|
||||
|
||||
### 4단계: 정리
|
||||
|
||||
- [x] 린트 에러 없음 확인
|
||||
- [x] 이 체크리스트 완료 표시 업데이트
|
||||
|
||||
### 5단계: 표시갯수(pageSize) 캐시 정책
|
||||
|
||||
- [x] 표시갯수 입력 시 onChange → 표시만 변경, 실제 적용은 onBlur/Enter
|
||||
- [x] 입력 필드 값 string 타입으로 변경 (백스페이스로 비우기 가능)
|
||||
- [x] 표시갯수 변경 시 1페이지로 리셋 + 데이터 정상 로드
|
||||
- [x] onConfigChange로 DB/부모 전파 제거 (pageSize는 세션 전용)
|
||||
- [x] localStorage → sessionStorage 전환 (탭 닫으면 자동 소멸)
|
||||
- [x] 키를 탭 ID 스코프로 변경 (`pageSize_{tabId}_{tableName}`)
|
||||
- [x] F5 새로고침 시 활성 탭 캐시 삭제 → 기본값 20 초기화
|
||||
- [x] 탭 바 새로고침 버튼 시 캐시 삭제 → 기본값 20 초기화
|
||||
- [x] 비활성 탭 캐시 유지 (탭 전환 시 복원)
|
||||
|
||||
### 6단계: 테이블 캐시 탭 격리
|
||||
|
||||
- [x] tableStateKey 탭 ID 스코프 (`tableState_{tabId}_{tableName}`) + sessionStorage
|
||||
- [x] filterSettingKey 탭 ID 스코프 (`filterSettings_{tabId}_{base}`) + sessionStorage
|
||||
- [x] groupSettingKey 탭 ID 스코프 (`groupSettings_{tabId}_{base}`) + sessionStorage
|
||||
- [x] clearTabCache 확장 (tableState_/pageSize_/filterSettings_/groupSettings_ 일괄 삭제)
|
||||
- [x] TabContent.tsx 모듈 레벨 플래그로 F5 감지 → 활성 탭 캐시만 삭제
|
||||
- [x] tabStore.refreshTab에 clearTabCache 추가
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 |
|
||||
| 2026-03-11 | 1단계(PageGroupNav 생성) + 2단계(v2-table-list 통합) + 4단계(린트) 완료. 3단계(수동 검증)은 브라우저에서 확인 필요 |
|
||||
| 2026-03-11 | 추가 개선: 선택 페이지 강조(ring + font-bold), 빈 슬롯 cursor-default 적용. 3단계 검증 완료. 전체 완료 |
|
||||
| 2026-03-11 | 5단계: pageSize 입력 UX 개선 + 캐시 정책 (sessionStorage + 탭 스코프 + F5/탭새로고침 초기화) |
|
||||
| 2026-03-11 | 6단계: 테이블 전체 캐시를 탭별 격리 (localStorage → sessionStorage + 탭 ID 스코프) |
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# [체크리스트] 페이징 - 페이지 번호 직접 입력 네비게이션
|
||||
|
||||
> 관련 문서: [계획서](./PGN[계획]-페이징-직접입력.md) | [맥락노트](./PGN[맥락]-페이징-직접입력.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (완료)
|
||||
- 현재 단계: 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 1단계: 이전 설계 산출물 정리
|
||||
|
||||
- [x] `frontend/components/common/PageGroupNav.tsx` 삭제
|
||||
- [x] `TableListComponent.tsx`에서 `PageGroupNav` import 제거 (있으면) — 이미 없음
|
||||
|
||||
### 2단계: 입력 필드 구현
|
||||
|
||||
- [x] `pageInputValue` 로컬 상태 추가 (`useState<string>`)
|
||||
- [x] `currentPage` 변경 시 `pageInputValue` 동기화 (`useEffect`)
|
||||
- [x] `commitPageInput` 핸들러 구현 (parseInt + clamp + handlePageChange)
|
||||
- [x] paginationJSX 중앙의 `<span>` → `<input>` + `/` + `<span>` 교체
|
||||
- [x] `inputMode="numeric"` 적용
|
||||
- [x] `onFocus`에 전체 선택 (`e.target.select()`)
|
||||
- [x] `onChange`에 `setPageInputValue` (표시만 변경)
|
||||
- [x] `onKeyDown` Enter에 `commitPageInput` + `blur()`
|
||||
- [x] `onBlur`에 `commitPageInput`
|
||||
- [x] `disabled={loading}` 적용
|
||||
- [x] 기존 좌측 페이지크기 입력과 일관된 스타일 적용
|
||||
|
||||
### 3단계: 버그 수정
|
||||
|
||||
- [x] `handlePageSizeChange`에 `onConfigChange` 호출 추가 (`pageSize` + `currentPage: 1` 전달)
|
||||
- [x] `fetchTableDataInternal`에서 `currentPage`를 단일 소스로 변경 (stale `tableConfig.pagination?.currentPage` 문제 해결)
|
||||
- [x] `useCallback` 의존성에서 `tableConfig.pagination?.currentPage` 제거
|
||||
- [x] `useMemo` 의존성에 `pageInputValue` 추가
|
||||
|
||||
### 4단계: 검증
|
||||
|
||||
- [x] 입력 필드에 숫자 입력 후 Enter → 해당 페이지로 이동
|
||||
- [x] 입력 필드에 숫자 입력 후 포커스 아웃 → 해당 페이지로 이동
|
||||
- [x] 0 입력 → 1로 보정
|
||||
- [x] totalPages 초과 입력 → totalPages로 보정
|
||||
- [x] 빈 값으로 blur → 현재 페이지 유지
|
||||
- [x] 비숫자(abc) 입력 후 Enter → 현재 페이지 유지
|
||||
- [x] 입력 필드 클릭 시 기존 숫자 전체 선택 확인
|
||||
- [x] `< >` 버튼 클릭 시 입력 필드 값도 갱신 확인
|
||||
- [x] `<< >>` 버튼 클릭 시 입력 필드 값도 갱신 확인
|
||||
- [x] 로딩 중 입력 필드 비활성화 확인
|
||||
- [x] 좌측 페이지크기 입력과 스타일 일관성 확인
|
||||
- [x] 기존 `<< < > >>` 버튼 동작 변화 없음 확인
|
||||
- [x] 페이지크기 변경 시 1페이지로 리셋 + 데이터 정상 로딩 확인
|
||||
|
||||
### 5단계: 정리
|
||||
|
||||
- [x] 린트 에러 없음 확인 (기존 에러만 존재, 신규 없음)
|
||||
- [x] 문서(계획서/맥락노트/체크리스트) 최신화 완료
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-11 | 최초 설계: 10개 번호 버튼 그룹 (PageGroupNav) |
|
||||
| 2026-03-11 | 설계 변경: 입력 필드 방식으로 전면 재작성 |
|
||||
| 2026-03-11 | 구현 완료: 입력 필드 + 유효성 검증 |
|
||||
| 2026-03-11 | 버그 수정: 페이지크기 변경 시 빈 데이터 문제 (onConfigChange 누락 + stale currentPage) |
|
||||
| 2026-03-11 | 문서 최신화: 버그 수정 내역 반영, 코드 설계 섹션 제거 (구현 완료) |
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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;
|
||||
|
||||
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={cn(
|
||||
"h-8 w-8 p-0 text-xs sm:h-9 sm:w-9 sm:text-sm",
|
||||
page === currentPage &&
|
||||
"font-bold ring-2 ring-primary ring-offset-1 ring-offset-background",
|
||||
)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
) : (
|
||||
<div key={idx} className="h-8 w-8 cursor-default 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,16 +2,16 @@
|
|||
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { TableListConfig, ColumnConfig } from "./types";
|
||||
import type { WebType } from "@/types/common";
|
||||
import { WebType } from "@/types/common";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { codeCache } from "@/lib/caching/codeCache";
|
||||
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import { getFilePreviewUrl } from "@/lib/api/file";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
|
||||
// 🖼️ 테이블 셀 이미지 썸네일 컴포넌트
|
||||
// objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용
|
||||
|
|
@ -156,8 +156,13 @@ declare global {
|
|||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
RefreshCw,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
TableIcon,
|
||||
Settings,
|
||||
X,
|
||||
Layers,
|
||||
ChevronDown,
|
||||
|
|
@ -170,14 +175,14 @@ import {
|
|||
Edit,
|
||||
CheckSquare,
|
||||
Trash2,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import * as XLSX from "xlsx";
|
||||
import { FileText } from "lucide-react";
|
||||
import { FileText, ChevronRightIcon } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { PageGroupNav } from "@/components/common/PageGroupNav";
|
||||
import { tableDisplayStore } from "@/stores/tableDisplayStore";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -189,6 +194,7 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters";
|
||||
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
||||
import { CardModeRenderer } from "./CardModeRenderer";
|
||||
import { TableOptionsModal } from "@/components/common/TableOptionsModal";
|
||||
|
|
@ -196,7 +202,7 @@ import { useTableOptions } from "@/contexts/TableOptionsContext";
|
|||
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
||||
|
||||
// ========================================
|
||||
|
|
@ -401,7 +407,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
const currentTabId = useTabId();
|
||||
|
||||
const buttonColor = getAdaptiveLabelColor(component.style?.labelColor);
|
||||
const buttonColor = component.style?.labelColor || "#212121";
|
||||
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
|
||||
|
||||
const gridColumns = component.gridColumns || 1;
|
||||
|
|
@ -429,13 +435,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: isDesignMode ? "300px" : "100%",
|
||||
...style,
|
||||
// 런타임에서는 DB의 고정 px 크기를 무시하고 부모에 맞춤
|
||||
...(!isDesignMode && {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minWidth: 0,
|
||||
}),
|
||||
...style, // style prop이 위의 기본값들을 덮어씀
|
||||
};
|
||||
|
||||
// ========================================
|
||||
|
|
@ -691,6 +691,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [pageInputValue, setPageInputValue] = useState<string>("1");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
|
|
@ -714,20 +715,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const val = sessionStorage.getItem(key);
|
||||
if (val) return Number(val);
|
||||
}
|
||||
return 20;
|
||||
});
|
||||
const [pageSizeInputValue, setPageSizeInputValue] = useState<string>(() => {
|
||||
const key =
|
||||
currentTabId && tableConfig.selectedTable
|
||||
? `pageSize_${currentTabId}_${tableConfig.selectedTable}`
|
||||
: tableConfig.selectedTable
|
||||
? `pageSize_${tableConfig.selectedTable}`
|
||||
: null;
|
||||
if (key) {
|
||||
const val = sessionStorage.getItem(key);
|
||||
if (val) return val;
|
||||
}
|
||||
return "20";
|
||||
return tableConfig.pagination?.pageSize || 20;
|
||||
});
|
||||
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
|
||||
const [columnMeta, setColumnMeta] = useState<
|
||||
|
|
@ -843,7 +831,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (!tableConfig.selectedTable) return null;
|
||||
if (currentTabId) return `tableState_${currentTabId}_${tableConfig.selectedTable}`;
|
||||
return `tableState_${tableConfig.selectedTable}`;
|
||||
}, [tableConfig.selectedTable, currentTabId]);
|
||||
}, [tableConfig.selectedTable]);
|
||||
|
||||
// 🆕 Real-Time Updates 관련 상태
|
||||
const [isRealTimeEnabled] = useState<boolean>((tableConfig as any).realTimeUpdates ?? false);
|
||||
|
|
@ -1647,7 +1635,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const page = currentPage || tableConfig.pagination?.currentPage || 1;
|
||||
const page = currentPage;
|
||||
const pageSize = localPageSize;
|
||||
// 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용
|
||||
const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined;
|
||||
|
|
@ -1910,7 +1898,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
}, [
|
||||
tableConfig.selectedTable,
|
||||
tableConfig.pagination?.currentPage,
|
||||
tableConfig.columns,
|
||||
currentPage,
|
||||
localPageSize,
|
||||
|
|
@ -1945,6 +1932,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage < 1 || newPage > totalPages) return;
|
||||
setCurrentPage(newPage);
|
||||
if (tableConfig.pagination) {
|
||||
tableConfig.pagination.currentPage = newPage;
|
||||
}
|
||||
if (onConfigChange) {
|
||||
onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPageInputValue(String(currentPage));
|
||||
}, [currentPage]);
|
||||
|
||||
const commitPageInput = () => {
|
||||
const parsed = parseInt(pageInputValue, 10);
|
||||
if (isNaN(parsed) || pageInputValue.trim() === "") {
|
||||
setPageInputValue(String(currentPage));
|
||||
return;
|
||||
}
|
||||
const clamped = Math.max(1, Math.min(parsed, totalPages || 1));
|
||||
if (clamped !== currentPage) {
|
||||
handlePageChange(clamped);
|
||||
}
|
||||
setPageInputValue(String(clamped));
|
||||
};
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
|
|
@ -2981,6 +2991,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
headerFilters: Object.fromEntries(
|
||||
Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set<string>)]),
|
||||
),
|
||||
pageSize: localPageSize,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
|
|
@ -3000,6 +3011,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
frozenColumnCount,
|
||||
showGridLines,
|
||||
headerFilters,
|
||||
localPageSize,
|
||||
]);
|
||||
|
||||
// 🆕 State Persistence: 통합 상태 복원
|
||||
|
|
@ -3018,6 +3030,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (state.sortDirection) setSortDirection(state.sortDirection);
|
||||
if (state.groupByColumns) setGroupByColumns(state.groupByColumns);
|
||||
if (state.frozenColumns) {
|
||||
// 체크박스 컬럼이 항상 포함되도록 보장
|
||||
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null;
|
||||
const restoredFrozenColumns =
|
||||
checkboxColumn && !state.frozenColumns.includes(checkboxColumn)
|
||||
|
|
@ -3025,7 +3038,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
: state.frozenColumns;
|
||||
setFrozenColumns(restoredFrozenColumns);
|
||||
}
|
||||
if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount);
|
||||
if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원
|
||||
if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines);
|
||||
if (state.headerFilters) {
|
||||
const filters: Record<string, Set<string>> = {};
|
||||
|
|
@ -3053,8 +3066,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setFrozenColumns([]);
|
||||
setShowGridLines(true);
|
||||
setHeaderFilters({});
|
||||
setLocalPageSize(20);
|
||||
setPageSizeInputValue("20");
|
||||
toast.success("테이블 설정이 초기화되었습니다.");
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 상태 초기화 실패:", error);
|
||||
|
|
@ -4280,8 +4291,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
return (
|
||||
<div className="flex max-w-full items-center gap-1.5 text-sm">
|
||||
<Paperclip className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-primary" title={fileNames}>
|
||||
<Paperclip className="h-4 w-4 flex-shrink-0 text-gray-500" />
|
||||
<span className="truncate text-blue-600" title={fileNames}>
|
||||
{fileNames}
|
||||
</span>
|
||||
{files.length > 1 && <span className="text-muted-foreground flex-shrink-0 text-xs">({files.length})</span>}
|
||||
|
|
@ -4500,6 +4511,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const savedFilters = JSON.parse(saved);
|
||||
setVisibleFilterColumns(new Set(savedFilters));
|
||||
} else {
|
||||
// 초기값: 빈 Set (아무것도 선택 안 함)
|
||||
setVisibleFilterColumns(new Set());
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -5131,16 +5143,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 페이지 크기 변경 핸들러
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
setPageSizeInputValue(String(newSize));
|
||||
setLocalPageSize(newSize);
|
||||
setCurrentPage(1);
|
||||
if (pageSizeKey) {
|
||||
sessionStorage.setItem(pageSizeKey, String(newSize));
|
||||
}
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...tableConfig,
|
||||
pagination: { ...tableConfig.pagination, pageSize: newSize, currentPage: 1 },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const pageSizeOptions = tableConfig.pagination?.pageSizeOptions || [5, 10, 20, 50, 100];
|
||||
|
||||
return (
|
||||
<div className="border-border bg-background relative flex h-14 w-full flex-shrink-0 items-center justify-center border-t-2 px-4 sm:h-[60px] sm:px-6">
|
||||
{/* 좌측: 페이지 크기 입력 */}
|
||||
|
|
@ -5150,20 +5165,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={pageSizeInputValue}
|
||||
value={localPageSize}
|
||||
onChange={(e) => {
|
||||
setPageSizeInputValue(e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = Math.min(10000, Math.max(1, Number(e.target.value) || 10));
|
||||
const value = Math.min(10000, Math.max(1, Number(e.target.value) || 1));
|
||||
handlePageSizeChange(value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const value = Math.min(10000, Math.max(1, Number((e.target as HTMLInputElement).value) || 10));
|
||||
handlePageSizeChange(value);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
onBlur={(e) => {
|
||||
// 포커스 잃을 때 유효 범위로 조정
|
||||
const value = Math.min(10000, Math.max(1, Number(e.target.value) || 10));
|
||||
handlePageSizeChange(value);
|
||||
}}
|
||||
className="border-input bg-background focus:ring-ring h-7 w-14 rounded-md border px-2 text-center text-xs focus:ring-1 focus:outline-none sm:h-8 sm:w-16"
|
||||
/>
|
||||
|
|
@ -5171,12 +5181,68 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 중앙 페이지네이션 컨트롤 */}
|
||||
<PageGroupNav
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
||||
>
|
||||
<ChevronsLeft className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={pageInputValue}
|
||||
onChange={(e) => setPageInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
commitPageInput();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
onBlur={commitPageInput}
|
||||
onFocus={(e) => e.target.select()}
|
||||
disabled={loading}
|
||||
className="border-input bg-background focus:ring-ring h-7 w-10 rounded-md border px-1 text-center text-xs font-medium focus:ring-1 focus:outline-none sm:h-8 sm:w-12 sm:text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs sm:text-sm">/</span>
|
||||
<span className="text-foreground text-xs font-medium sm:text-sm">
|
||||
{totalPages || 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
||||
>
|
||||
<ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 우측 버튼 그룹 */}
|
||||
<div className="absolute right-2 flex items-center gap-1 sm:right-6">
|
||||
|
|
@ -5191,7 +5257,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<div className="flex flex-col gap-1">
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">Excel</div>
|
||||
<Button variant="ghost" size="sm" className="justify-start text-xs" onClick={() => exportToExcel(true)}>
|
||||
<FileSpreadsheet className="mr-2 h-3 w-3 text-emerald-600" />
|
||||
<FileSpreadsheet className="mr-2 h-3 w-3 text-green-600" />
|
||||
전체 Excel 내보내기
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -5201,13 +5267,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onClick={() => exportToExcel(false)}
|
||||
disabled={selectedRows.size === 0}
|
||||
>
|
||||
<FileSpreadsheet className="mr-2 h-3 w-3 text-emerald-600" />
|
||||
<FileSpreadsheet className="mr-2 h-3 w-3 text-green-600" />
|
||||
선택 항목만 ({selectedRows.size}개)
|
||||
</Button>
|
||||
<div className="border-border my-1 border-t" />
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">PDF/인쇄</div>
|
||||
<Button variant="ghost" size="sm" className="justify-start text-xs" onClick={() => exportToPdf(true)}>
|
||||
<FileText className="mr-2 h-3 w-3 text-destructive" />
|
||||
<FileText className="mr-2 h-3 w-3 text-red-600" />
|
||||
전체 PDF 내보내기
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -5217,7 +5283,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onClick={() => exportToPdf(false)}
|
||||
disabled={selectedRows.size === 0}
|
||||
>
|
||||
<FileText className="mr-2 h-3 w-3 text-destructive" />
|
||||
<FileText className="mr-2 h-3 w-3 text-red-600" />
|
||||
선택 항목만 ({selectedRows.size}개)
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -5251,9 +5317,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
exportToExcel,
|
||||
exportToPdf,
|
||||
localPageSize,
|
||||
pageSizeInputValue,
|
||||
onConfigChange,
|
||||
tableConfig,
|
||||
pageInputValue,
|
||||
]);
|
||||
|
||||
// ========================================
|
||||
|
|
@ -5265,7 +5331,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onDragStart: isDesignMode ? onDragStart : undefined,
|
||||
onDragEnd: isDesignMode ? onDragEnd : undefined,
|
||||
draggable: isDesignMode,
|
||||
className: cn("w-full h-full overflow-hidden", className, isDesignMode && "cursor-move"),
|
||||
className: cn("w-full h-full", className, isDesignMode && "cursor-move"), // customer-item-mapping과 동일
|
||||
style: componentStyle,
|
||||
};
|
||||
|
||||
|
|
@ -5335,7 +5401,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", WebkitOverflowScrolling: "touch" }}>
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||
<SingleTableWithSticky
|
||||
data={data}
|
||||
columns={visibleColumns}
|
||||
|
|
@ -5401,7 +5467,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
className="h-7 text-xs"
|
||||
title="Excel 내보내기"
|
||||
>
|
||||
<FileSpreadsheet className="mr-1 h-3 w-3 text-emerald-600" />
|
||||
<FileSpreadsheet className="mr-1 h-3 w-3 text-green-600" />
|
||||
Excel
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -5413,7 +5479,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
className="h-7 text-xs"
|
||||
title="PDF 내보내기"
|
||||
>
|
||||
<FileText className="mr-1 h-3 w-3 text-destructive" />
|
||||
<FileText className="mr-1 h-3 w-3 text-red-600" />
|
||||
PDF
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -5645,7 +5711,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
}}
|
||||
onScroll={handleVirtualScroll}
|
||||
>
|
||||
|
|
@ -5656,7 +5721,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
borderCollapse: "collapse",
|
||||
width: "100%",
|
||||
tableLayout: "fixed",
|
||||
minWidth: "400px",
|
||||
}}
|
||||
>
|
||||
{/* 헤더 (sticky) */}
|
||||
|
|
@ -5874,7 +5938,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
{/* 리사이즈 핸들 (체크박스 제외) */}
|
||||
{columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
|
||||
<div
|
||||
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-primary"
|
||||
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
|
||||
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
|
||||
onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지
|
||||
onMouseDown={(e) => {
|
||||
|
|
@ -6227,11 +6291,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 🆕 배치 편집: 수정된 셀 스타일 (노란 배경)
|
||||
isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40",
|
||||
// 🆕 유효성 에러: 빨간 테두리 및 배경
|
||||
cellValidationError && "bg-destructive/10 ring-2 ring-destructive ring-inset dark:bg-destructive/15",
|
||||
cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40",
|
||||
// 🆕 검색 하이라이트 스타일 (노란 배경)
|
||||
isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50",
|
||||
// 🆕 편집 불가 컬럼 스타일 (연한 회색 배경)
|
||||
column.editable === false && "bg-muted dark:bg-foreground/30",
|
||||
column.editable === false && "bg-gray-50 dark:bg-gray-900/30",
|
||||
)}
|
||||
// 🆕 유효성 에러 툴팁
|
||||
title={cellValidationError || undefined}
|
||||
|
|
@ -6624,7 +6688,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
{/* 행 삭제 */}
|
||||
<button
|
||||
className="hover:bg-destructive hover:text-destructive-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-destructive"
|
||||
className="hover:bg-destructive hover:text-destructive-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-red-600"
|
||||
onClick={async () => {
|
||||
if (confirm("이 행을 삭제하시겠습니까?")) {
|
||||
try {
|
||||
|
|
@ -6691,7 +6755,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFilterGroup(group.id)}
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -6751,7 +6815,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFilterCondition(group.id, condition.id)}
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||||
disabled={group.conditions.length === 1}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
|
|
|
|||
Loading…
Reference in New Issue