docs: Add project conventions and guidelines for ERP/PLM project

- Introduced a comprehensive document outlining project conventions for the WACE ERP/PLM project.
- Included sections on project structure, backend practices, frontend practices, and specific implementation patterns.
- Established guidelines for file creation order, controller and service patterns, pagination handling, and caching strategies.
- Enhanced documentation to improve consistency and maintainability across the codebase.

These additions serve as a reference for developers to follow best practices and ensure uniformity in the project's development process.
This commit is contained in:
syc0123 2026-03-11 12:42:25 +09:00
parent 2406052742
commit 9c128cc52c
10 changed files with 1607 additions and 105 deletions

View File

@ -0,0 +1,731 @@
# WACE ERP/PLM 프로젝트 관행 (Project Conventions)
이 문서는 AI 에이전트가 새 기능을 구현할 때 기존 코드베이스의 관행을 따르기 위한 참조 문서입니다.
코드를 작성하기 전에 반드시 이 문서를 읽고 동일한 패턴을 사용하세요.
---
## 1. 프로젝트 구조
```
ERP-node/
├── backend-node/src/ # Express + TypeScript 백엔드
│ ├── app.ts # 엔트리포인트 (미들웨어, 라우트 등록)
│ ├── controllers/ # API 컨트롤러 (요청 처리, 응답 반환)
│ ├── services/ # 비즈니스 로직 (DB 접근, 트랜잭션)
│ ├── routes/ # Express 라우터 (URL 매핑)
│ ├── middleware/ # 인증, 에러처리, 권한 미들웨어
│ ├── database/db.ts # PostgreSQL 연결 풀, query/queryOne/transaction
│ ├── config/environment.ts # 환경 변수 설정
│ ├── types/ # TypeScript 타입 정의
│ └── utils/logger.ts # winston 로거
├── frontend/ # Next.js 15 (App Router) 프론트엔드
│ ├── app/ # 페이지 (Route Groups: (main), (auth), (admin))
│ ├── components/ # React 컴포넌트
│ │ ├── ui/ # shadcn/ui 기본 컴포넌트 (33개)
│ │ ├── admin/ # 관리자 화면 컴포넌트
│ │ └── screen/ # 화면 디자이너/렌더러 컴포넌트
│ ├── hooks/ # 커스텀 React 훅
│ ├── lib/api/ # API 클라이언트 모듈 (63개 파일)
│ ├── lib/utils.ts # cn() 등 유틸리티
│ ├── types/ # 프론트엔드 타입 정의
│ └── contexts/ # React Context (Auth, Menu 등)
├── db/migrations/ # SQL 마이그레이션 파일
└── docs/ # 프로젝트 문서
```
---
## 2. 백엔드 관행
### 2.1 새 기능 추가 시 파일 생성 순서
1. `backend-node/src/types/` — 타입 정의 (필요 시)
2. `backend-node/src/services/xxxService.ts` — 비즈니스 로직
3. `backend-node/src/controllers/xxxController.ts` — 컨트롤러
4. `backend-node/src/routes/xxxRoutes.ts` — 라우터
5. `backend-node/src/app.ts` — 라우트 등록 (`app.use("/api/xxx", xxxRoutes)`)
### 2.2 컨트롤러 패턴
```typescript
// backend-node/src/controllers/xxxController.ts
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
// 패턴 A: named async function (가장 많이 사용)
export async function getXxxList(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
logger.info("XXX 목록 조회 요청", { companyCode, userId });
// ... 비즈니스 로직 ...
res.status(200).json({
success: true,
message: "XXX 목록 조회 성공",
data: result,
});
} catch (error) {
logger.error("XXX 목록 조회 중 오류:", error);
res.status(500).json({
success: false,
message: "XXX 목록 조회 중 오류가 발생했습니다.",
error: {
code: "XXX_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
}
```
**핵심 규칙:**
- `AuthenticatedRequest`로 인증된 사용자 정보 접근
- `req.user?.companyCode`로 회사 코드 추출
- `try-catch` + `logger.error` + `res.status().json()` 패턴
- 응답 형식: `{ success, data?, message?, error?: { code, details } }`
### 2.3 서비스 패턴
```typescript
// backend-node/src/services/xxxService.ts
import { logger } from "../utils/logger";
import { query, queryOne, transaction } from "../database/db";
export class XxxService {
// static 메서드 또는 인스턴스 메서드 (둘 다 사용됨)
static async getList(companyCode: string, filters?: any) {
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIndex = 2;
if (filters?.search) {
conditions.push(`name ILIKE $${paramIndex}`);
params.push(`%${filters.search}%`);
paramIndex++;
}
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(" AND ")}`
: "";
const rows = await query(
`SELECT * FROM xxx_table ${whereClause} ORDER BY created_date DESC`,
params
);
return rows;
}
}
```
**핵심 규칙:**
- `query<T>(sql, params)` — 다건 조회 (배열 반환)
- `queryOne<T>(sql, params)` — 단건 조회 (객체 | null 반환)
- `transaction(async (client) => { ... })` — 트랜잭션
- 동적 WHERE: `conditions[]` + `params[]` + `paramIndex` 패턴
- 파라미터 바인딩: `$1`, `$2`, ... (절대 문자열 삽입 금지)
### 2.4 DB 쿼리 함수 (database/db.ts)
```typescript
import { query, queryOne, transaction } from "../database/db";
// 다건 조회
const rows = await query<{ id: string; name: string }>(
"SELECT * FROM xxx WHERE company_code = $1",
[companyCode]
);
// 단건 조회
const row = await queryOne<{ id: string }>(
"SELECT * FROM xxx WHERE id = $1 AND company_code = $2",
[id, companyCode]
);
// 트랜잭션
const result = await transaction(async (client) => {
await client.query("INSERT INTO xxx (...) VALUES (...)", [params]);
await client.query("UPDATE yyy SET ... WHERE ...", [params]);
return { success: true };
});
```
### 2.5 라우터 패턴
```typescript
// backend-node/src/routes/xxxRoutes.ts
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { getXxxList, createXxx, updateXxx, deleteXxx } from "../controllers/xxxController";
const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// CRUD 라우트
router.get("/", getXxxList); // GET /api/xxx
router.get("/:id", getXxxDetail); // GET /api/xxx/:id
router.post("/", createXxx); // POST /api/xxx
router.put("/:id", updateXxx); // PUT /api/xxx/:id
router.delete("/:id", deleteXxx); // DELETE /api/xxx/:id
export default router;
```
**URL 네이밍:**
- 리소스명: 복수형, kebab-case (`/api/flow-definitions`, `/api/admin/users`)
- 하위 리소스: `/api/xxx/:id/yyy`
- 액션: `/api/xxx/:id/toggle`, `/api/xxx/check-duplicate`
### 2.6 app.ts 라우트 등록
```typescript
// backend-node/src/app.ts 에 추가
import xxxRoutes from "./routes/xxxRoutes";
// ...
app.use("/api/xxx", xxxRoutes);
```
라우트 등록 위치: 기존 라우트들 사이에 알파벳 순서 또는 관련 기능 근처에 배치.
### 2.7 타입 정의
```typescript
// backend-node/src/types/xxx.ts
export interface XxxItem {
id: string;
company_code: string;
name: string;
created_date?: string;
updated_date?: string;
writer?: string;
}
```
**공통 타입 (types/common.ts):**
- `ApiResponse<T>` — 표준 API 응답
- `AuthenticatedRequest` — 인증된 요청 (req.user 포함)
- `PaginationParams` — 페이지네이션 파라미터
**인증 타입 (types/auth.ts):**
- `PersonBean` — 세션 사용자 정보 (userId, companyCode, userType 등)
- `AuthenticatedRequest` — Request + PersonBean
### 2.8 로깅
```typescript
import { logger } from "../utils/logger";
logger.info("작업 시작", { companyCode, userId });
logger.error("작업 실패:", error);
logger.warn("경고 상황", { details });
logger.debug("디버그 정보", { query, params });
```
---
## 3. 프론트엔드 관행
### 3.1 새 기능 추가 시 파일 생성 순서
1. `frontend/lib/api/xxx.ts` — API 클라이언트 함수
2. `frontend/hooks/useXxx.ts` — 커스텀 훅 (선택)
3. `frontend/components/xxx/XxxComponent.tsx` — 비즈니스 컴포넌트
4. `frontend/app/(main)/xxx/page.tsx` — 페이지
### 3.2 페이지 패턴
```tsx
// frontend/app/(main)/xxx/page.tsx
"use client";
import { useState } from "react";
import { useXxx } from "@/hooks/useXxx";
import { XxxToolbar } from "@/components/xxx/XxxToolbar";
import { XxxTable } from "@/components/xxx/XxxTable";
export default function XxxPage() {
const { data, isLoading, ... } = useXxx();
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight">페이지 제목</h1>
<p className="text-sm text-muted-foreground">페이지 설명</p>
</div>
{/* 툴바 + 테이블 + 모달 등 */}
<XxxToolbar ... />
<XxxTable ... />
</div>
</div>
);
}
```
**핵심 규칙:**
- 모든 페이지: `"use client"` + `export default function`
- 비즈니스 로직은 커스텀 훅으로 분리
- 페이지는 훅 + UI 컴포넌트 조합에 집중
### 3.3 컴포넌트 패턴
```tsx
// frontend/components/xxx/XxxToolbar.tsx
interface XxxToolbarProps {
searchFilter: SearchFilter;
totalCount: number;
onSearchChange: (filter: Partial<SearchFilter>) => void;
onCreateClick: () => void;
}
export function XxxToolbar({
searchFilter,
totalCount,
onSearchChange,
onCreateClick,
}: XxxToolbarProps) {
return (
<div className="flex items-center justify-between">
{/* ... */}
</div>
);
}
```
**핵심 규칙:**
- `export function ComponentName()` (arrow function 아님)
- `interface XxxProps` 정의 후 props 구조 분해
- 이벤트 핸들러: 내부 `handle` 접두사, props 콜백 `on` 접두사
- shadcn/ui 컴포넌트 우선 사용
### 3.4 커스텀 훅 패턴
```typescript
// frontend/hooks/useXxx.ts
import { useState, useCallback, useEffect, useMemo } from "react";
import { xxxApi } from "@/lib/api/xxx";
import { toast } from "sonner";
export const useXxx = () => {
const [data, setData] = useState<XxxItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const response = await xxxApi.getList();
if (response.success) {
setData(response.data);
}
} catch (err) {
setError("데이터 로딩 실패");
toast.error("데이터를 불러올 수 없습니다.");
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
return {
data,
isLoading,
error,
refreshData: loadData,
};
};
```
### 3.5 API 클라이언트 패턴
```typescript
// frontend/lib/api/xxx.ts
import { apiClient } from "./client";
interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
}
export async function getXxxList(params?: Record<string, any>) {
try {
const response = await apiClient.get("/xxx", { params });
return response.data;
} catch (error) {
console.error("XXX 목록 API 오류:", error);
throw error;
}
}
export async function createXxx(data: any) {
try {
const response = await apiClient.post("/xxx", data);
return response.data;
} catch (error) {
console.error("XXX 생성 API 오류:", error);
throw error;
}
}
export async function updateXxx(id: string, data: any) {
const response = await apiClient.put(`/xxx/${id}`, data);
return response.data;
}
export async function deleteXxx(id: string) {
const response = await apiClient.delete(`/xxx/${id}`);
return response.data;
}
// 객체로도 export (선택)
export const xxxApi = {
getList: getXxxList,
create: createXxx,
update: updateXxx,
delete: deleteXxx,
};
```
**핵심 규칙:**
- `apiClient` (Axios) 사용 — 절대 `fetch` 직접 사용 금지
- `apiClient`는 자동으로 Authorization 헤더, 환경별 URL, 토큰 갱신 처리
- URL에 `/api` 접두사 불필요 (client.ts에서 baseURL에 포함됨)
- 개별 함수 export + 객체 export 둘 다 가능
### 3.6 토스트/알림
```typescript
import { toast } from "sonner";
toast.success("저장되었습니다.");
toast.error("저장에 실패했습니다.");
toast.info("처리 중입니다.");
```
- `sonner` 라이브러리 직접 사용
- 루트 레이아웃에 `<Toaster position="top-right" />` 설정됨
### 3.7 모달/다이얼로그
```tsx
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
DialogDescription, DialogFooter
} from "@/components/ui/dialog";
interface XxxModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
editingItem?: XxxItem | null;
}
export function XxxModal({ isOpen, onClose, onSuccess, editingItem }: XxxModalProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">모달 제목</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">설명</DialogDescription>
</DialogHeader>
{/* 컨텐츠 */}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={onClose}>취소</Button>
<Button onClick={handleSubmit}>확인</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```
### 3.8 레이아웃 계층
```
app/layout.tsx → QueryProvider, RegistryProvider, Toaster
app/(main)/layout.tsx → AuthProvider, MenuProvider, AppLayout
app/(main)/admin/xxx/page.tsx → 실제 페이지
app/(auth)/layout.tsx → 로그인 등 인증 페이지
```
---
## 4. 데이터베이스 관행
### 4.1 테이블 생성 패턴
```sql
CREATE TABLE xxx_table (
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
company_code VARCHAR(500) NOT NULL,
name VARCHAR(500),
description VARCHAR(500),
status VARCHAR(500) DEFAULT 'active',
created_date TIMESTAMP DEFAULT NOW(),
updated_date TIMESTAMP DEFAULT NOW(),
writer VARCHAR(500) DEFAULT NULL
);
CREATE INDEX idx_xxx_table_company_code ON xxx_table(company_code);
```
**기본 컬럼 (모든 테이블 필수):**
- `id` — VARCHAR(500), PK, `gen_random_uuid()::text`
- `company_code` — VARCHAR(500), NOT NULL
- `created_date` — TIMESTAMP, DEFAULT NOW()
- `updated_date` — TIMESTAMP, DEFAULT NOW()
- `writer` — VARCHAR(500)
**컬럼 타입 관행:**
- 문자열: `VARCHAR(500)` (거의 모든 컬럼에 통일)
- 날짜: `TIMESTAMP`
- ID: `VARCHAR(500)` + `gen_random_uuid()::text`
### 4.2 마이그레이션 파일명
```
db/migrations/NNN_description.sql
예: 034_create_numbering_rules.sql
078_create_production_plan_tables.sql
1003_add_source_menu_objid_to_menu_info.sql
```
---
## 5. 멀티테넌시 (가장 중요)
### 5.1 모든 쿼리에 company_code 필수
```typescript
// SELECT
WHERE company_code = $1
// INSERT
INSERT INTO xxx (company_code, ...) VALUES ($1, ...)
// UPDATE
UPDATE xxx SET ... WHERE id = $1 AND company_code = $2
// DELETE
DELETE FROM xxx WHERE id = $1 AND company_code = $2
// JOIN
LEFT JOIN yyy ON xxx.yyy_id = yyy.id AND xxx.company_code = yyy.company_code
WHERE xxx.company_code = $1
```
### 5.2 최고 관리자(SUPER_ADMIN) 예외
```typescript
const companyCode = req.user?.companyCode;
if (companyCode === "*") {
// 최고 관리자: 전체 데이터 조회
query = "SELECT * FROM xxx ORDER BY company_code, created_date DESC";
params = [];
} else {
// 일반 사용자: 자기 회사만
query = "SELECT * FROM xxx WHERE company_code = $1 ORDER BY created_date DESC";
params = [companyCode];
}
```
### 5.3 최고 관리자 가시성 제한
사용자 관련 API에서 일반 회사 사용자는 `company_code = "*"` 데이터를 볼 수 없음:
```typescript
if (req.user && req.user.companyCode !== "*") {
whereConditions.push(`company_code != '*'`);
}
```
---
## 6. 인증 체계
### 6.1 JWT 토큰 기반
- 로그인 → JWT 발급 → `localStorage`에 저장
- 모든 API 요청: `Authorization: Bearer {token}` 헤더
- 프론트엔드 `apiClient`가 자동으로 토큰 관리
### 6.2 사용자 권한 3단계
| 역할 | company_code | userType |
|------|-------------|----------|
| 최고 관리자 | `"*"` | `SUPER_ADMIN` |
| 회사 관리자 | `"COMPANY_A"` | `COMPANY_ADMIN` |
| 일반 사용자 | `"COMPANY_A"` | `USER` |
### 6.3 미들웨어
- `authenticateToken` — JWT 검증 (대부분의 라우트에 적용)
- `requireSuperAdmin` — 최고 관리자 전용
- `requireAdmin` — 관리자(슈퍼+회사) 전용
---
## 7. 코드 스타일 관행
### 7.1 백엔드
- TypeScript strict: `false` (느슨한 타입 체크)
- 로거: `winston` (`logger` import)
- 컬럼명: `snake_case` (DB), `camelCase` (TypeScript 변수)
- 에러 코드: `UPPER_SNAKE_CASE` (예: `XXX_LIST_ERROR`)
### 7.2 프론트엔드
- TypeScript strict: `true`
- 스타일: Tailwind CSS v4 + shadcn/ui
- 클래스 병합: `cn()` (clsx + tailwind-merge)
- 색상: CSS 변수 기반 (`bg-primary`, `text-muted-foreground`)
- 아이콘: `lucide-react`
- 상태 관리: `zustand` (전역), `useState`/`useReducer` (로컬)
- 데이터 패칭: `@tanstack/react-query` 또는 직접 `useEffect` + API 호출
- 폼: `react-hook-form` + `zod` 또는 직접 `useState`
- 테이블: `@tanstack/react-table` 또는 shadcn `Table`
- 차트: `recharts`
- 날짜: `date-fns`
### 7.3 네이밍 컨벤션
| 대상 | 컨벤션 | 예시 |
|------|--------|------|
| 파일명 (백엔드) | camelCase | `xxxController.ts`, `xxxService.ts`, `xxxRoutes.ts` |
| 파일명 (프론트엔드 컴포넌트) | PascalCase | `XxxToolbar.tsx`, `XxxModal.tsx` |
| 파일명 (프론트엔드 훅) | camelCase | `useXxx.ts` |
| 파일명 (프론트엔드 API) | camelCase | `xxx.ts` |
| 파일명 (프론트엔드 페이지) | camelCase 폴더 | `app/(main)/xxxMng/page.tsx` |
| DB 테이블명 | snake_case | `xxx_table`, `user_info` |
| DB 컬럼명 | snake_case | `company_code`, `created_date` |
| 컴포넌트명 | PascalCase | `XxxToolbar`, `XxxModal` |
| 함수명 | camelCase | `getXxxList`, `handleSubmit` |
| 이벤트 핸들러 (내부) | handle 접두사 | `handleCreateUser` |
| 이벤트 콜백 (props) | on 접두사 | `onSearchChange`, `onClose` |
| 상수 | UPPER_SNAKE_CASE | `MAX_PAGE_SIZE`, `DEFAULT_LIMIT` |
---
## 8. 응답 형식 표준
### 8.1 성공 응답
```json
{
"success": true,
"message": "조회 성공",
"data": [ ... ],
"pagination": {
"page": 1,
"limit": 20,
"total": 100,
"totalPages": 5
}
}
```
### 8.2 에러 응답
```json
{
"success": false,
"message": "조회 중 오류가 발생했습니다.",
"error": {
"code": "XXX_LIST_ERROR",
"details": "에러 상세 메시지"
}
}
```
---
## 9. 환경별 URL 매핑
| 환경 | 프론트엔드 | 백엔드 API |
|------|-----------|-----------|
| 프로덕션 | `v1.vexplor.com` | `https://api.vexplor.com/api` |
| 개발 (로컬) | `localhost:9771` 또는 `localhost:3000` | `http://localhost:8080/api` |
- 프론트엔드 `apiClient`가 `window.location.hostname` 기반으로 자동 판별
- 프론트엔드에서 API URL 하드코딩 금지
---
## 10. 자주 사용하는 import 경로
### 백엔드
```typescript
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import { AuthenticatedRequest, PersonBean } from "../types/auth";
import { ApiResponse } from "../types/common";
import { query, queryOne, transaction } from "../database/db";
import { authenticateToken } from "../middleware/authMiddleware";
```
### 프론트엔드
```typescript
import { apiClient } from "@/lib/api/client";
import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, ... } from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
```
---
## 11. 체크리스트: 새 기능 구현 시
### 백엔드
- [ ] `company_code` 필터링이 모든 SELECT/INSERT/UPDATE/DELETE에 포함되어 있는가?
- [ ] `req.user?.companyCode`를 사용하는가? (클라이언트 입력 아님)
- [ ] SUPER_ADMIN (`company_code === "*"`) 예외 처리가 되어 있는가?
- [ ] JOIN 쿼리에도 `company_code` 매칭이 있는가?
- [ ] 파라미터 바인딩 (`$1`, `$2`) 사용하는가? (SQL 인젝션 방지)
- [ ] `try-catch` + `logger` + 적절한 HTTP 상태 코드를 반환하는가?
- [ ] `app.ts`에 라우트가 등록되어 있는가?
### 프론트엔드
- [ ] `apiClient`를 통해 API를 호출하는가? (fetch 직접 사용 금지)
- [ ] `"use client"` 지시어가 있는가?
- [ ] 비즈니스 로직이 커스텀 훅으로 분리되어 있는가?
- [ ] shadcn/ui 컴포넌트를 사용하는가?
- [ ] 에러 시 `toast.error()`로 사용자에게 피드백하는가?
- [ ] 로딩 상태를 표시하는가?
- [ ] 반응형 디자인 (모바일 우선)을 적용했는가?
---
## 12. 주의사항
1. **백엔드 재시작 금지** — nodemon이 파일 변경 감지 시 자동 재시작
2. **fetch 직접 사용 금지** — 반드시 `apiClient` 사용
3. **하드코딩 색상 금지** — `bg-blue-500` 대신 `bg-primary` 등 CSS 변수 사용
4. **company_code 누락 금지** — 모든 비즈니스 테이블/쿼리에 필수
5. **중첩 박스 금지** — Card 안에 Card, Border 안에 Border 금지
6. **항상 한글로 답변**

View File

@ -0,0 +1,389 @@
# [계획서] 페이징 단락(그룹) 번호 네비게이션 - 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에 유지 (세션 간 보존).

View File

@ -0,0 +1,128 @@
# [맥락노트] 페이징 단락(그룹) 번호 네비게이션 - 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_*`

View File

@ -0,0 +1,90 @@
# [체크리스트] 페이징 단락(그룹) 번호 네비게이션 - 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 스코프) |

View File

@ -123,15 +123,49 @@
- [ ] 비활성 탭: 캐시에서 복원
- [ ] 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제
### 6-3. 캐시 키 관리 (clearTabStateCache)
### 6-3. 캐시 키 관리 (clearTabCache)
탭 닫기/새로고침 시 관련 sessionStorage 키 일괄 제거:
- `tab-cache-{screenId}-{menuObjid}`
- `page-scroll-{screenId}-{menuObjid}`
- `tsp-{screenId}-*`, `table-state-{screenId}-*`
- `split-sel-{screenId}-*`, `catval-sel-{screenId}-*`
- `bom-tree-{screenId}-*`
- URL 탭: `tsp-{urlHash}-*`, `admin-scroll-{url}`
- `tab-cache-{tabId}` (폼/스크롤 캐시)
- `tableState_{tabId}_*` (컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터)
- `pageSize_{tabId}_*` (표시갯수)
- `filterSettings_{tabId}_*` (검색 필터 설정)
- `groupSettings_{tabId}_*` (그룹 설정)
### 6-4. F5 새로고침 시 캐시 정책 (구현 완료)
| 탭 상태 | F5 시 동작 |
|---------|-----------|
| **활성 탭** | `clearTabCache(activeTabId)` → 캐시 전체 삭제 → fresh API 호출 |
| **비활성 탭** | 캐시 유지 → 탭 전환 시 복원 |
**구현 방식**: `TabContent.tsx`에 모듈 레벨 플래그(`hasHandledPageLoad`)를 사용.
전체 페이지 로드 시 모듈이 재실행되어 플래그가 `false`로 리셋.
SPA 내비게이션에서는 모듈이 유지되므로 `true`로 남아 중복 실행 방지.
### 6-5. 탭 바 새로고침 버튼 (구현 완료)
`tabStore.refreshTab(tabId)` 호출 시:
1. `clearTabCache(tabId)` → 해당 탭의 모든 sessionStorage 캐시 삭제
2. `refreshKey` 증가 → 컴포넌트 리마운트 → 기본값으로 초기화
### 6-6. 저장소 분류 기준 (구현 완료)
| 데이터 성격 | 저장소 | 키 구조 | 비고 |
|------------|--------|---------|------|
| 탭별 캐시 | sessionStorage | `{prefix}_{tabId}_{tableName}` | 탭 닫으면 소멸 |
| 사용자 설정 | localStorage | `{prefix}_{tableName}_{userId}` | 세션 간 보존 |
**탭별 캐시 (sessionStorage)**:
- tableState: 컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터
- pageSize: 표시갯수
- filterSettings: 검색 필터 설정
- groupSettings: 그룹 설정
**사용자 설정 (localStorage)**:
- table_column_visibility: 컬럼 표시/숨김
- table_sort_state: 정렬 상태
- table_column_order: 컬럼 순서
---

View File

@ -0,0 +1,109 @@
"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>
);
}

View File

@ -19,6 +19,11 @@ import {
clearTabCache,
} from "@/lib/tabStateCache";
// 페이지 로드(F5 새로고침) 감지용 모듈 레벨 플래그.
// 전체 페이지 로드 시 모듈이 재실행되어 false로 리셋된다.
// SPA 내비게이션에서는 모듈이 유지되므로 true로 남는다.
let hasHandledPageLoad = false;
export function TabContent() {
const tabs = useTabStore(selectTabs);
const activeTabId = useTabStore(selectActiveTabId);
@ -39,6 +44,13 @@ export function TabContent() {
// 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함)
const pathCacheRef = useRef<WeakMap<HTMLElement, string | null>>(new WeakMap());
// 페이지 로드(F5) 시 활성 탭 캐시만 삭제 → fresh API 호출 유도
// 비활성 탭 캐시는 유지하여 탭 전환 시 복원
if (!hasHandledPageLoad && activeTabId) {
hasHandledPageLoad = true;
clearTabCache(activeTabId);
}
if (activeTabId) {
mountedTabIdsRef.current.add(activeTabId);
}

View File

@ -2,15 +2,15 @@
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { TableListConfig, ColumnConfig } from "./types";
import { WebType } from "@/types/common";
import type { 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";
// 🖼️ 테이블 셀 이미지 썸네일 컴포넌트
// objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용
@ -155,13 +155,8 @@ declare global {
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
RefreshCw,
ArrowUp,
ArrowDown,
TableIcon,
Settings,
X,
Layers,
ChevronDown,
@ -174,14 +169,14 @@ import {
Edit,
CheckSquare,
Trash2,
Lock,
} from "lucide-react";
import * as XLSX from "xlsx";
import { FileText, ChevronRightIcon } from "lucide-react";
import { FileText } 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,
@ -193,7 +188,6 @@ 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";
@ -201,7 +195,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, SplitPanelPosition } from "@/contexts/SplitPanelContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
// ========================================
@ -404,6 +398,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 디버그 로그 제거 (성능 최적화)
const currentTabId = useTabId();
const buttonColor = component.style?.labelColor || "#212121";
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
@ -694,7 +690,38 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const hasInitializedSort = useRef(false);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [tableLabel, setTableLabel] = useState<string>("");
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
const pageSizeKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
if (currentTabId) return `pageSize_${currentTabId}_${tableConfig.selectedTable}`;
return `pageSize_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, currentTabId]);
const [localPageSize, setLocalPageSize] = useState<number>(() => {
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 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";
});
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
const [columnMeta, setColumnMeta] = useState<
Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }>
@ -804,11 +831,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [dropTargetColumnIndex, setDropTargetColumnIndex] = useState<number | null>(null);
const [isColumnDragEnabled] = useState<boolean>((tableConfig as any).enableColumnDrag ?? true);
// 🆕 State Persistence: 통합 상태 키
// 🆕 State Persistence: 통합 상태 키 (탭 ID 스코프 + sessionStorage)
const tableStateKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
if (currentTabId) return `tableState_${currentTabId}_${tableConfig.selectedTable}`;
return `tableState_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable]);
}, [tableConfig.selectedTable, currentTabId]);
// 🆕 Real-Time Updates 관련 상태
const [isRealTimeEnabled] = useState<boolean>((tableConfig as any).realTimeUpdates ?? false);
@ -1612,7 +1640,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setError(null);
try {
const page = tableConfig.pagination?.currentPage || currentPage;
const page = currentPage || tableConfig.pagination?.currentPage || 1;
const pageSize = localPageSize;
// 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용
const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined;
@ -1910,12 +1938,6 @@ 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 } });
}
};
const handleSort = (column: string) => {
@ -2952,12 +2974,11 @@ 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(),
};
try {
localStorage.setItem(tableStateKey, JSON.stringify(state));
sessionStorage.setItem(tableStateKey, JSON.stringify(state));
} catch (error) {
console.error("❌ 테이블 상태 저장 실패:", error);
}
@ -2972,7 +2993,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
frozenColumnCount,
showGridLines,
headerFilters,
localPageSize,
]);
// 🆕 State Persistence: 통합 상태 복원
@ -2980,7 +3000,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!tableStateKey) return;
try {
const saved = localStorage.getItem(tableStateKey);
const saved = sessionStorage.getItem(tableStateKey);
if (!saved) return;
const state = JSON.parse(saved);
@ -2991,7 +3011,6 @@ 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)
@ -2999,7 +3018,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>> = {};
@ -3018,7 +3037,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!tableStateKey) return;
try {
localStorage.removeItem(tableStateKey);
sessionStorage.removeItem(tableStateKey);
setColumnWidths({});
setColumnOrder([]);
setSortColumn(null);
@ -3027,6 +3046,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setFrozenColumns([]);
setShowGridLines(true);
setHeaderFilters({});
setLocalPageSize(20);
setPageSizeInputValue("20");
toast.success("테이블 설정이 초기화되었습니다.");
} catch (error) {
console.error("❌ 테이블 상태 초기화 실패:", error);
@ -4442,33 +4463,36 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// useEffect 훅
// ========================================
// 필터 설정 localStorage 키 생성 (화면별로 독립적)
// 필터 설정 sessionStorage 키 생성 (탭 ID 스코프)
const filterSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return screenId
? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}`
: `tableList_filterSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, screenId]);
const base = screenId
? `${tableConfig.selectedTable}_screen_${screenId}`
: tableConfig.selectedTable;
if (currentTabId) return `filterSettings_${currentTabId}_${base}`;
return `filterSettings_${base}`;
}, [tableConfig.selectedTable, screenId, currentTabId]);
// 그룹 설정 localStorage 키 생성 (화면별로 독립적)
// 그룹 설정 sessionStorage 키 생성 (탭 ID 스코프)
const groupSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return screenId
? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}`
: `tableList_groupSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, screenId]);
const base = screenId
? `${tableConfig.selectedTable}_screen_${screenId}`
: tableConfig.selectedTable;
if (currentTabId) return `groupSettings_${currentTabId}_${base}`;
return `groupSettings_${base}`;
}, [tableConfig.selectedTable, screenId, currentTabId]);
// 저장된 필터 설정 불러오기
useEffect(() => {
if (!filterSettingKey || visibleColumns.length === 0) return;
try {
const saved = localStorage.getItem(filterSettingKey);
const saved = sessionStorage.getItem(filterSettingKey);
if (saved) {
const savedFilters = JSON.parse(saved);
setVisibleFilterColumns(new Set(savedFilters));
} else {
// 초기값: 빈 Set (아무것도 선택 안 함)
setVisibleFilterColumns(new Set());
}
} catch (error) {
@ -4482,7 +4506,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!filterSettingKey) return;
try {
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
sessionStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
setIsFilterSettingOpen(false);
toast.success("검색 필터 설정이 저장되었습니다");
@ -4537,7 +4561,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!groupSettingKey) return;
try {
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
sessionStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
} catch (error) {
console.error("그룹 설정 저장 실패:", error);
}
@ -4617,7 +4641,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setGroupByColumns([]);
setCollapsedGroups(new Set());
if (groupSettingKey) {
localStorage.removeItem(groupSettingKey);
sessionStorage.removeItem(groupSettingKey);
}
toast.success("그룹이 해제되었습니다");
}, [groupSettingKey]);
@ -4801,7 +4825,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!groupSettingKey || visibleColumns.length === 0) return;
try {
const saved = localStorage.getItem(groupSettingKey);
const saved = sessionStorage.getItem(groupSettingKey);
if (saved) {
const savedGroups = JSON.parse(saved);
setGroupByColumns(savedGroups);
@ -5100,13 +5124,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 페이지 크기 변경 핸들러
const handlePageSizeChange = (newSize: number) => {
setPageSizeInputValue(String(newSize));
setLocalPageSize(newSize);
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로 이동
if (onConfigChange) {
onConfigChange({
...tableConfig,
pagination: { ...tableConfig.pagination, pageSize: newSize, currentPage: 1 },
});
setCurrentPage(1);
if (pageSizeKey) {
sessionStorage.setItem(pageSizeKey, String(newSize));
}
};
@ -5121,65 +5143,33 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
type="number"
min={1}
max={10000}
value={localPageSize}
value={pageSizeInputValue}
onChange={(e) => {
const value = Math.min(10000, Math.max(1, Number(e.target.value) || 1));
handlePageSizeChange(value);
setPageSizeInputValue(e.target.value);
}}
onBlur={(e) => {
// 포커스 잃을 때 유효 범위로 조정
const value = Math.min(10000, Math.max(1, Number(e.target.value) || 10));
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();
}
}}
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"
/>
<span className="text-muted-foreground text-xs"></span>
</div>
{/* 중앙 페이지네이션 컨트롤 */}
<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>
<span className="text-foreground min-w-[60px] text-center text-xs font-medium sm:min-w-[80px] sm:text-sm">
{currentPage} / {totalPages || 1}
</span>
<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>
<PageGroupNav
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
disabled={loading}
/>
{/* 우측 버튼 그룹 */}
<div className="absolute right-2 flex items-center gap-1 sm:right-6">
@ -5254,6 +5244,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
exportToExcel,
exportToPdf,
localPageSize,
pageSizeInputValue,
onConfigChange,
tableConfig,
]);

View File

@ -78,13 +78,30 @@ export function loadTabCache(tabId: string): TabCacheData | null {
}
/**
*
* .
* tab-cache-{tabId} (tableState_, pageSize_, filterSettings_, groupSettings_) .
*/
export function clearTabCache(tabId: string): void {
if (typeof window === "undefined") return;
try {
sessionStorage.removeItem(CACHE_PREFIX + tabId);
const suffix = `_${tabId}_`;
const keysToRemove: string[] = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (
key &&
(key.startsWith("tableState" + suffix) ||
key.startsWith("pageSize" + suffix) ||
key.startsWith("filterSettings" + suffix) ||
key.startsWith("groupSettings" + suffix))
) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((k) => sessionStorage.removeItem(k));
} catch {
// ignore
}

View File

@ -149,6 +149,7 @@ export const useTabStore = create<TabState>()(
},
refreshTab: (tabId) => {
clearTabCache(tabId);
set((state) => ({
refreshKeys: { ...state.refreshKeys, [tabId]: (state.refreshKeys[tabId] || 0) + 1 },
}));