diff --git a/docs/ycshin-node/PGN[계획]-페이징-단락이동.md b/docs/ycshin-node/PGN[계획]-페이징-단락이동.md deleted file mode 100644 index 4e39f276..00000000 --- a/docs/ycshin-node/PGN[계획]-페이징-단락이동.md +++ /dev/null @@ -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 -{/* 중앙 페이지네이션 컨트롤 */} -
-
-``` - ---- - -## 변경 후 동작 - -### 페이지네이션 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 ( -
- {/* << 첫 단락 */} - - - {/* < 이전 단락 */} - - - {/* 페이지 번호 (고정 슬롯) */} - {slots.map((page, idx) => - page !== null ? ( - - ) : ( -
- ) - )} - - {/* > 다음 단락 */} - - - {/* >> 마지막 단락 */} - -
- ); -} -``` - -### v2-table-list 통합 (paginationJSX 중앙 영역 교체) - -기존 5139~5182행의 `
` 블록을 다음으로 교체: - -```tsx -import { PageGroupNav } from "@/components/common/PageGroupNav"; - -// paginationJSX 내부 중앙 영역 - -``` - -좌측(페이지크기 입력), 우측(내보내기/새로고침)은 기존 코드 그대로 유지. - ---- - -## 설계 원칙 - -- **레이아웃 무관 컴포넌트**: 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에 유지 (세션 간 보존). diff --git a/docs/ycshin-node/PGN[계획]-페이징-직접입력.md b/docs/ycshin-node/PGN[계획]-페이징-직접입력.md new file mode 100644 index 00000000..635041b5 --- /dev/null +++ b/docs/ycshin-node/PGN[계획]-페이징-직접입력.md @@ -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 중앙 `` → `` + `/` + `` 교체 | +| | | (3) `handlePageSizeChange`에 `onConfigChange` 호출 추가 | +| | | (4) `fetchTableDataInternal`에서 `currentPage`를 단일 소스로 사용 | +| | | (5) `useMemo` 의존성에 `pageInputValue` 추가 | +| 삭제 | `PageGroupNav.tsx` | 이전 설계 산출물 삭제 (이미 삭제됨) | + +- 신규 파일 생성 없음 +- 백엔드 변경 없음, DB 변경 없음 +- v2-table-list를 사용하는 **모든 동적 화면**에 자동 적용 + +--- + +## 설계 원칙 + +- **최소 변경**: `` 1개를 `` + 유효성 검증으로 교체. 나머지 전부 유지 +- **기존 버튼 동작 무변경**: `<< < > >>` 4개 버튼의 onClick/disabled 로직은 그대로 +- **`handlePageChange` 재사용**: 기존 함수를 그대로 호출 +- **입력 중 페이지 이동 안 함**: onChange는 표시만 변경, Enter/blur로 실제 적용 +- **유효 범위 자동 보정**: 1 미만 → 1, totalPages 초과 → totalPages, 비숫자 → 현재 값 유지 +- **포커스 시 전체 선택**: 클릭하면 바로 타이핑 가능 +- **`currentPage`가 단일 소스**: fetch 시 `tableConfig.pagination?.currentPage` 대신 로컬 `currentPage`만 사용 (비동기 전파 문제 방지) +- **페이지크기 변경 시 1페이지로 리셋**: `handlePageSizeChange`가 `onConfigChange`를 호출하여 부모/백엔드 동기화 diff --git a/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md b/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md deleted file mode 100644 index 024bd7a2..00000000 --- a/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md +++ /dev/null @@ -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개 미만이면 남은 슬롯은 동일 크기의 빈 `
`로 채움 - -### 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_*` diff --git a/docs/ycshin-node/PGN[맥락]-페이징-직접입력.md b/docs/ycshin-node/PGN[맥락]-페이징-직접입력.md new file mode 100644 index 00000000..c036a089 --- /dev/null +++ b/docs/ycshin-node/PGN[맥락]-페이징-직접입력.md @@ -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 내부에 인라인으로 구현 +- **근거**: 변경이 `` → `` + 핸들러 약 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 호출 +``` diff --git a/docs/ycshin-node/PGN[체크]-페이징-단락이동.md b/docs/ycshin-node/PGN[체크]-페이징-단락이동.md deleted file mode 100644 index 46b94395..00000000 --- a/docs/ycshin-node/PGN[체크]-페이징-단락이동.md +++ /dev/null @@ -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행)을 `` 호출로 교체 -- [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 스코프) | diff --git a/docs/ycshin-node/PGN[체크]-페이징-직접입력.md b/docs/ycshin-node/PGN[체크]-페이징-직접입력.md new file mode 100644 index 00000000..50f8fe8d --- /dev/null +++ b/docs/ycshin-node/PGN[체크]-페이징-직접입력.md @@ -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`) +- [x] `currentPage` 변경 시 `pageInputValue` 동기화 (`useEffect`) +- [x] `commitPageInput` 핸들러 구현 (parseInt + clamp + handlePageChange) +- [x] paginationJSX 중앙의 `` → `` + `/` + `` 교체 +- [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 | 문서 최신화: 버그 수정 내역 반영, 코드 설계 섹션 제거 (구현 완료) | diff --git a/frontend/components/common/PageGroupNav.tsx b/frontend/components/common/PageGroupNav.tsx deleted file mode 100644 index dc59b35e..00000000 --- a/frontend/components/common/PageGroupNav.tsx +++ /dev/null @@ -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 ( -
- {/* << 첫 단락 */} - - - {/* < 이전 단락 */} - - - {/* 페이지 번호 (고정 슬롯) */} - {slots.map((page, idx) => - page !== null ? ( - - ) : ( -
- ), - )} - - {/* > 다음 단락 */} - - - {/* >> 마지막 단락 */} - -
- ); -} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 593ab529..63cdc3f2 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -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 = ({ 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 = ({ 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 = ({ const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); const [totalItems, setTotalItems] = useState(0); + const [pageInputValue, setPageInputValue] = useState("1"); const [searchTerm, setSearchTerm] = useState(""); const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); @@ -714,20 +715,7 @@ export const TableListComponent: React.FC = ({ const val = sessionStorage.getItem(key); if (val) return Number(val); } - return 20; - }); - const [pageSizeInputValue, setPageSizeInputValue] = useState(() => { - 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([]); const [columnMeta, setColumnMeta] = useState< @@ -843,7 +831,7 @@ export const TableListComponent: React.FC = ({ 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((tableConfig as any).realTimeUpdates ?? false); @@ -1647,7 +1635,7 @@ export const TableListComponent: React.FC = ({ 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 = ({ } }, [ tableConfig.selectedTable, - tableConfig.pagination?.currentPage, tableConfig.columns, currentPage, localPageSize, @@ -1945,6 +1932,29 @@ export const TableListComponent: React.FC = ({ 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 = ({ headerFilters: Object.fromEntries( Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]), ), + pageSize: localPageSize, timestamp: Date.now(), }; @@ -3000,6 +3011,7 @@ export const TableListComponent: React.FC = ({ frozenColumnCount, showGridLines, headerFilters, + localPageSize, ]); // 🆕 State Persistence: 통합 상태 복원 @@ -3018,6 +3030,7 @@ export const TableListComponent: React.FC = ({ 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 = ({ : 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> = {}; @@ -3053,8 +3066,6 @@ export const TableListComponent: React.FC = ({ setFrozenColumns([]); setShowGridLines(true); setHeaderFilters({}); - setLocalPageSize(20); - setPageSizeInputValue("20"); toast.success("테이블 설정이 초기화되었습니다."); } catch (error) { console.error("❌ 테이블 상태 초기화 실패:", error); @@ -4280,8 +4291,8 @@ export const TableListComponent: React.FC = ({ return (
- - + + {fileNames} {files.length > 1 && ({files.length})} @@ -4500,6 +4511,7 @@ export const TableListComponent: React.FC = ({ 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 = ({ // 페이지 크기 변경 핸들러 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 (
{/* 좌측: 페이지 크기 입력 */} @@ -5150,20 +5165,15 @@ export const TableListComponent: React.FC = ({ 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 = ({
{/* 중앙 페이지네이션 컨트롤 */} - +
+ + + +
+ 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" + /> + / + + {totalPages || 1} + +
+ + + +
{/* 우측 버튼 그룹 */}
@@ -5191,7 +5257,7 @@ export const TableListComponent: React.FC = ({
Excel
PDF/인쇄
@@ -5251,9 +5317,9 @@ export const TableListComponent: React.FC = ({ exportToExcel, exportToPdf, localPageSize, - pageSizeInputValue, onConfigChange, tableConfig, + pageInputValue, ]); // ======================================== @@ -5265,7 +5331,7 @@ export const TableListComponent: React.FC = ({ 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 = ({
)} -
+
= ({ className="h-7 text-xs" title="Excel 내보내기" > - + Excel )} @@ -5413,7 +5479,7 @@ export const TableListComponent: React.FC = ({ className="h-7 text-xs" title="PDF 내보내기" > - + PDF )} @@ -5645,7 +5711,6 @@ export const TableListComponent: React.FC = ({ width: "100%", height: "100%", overflow: "auto", - WebkitOverflowScrolling: "touch", }} onScroll={handleVirtualScroll} > @@ -5656,7 +5721,6 @@ export const TableListComponent: React.FC = ({ borderCollapse: "collapse", width: "100%", tableLayout: "fixed", - minWidth: "400px", }} > {/* 헤더 (sticky) */} @@ -5874,7 +5938,7 @@ export const TableListComponent: React.FC = ({ {/* 리사이즈 핸들 (체크박스 제외) */} {columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
e.stopPropagation()} // 정렬 클릭 방지 onMouseDown={(e) => { @@ -6227,11 +6291,11 @@ export const TableListComponent: React.FC = ({ // 🆕 배치 편집: 수정된 셀 스타일 (노란 배경) 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 = ({ {/* 행 삭제 */} @@ -6751,7 +6815,7 @@ export const TableListComponent: React.FC = ({ 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} >