Merge branch 'jskim-node' into mhkim-node
Made-with: Cursor
This commit is contained in:
commit
4d313008c1
|
|
@ -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. **항상 한글로 답변**
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
# [계획서] 렉 구조 위치코드/위치명 포맷 사용자 설정
|
||||
|
||||
> 관련 문서: [맥락노트](./LFC[맥락]-위치포맷-사용자설정.md) | [체크리스트](./LFC[체크]-위치포맷-사용자설정.md)
|
||||
|
||||
## 개요
|
||||
|
||||
물류관리 > 창고정보 관리 > 렉 구조 등록 모달에서 생성되는 **위치코드(`location_code`)와 위치명(`location_name`)의 포맷을 관리자가 화면 디자이너에서 자유롭게 설정**할 수 있도록 합니다.
|
||||
|
||||
현재 위치코드/위치명 생성 로직은 하드코딩되어 있어, 구분자("-"), 세그먼트 순서(창고코드-층-구역-열-단), 한글 접미사("구역", "열", "단") 등을 변경할 수 없습니다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 동작
|
||||
|
||||
### 1. 타입/설정에 패턴 필드가 정의되어 있지만 사용하지 않음
|
||||
|
||||
`types.ts`(57~58행)에 `codePattern`/`namePattern`이 정의되어 있고, `config.ts`(14~15행)에 기본값도 있으나, 실제 컴포넌트에서는 **전혀 참조하지 않음**:
|
||||
|
||||
```typescript
|
||||
// types.ts:57~58 - 정의만 있음
|
||||
codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}")
|
||||
namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단")
|
||||
|
||||
// config.ts:14~15 - 기본값만 있음
|
||||
codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}",
|
||||
namePattern: "{zone}구역-{row:02d}열-{level}단",
|
||||
```
|
||||
|
||||
### 2. 위치 코드 생성 하드코딩 (RackStructureComponent.tsx:494~510)
|
||||
|
||||
```tsx
|
||||
const generateLocationCode = useCallback(
|
||||
(row: number, level: number): { code: string; name: string } => {
|
||||
const warehouseCode = context?.warehouseCode || "WH001";
|
||||
const floor = context?.floor;
|
||||
const zone = context?.zone || "A";
|
||||
|
||||
const floorPrefix = floor ? `${floor}` : "";
|
||||
const code = `${warehouseCode}-${floorPrefix}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
||||
|
||||
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
|
||||
const floorNamePrefix = floor ? `${floor}-` : "";
|
||||
const name = `${floorNamePrefix}${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`;
|
||||
|
||||
return { code, name };
|
||||
},
|
||||
[context],
|
||||
);
|
||||
```
|
||||
|
||||
### 3. ConfigPanel에 포맷 관련 설정 UI 없음
|
||||
|
||||
`RackStructureConfigPanel.tsx`에는 필드 매핑, 제한 설정, UI 설정만 있고, `codePattern`/`namePattern`을 편집하는 UI가 없음.
|
||||
|
||||
---
|
||||
|
||||
## 변경 후 동작
|
||||
|
||||
### 1. ConfigPanel에 "포맷 설정" 섹션 추가
|
||||
|
||||
화면 디자이너 좌측 속성 패널의 v2-rack-structure ConfigPanel에 새 섹션이 추가됨:
|
||||
|
||||
- 위치코드/위치명 각각의 세그먼트 목록
|
||||
- 최상단에 컬럼 헤더(`라벨` / `구분` / `자릿수`) 표시
|
||||
- 세그먼트별로 **드래그 순서변경**, **체크박스로 한글 라벨 표시/숨김**, **라벨 텍스트 입력**, **구분자 입력**, **자릿수 입력**
|
||||
- 자릿수 필드는 숫자 타입(열, 단)만 활성화, 나머지(창고코드, 층, 구역)는 비활성화
|
||||
- 변경 시 실시간 미리보기로 결과 확인
|
||||
|
||||
### 2. 컴포넌트에서 config 기반 코드 생성
|
||||
|
||||
`RackStructureComponent`의 `generateLocationCode`가 하드코딩 대신 `config.formatConfig`의 세그먼트 배열을 순회하며 동적으로 코드/이름 생성.
|
||||
|
||||
### 3. 기본값은 현재 하드코딩과 동일
|
||||
|
||||
`formatConfig`가 설정되지 않으면 기본 세그먼트가 적용되어 현재와 완전히 동일한 결과 생성 (하위 호환).
|
||||
|
||||
---
|
||||
|
||||
## 시각적 예시
|
||||
|
||||
### ConfigPanel UI (화면 디자이너 좌측 속성 패널)
|
||||
|
||||
```
|
||||
┌─ 포맷 설정 ──────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 위치코드 포맷 │
|
||||
│ 라벨 구분 자릿수 │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ ☰ 창고코드 [✓] [ ] [ - ] [ 0 ] (비활성) │ │
|
||||
│ │ ☰ 층 [✓] [ 층 ] [ ] [ 0 ] (비활성) │ │
|
||||
│ │ ☰ 구역 [✓] [구역 ] [ - ] [ 0 ] (비활성) │ │
|
||||
│ │ ☰ 열 [✓] [ ] [ - ] [ 2 ] │ │
|
||||
│ │ ☰ 단 [✓] [ ] [ ] [ 0 ] │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ 미리보기: WH001-1층A구역-01-1 │
|
||||
│ │
|
||||
│ 위치명 포맷 │
|
||||
│ 라벨 구분 자릿수 │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ ☰ 구역 [✓] [구역 ] [ - ] [ 0 ] (비활성) │ │
|
||||
│ │ ☰ 열 [✓] [ 열 ] [ - ] [ 2 ] │ │
|
||||
│ │ ☰ 단 [✓] [ 단 ] [ ] [ 0 ] │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ 미리보기: A구역-01열-1단 │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 사용자 커스터마이징 예시
|
||||
|
||||
| 설정 변경 | 위치코드 결과 | 위치명 결과 |
|
||||
|-----------|-------------|------------|
|
||||
| 기본값 (변경 없음) | `WH001-1층A구역-01-1` | `A구역-01열-1단` |
|
||||
| 구분자를 "/" 로 변경 | `WH001/1층A구역/01/1` | `A구역/01열/1단` |
|
||||
| 층 라벨 해제 | `WH001-1A구역-01-1` | `A구역-01열-1단` |
|
||||
| 구역+열 라벨 해제 | `WH001-1층A-01-1` | `A-01-1단` |
|
||||
| 순서를 구역→층→열→단 으로 변경 | `WH001-A구역1층-01-1` | `A구역-1층-01열-1단` |
|
||||
| 한글 라벨 모두 해제 | `WH001-1A-01-1` | `A-01-1` |
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 데이터 흐름
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["관리자: 화면 디자이너 열기"] --> B["RackStructureConfigPanel\n포맷 세그먼트 편집"]
|
||||
B --> C["componentConfig.formatConfig\n에 세그먼트 배열 저장"]
|
||||
C --> D["screen_layouts_v2.layout_data\nDB JSONB에 영구 저장"]
|
||||
D --> E["엔드유저: 렉 구조 모달 열기"]
|
||||
E --> F["RackStructureComponent\nconfig.formatConfig 읽기"]
|
||||
F --> G["generateLocationCode\n세그먼트 배열 순회하며 동적 생성"]
|
||||
G --> H["미리보기 테이블에 표시\nlocation_code / location_name"]
|
||||
```
|
||||
|
||||
### 컴포넌트 관계
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph designer ["화면 디자이너 (관리자)"]
|
||||
CP["RackStructureConfigPanel"]
|
||||
FE["FormatSegmentEditor\n(신규 서브컴포넌트)"]
|
||||
CP --> FE
|
||||
end
|
||||
subgraph runtime ["렉 구조 모달 (엔드유저)"]
|
||||
RC["RackStructureComponent"]
|
||||
GL["generateLocationCode\n(세그먼트 기반으로 교체)"]
|
||||
RC --> GL
|
||||
end
|
||||
subgraph storage ["저장소"]
|
||||
DB["screen_layouts_v2\nlayout_data.overrides.formatConfig"]
|
||||
end
|
||||
|
||||
FE -->|"onChange → componentConfig"| DB
|
||||
DB -->|"config prop 전달"| RC
|
||||
```
|
||||
|
||||
> 노란색 영역은 없음. 기존 설정-저장-전달 파이프라인을 그대로 활용.
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상 파일
|
||||
|
||||
| 파일 | 수정 내용 | 수정 규모 |
|
||||
|------|----------|----------|
|
||||
| `frontend/lib/registry/components/v2-rack-structure/types.ts` | `FormatSegment`, `LocationFormatConfig` 타입 추가, `RackStructureComponentConfig`에 `formatConfig` 필드 추가 | ~25줄 |
|
||||
| `frontend/lib/registry/components/v2-rack-structure/config.ts` | 기본 코드/이름 세그먼트 상수 정의 | ~40줄 |
|
||||
| `frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx` | **신규** - grid 레이아웃 + 컬럼 헤더 + 드래그 순서변경 + showLabel 체크박스 + 라벨/구분/자릿수 고정 필드 + 자릿수 비숫자 타입 비활성화 + 미리보기 | ~200줄 |
|
||||
| `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | "포맷 설정" 섹션 추가, FormatSegmentEditor 배치 | ~30줄 |
|
||||
| `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | `generateLocationCode`를 세그먼트 기반으로 교체 | ~20줄 |
|
||||
|
||||
### 변경하지 않는 파일
|
||||
|
||||
- `buttonActions.ts` - 생성된 `location_code`/`location_name`을 그대로 저장하므로 변경 불필요
|
||||
- 백엔드 전체 - 포맷은 프론트엔드에서만 처리
|
||||
- DB 스키마 - `screen_layouts_v2.layout_data` JSONB에 자동 포함
|
||||
|
||||
---
|
||||
|
||||
## 코드 설계
|
||||
|
||||
### 1. 타입 추가 (types.ts)
|
||||
|
||||
```typescript
|
||||
// 포맷 세그먼트 (위치코드/위치명의 각 구성요소)
|
||||
export interface FormatSegment {
|
||||
type: 'warehouseCode' | 'floor' | 'zone' | 'row' | 'level';
|
||||
enabled: boolean; // 이 세그먼트를 포함할지 여부
|
||||
showLabel: boolean; // 한글 라벨 표시 여부 (false면 값에서 라벨 제거)
|
||||
label: string; // 한글 라벨 (예: "층", "구역", "열", "단")
|
||||
separatorAfter: string; // 이 세그먼트 뒤의 구분자 (예: "-", "/", "")
|
||||
pad: number; // 최소 자릿수 (0 = 그대로, 2 = "01"처럼 2자리 맞춤)
|
||||
}
|
||||
|
||||
// 위치코드 + 위치명 포맷 설정
|
||||
export interface LocationFormatConfig {
|
||||
codeSegments: FormatSegment[];
|
||||
nameSegments: FormatSegment[];
|
||||
}
|
||||
```
|
||||
|
||||
`RackStructureComponentConfig`에 필드 추가:
|
||||
|
||||
```typescript
|
||||
export interface RackStructureComponentConfig {
|
||||
// ... 기존 필드 유지 ...
|
||||
codePattern?: string; // (기존, 하위 호환용 유지)
|
||||
namePattern?: string; // (기존, 하위 호환용 유지)
|
||||
formatConfig?: LocationFormatConfig; // 신규: 구조화된 포맷 설정
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 기본 세그먼트 상수 (config.ts)
|
||||
|
||||
```typescript
|
||||
import { FormatSegment, LocationFormatConfig } from "./types";
|
||||
|
||||
// 위치코드 기본 세그먼트 (현재 하드코딩과 동일한 결과)
|
||||
export const defaultCodeSegments: FormatSegment[] = [
|
||||
{ type: "warehouseCode", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 0 },
|
||||
{ type: "floor", enabled: true, showLabel: true, label: "층", separatorAfter: "", pad: 0 },
|
||||
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
|
||||
{ type: "row", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 2 },
|
||||
{ type: "level", enabled: true, showLabel: false, label: "", separatorAfter: "", pad: 0 },
|
||||
];
|
||||
|
||||
// 위치명 기본 세그먼트 (현재 하드코딩과 동일한 결과)
|
||||
export const defaultNameSegments: FormatSegment[] = [
|
||||
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
|
||||
{ type: "row", enabled: true, showLabel: true, label: "열", separatorAfter: "-", pad: 2 },
|
||||
{ type: "level", enabled: true, showLabel: true, label: "단", separatorAfter: "", pad: 0 },
|
||||
];
|
||||
|
||||
export const defaultFormatConfig: LocationFormatConfig = {
|
||||
codeSegments: defaultCodeSegments,
|
||||
nameSegments: defaultNameSegments,
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 세그먼트 기반 문자열 생성 함수 (config.ts)
|
||||
|
||||
```typescript
|
||||
// context 값에 포함된 한글 접미사 ("1층", "A구역")
|
||||
const KNOWN_SUFFIXES: Partial<Record<FormatSegmentType, string>> = {
|
||||
floor: "층",
|
||||
zone: "구역",
|
||||
};
|
||||
|
||||
function stripKnownSuffix(type: FormatSegmentType, val: string): string {
|
||||
const suffix = KNOWN_SUFFIXES[type];
|
||||
if (suffix && val.endsWith(suffix)) {
|
||||
return val.slice(0, -suffix.length);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
export function buildFormattedString(
|
||||
segments: FormatSegment[],
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
const activeSegments = segments.filter(
|
||||
(seg) => seg.enabled && values[seg.type],
|
||||
);
|
||||
|
||||
return activeSegments
|
||||
.map((seg, idx) => {
|
||||
// 1) 원본 값에서 한글 접미사를 먼저 벗겨냄 ("A구역" → "A", "1층" → "1")
|
||||
let val = stripKnownSuffix(seg.type, values[seg.type]);
|
||||
|
||||
// 2) showLabel이 켜져 있고 label이 있으면 붙임
|
||||
if (seg.showLabel && seg.label) {
|
||||
val += seg.label;
|
||||
}
|
||||
|
||||
if (seg.pad > 0 && !isNaN(Number(val))) {
|
||||
val = val.padStart(seg.pad, "0");
|
||||
}
|
||||
|
||||
if (idx < activeSegments.length - 1) {
|
||||
val += seg.separatorAfter;
|
||||
}
|
||||
return val;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
```
|
||||
|
||||
### 4. generateLocationCode 교체 (RackStructureComponent.tsx:494~510)
|
||||
|
||||
```typescript
|
||||
// 변경 전 (하드코딩)
|
||||
const generateLocationCode = useCallback(
|
||||
(row: number, level: number): { code: string; name: string } => {
|
||||
const warehouseCode = context?.warehouseCode || "WH001";
|
||||
const floor = context?.floor;
|
||||
const zone = context?.zone || "A";
|
||||
const floorPrefix = floor ? `${floor}` : "";
|
||||
const code = `${warehouseCode}-${floorPrefix}${zone}-...`;
|
||||
// ...
|
||||
},
|
||||
[context],
|
||||
);
|
||||
|
||||
// 변경 후 (세그먼트 기반)
|
||||
const formatConfig = config.formatConfig || defaultFormatConfig;
|
||||
|
||||
const generateLocationCode = useCallback(
|
||||
(row: number, level: number): { code: string; name: string } => {
|
||||
const values: Record<string, string> = {
|
||||
warehouseCode: context?.warehouseCode || "WH001",
|
||||
floor: context?.floor || "",
|
||||
zone: context?.zone || "A",
|
||||
row: row.toString(),
|
||||
level: level.toString(),
|
||||
};
|
||||
|
||||
const code = buildFormattedString(formatConfig.codeSegments, values);
|
||||
const name = buildFormattedString(formatConfig.nameSegments, values);
|
||||
|
||||
return { code, name };
|
||||
},
|
||||
[context, formatConfig],
|
||||
);
|
||||
```
|
||||
|
||||
### 5. ConfigPanel에 포맷 설정 섹션 추가 (RackStructureConfigPanel.tsx:284행 위)
|
||||
|
||||
```tsx
|
||||
{/* 포맷 설정 - UI 설정 섹션 아래에 추가 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-gray-700">포맷 설정</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고,
|
||||
구분자/라벨을 편집할 수 있습니다
|
||||
</p>
|
||||
|
||||
<FormatSegmentEditor
|
||||
label="위치코드 포맷"
|
||||
segments={formatConfig.codeSegments}
|
||||
onChange={(segs) => handleFormatChange("codeSegments", segs)}
|
||||
sampleValues={sampleValues}
|
||||
/>
|
||||
|
||||
<FormatSegmentEditor
|
||||
label="위치명 포맷"
|
||||
segments={formatConfig.nameSegments}
|
||||
onChange={(segs) => handleFormatChange("nameSegments", segs)}
|
||||
sampleValues={sampleValues}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 6. FormatSegmentEditor 서브컴포넌트 (신규 파일)
|
||||
|
||||
- `@dnd-kit/core` + `@dnd-kit/sortable`로 드래그 순서변경
|
||||
- 프로젝트 표준 패턴: `useSortable`, `DndContext`, `SortableContext` 사용
|
||||
- **grid 레이아웃** (`grid-cols-[16px_56px_18px_1fr_1fr_1fr]`): 드래그핸들 / 타입명 / 체크박스 / 라벨 / 구분 / 자릿수
|
||||
- 최상단에 **컬럼 헤더** (`라벨` / `구분` / `자릿수`) 표시 — 각 행에서 텍스트 라벨 제거하여 공간 절약
|
||||
- 라벨/구분/자릿수 3개 필드는 **항상 고정 표시** (빈 값이어도 입력 필드가 사라지지 않음)
|
||||
- 자릿수 필드는 숫자 타입(열, 단)만 활성화, 비숫자 타입은 `disabled` + 회색 배경
|
||||
- 하단에 `buildFormattedString`으로 실시간 미리보기 표시
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- `formatConfig` 미설정 시 `defaultFormatConfig` 적용으로 **기존 동작 100% 유지** (하위 호환)
|
||||
- 포맷 설정은 **화면 디자이너 ConfigPanel에서만** 편집 (프로젝트의 설정-사용 분리 관행 준수)
|
||||
- `componentConfig` → `screen_layouts_v2.layout_data` 저장 파이프라인을 **그대로 활용** (추가 인프라 불필요)
|
||||
- 기존 `codePattern`/`namePattern` 문자열 필드는 삭제하지 않고 유지 (하위 호환)
|
||||
- v2-pivot-grid의 `format` 설정 패턴과 동일한 구조: ConfigPanel에서 설정 → 런타임에서 읽어 사용
|
||||
- `@dnd-kit` 드래그 구현은 `SortableCodeItem.tsx`, `useDragAndDrop.ts`의 기존 패턴 재사용
|
||||
- 백엔드 변경 없음, DB 스키마 변경 없음
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
# [맥락노트] 렉 구조 위치코드/위치명 포맷 사용자 설정
|
||||
|
||||
> 관련 문서: [계획서](./LFC[계획]-위치포맷-사용자설정.md) | [체크리스트](./LFC[체크]-위치포맷-사용자설정.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 위치코드(`WH001-1층A구역-01-1`)와 위치명(`A구역-01열-1단`)의 포맷이 하드코딩되어 있음
|
||||
- 회사마다 구분자("-" vs "/"), 세그먼트 순서, 한글 라벨 유무 등 요구사항이 다름
|
||||
- 현재는 코드를 직접 수정하지 않으면 포맷 변경 불가 → 관리자가 화면 디자이너에서 설정할 수 있어야 함
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. 엔드유저 모달이 아닌 화면 디자이너 ConfigPanel에 설정 UI 배치
|
||||
|
||||
- **결정**: 포맷 편집 UI를 렉 구조 등록 모달이 아닌 화면 디자이너 좌측 속성 패널(ConfigPanel)에 배치
|
||||
- **근거**: 프로젝트의 설정-사용 분리 패턴 준수. 모든 v2 컴포넌트가 ConfigPanel에서 설정하고 런타임에서 읽기만 하는 구조를 따름
|
||||
- **대안 검토**: 모달 안에 포맷 편집 UI 배치(방법 B) → 기각 (프로젝트 관행에 맞지 않음, 매번 설정해야 함, 설정이 휘발됨)
|
||||
|
||||
### 2. 패턴 문자열이 아닌 구조화된 세그먼트 배열 사용
|
||||
|
||||
- **결정**: `"{warehouseCode}-{floor}{zone}-{row:02d}-{level}"` 같은 문자열 대신 `FormatSegment[]` 배열로 포맷 정의
|
||||
- **근거**: 관리자가 패턴 문법을 알 필요 없이 드래그/토글/Input으로 직관적 편집 가능
|
||||
- **대안 검토**: 기존 `codePattern`/`namePattern` 문자열 활용 → 기각 (관리자가 패턴 문법을 모를 수 있고, 오타 가능성 높음)
|
||||
|
||||
### 2-1. 체크박스는 한글 라벨 표시/숨김 제어 (showLabel)
|
||||
|
||||
- **결정**: 세그먼트의 체크박스는 `showLabel` 속성을 토글하며, 세그먼트 자체를 제거하지 않음
|
||||
- **근거**: "A구역-01열-1단"에서 "구역", "열" 체크 해제 시 → "A-01-1단"이 되어야 함 (값은 유지, 한글만 제거)
|
||||
- **주의**: `enabled`는 세그먼트 자체의 포함 여부, `showLabel`은 한글 라벨만 표시/숨김. 혼동하지 않도록 분리
|
||||
|
||||
### 2-2. 라벨/구분/자릿수 3개 필드 항상 고정 표시
|
||||
|
||||
- **결정**: 라벨 필드를 비워도 입력 필드가 사라지지 않고, 3개 필드(라벨, 구분, 자릿수)가 모든 세그먼트에 항상 표시
|
||||
- **근거**: 라벨을 지웠을 때 "라벨 없음"이 뜨면서 입력 필드가 사라지면 다시 라벨을 추가할 수 없는 문제 발생
|
||||
- **UI 개선**: 컬럼 헤더를 최상단에 배치하고, 각 행에서는 "구분", "자릿수" 텍스트를 제거하여 공간 확보
|
||||
|
||||
### 2-3. stripKnownSuffix로 원본 값의 한글 접미사를 먼저 벗긴 뒤 라벨 붙임
|
||||
|
||||
- **결정**: `buildFormattedString`에서 값을 처리할 때, 먼저 `KNOWN_SUFFIXES`(층, 구역)를 벗겨내고 순수 값만 남긴 뒤, `showLabel && label`일 때만 라벨을 붙이는 구조
|
||||
- **근거**: context 값이 "1층", "A구역"처럼 한글이 이미 포함된 상태로 들어옴. 이전 방식(`if (seg.label)`)은 라벨 필드가 빈 문자열이면 조건을 건너뛰어서 한글이 제거되지 않는 버그 발생
|
||||
- **핵심 흐름**: 원본 값 → `stripKnownSuffix` → 순수 값 → `showLabel && label`이면 라벨 붙임
|
||||
|
||||
### 2-4. 자릿수 필드는 숫자 타입만 활성화
|
||||
|
||||
- **결정**: 자릿수(pad) 필드는 열(row), 단(level)만 편집 가능, 나머지(창고코드, 층, 구역)는 disabled + 회색 배경
|
||||
- **근거**: 자릿수(zero-padding)는 숫자 값에만 의미가 있음. 비숫자 타입에 자릿수를 설정하면 혼란을 줄 수 있음
|
||||
|
||||
### 3. 기존 codePattern/namePattern 필드는 삭제하지 않고 유지
|
||||
|
||||
- **결정**: `types.ts`의 `codePattern`, `namePattern` 필드를 삭제하지 않음
|
||||
- **근거**: 하위 호환. 기존에 이 필드를 참조하는 코드가 없지만, 향후 다른 용도로 활용될 수 있음
|
||||
|
||||
### 4. formatConfig 미설정 시 기본값으로 현재 동작 유지
|
||||
|
||||
- **결정**: `config.formatConfig`가 없으면 `defaultFormatConfig` 사용
|
||||
- **근거**: 기존 화면 설정을 수정하지 않아도 현재와 동일한 위치코드/위치명이 생성됨 (무중단 배포 가능)
|
||||
|
||||
### 5. UI 라벨에서 "패딩" 대신 "자릿수" 사용
|
||||
|
||||
- **결정**: ConfigPanel UI에서 숫자 제로패딩 설정을 "자릿수"로 표시
|
||||
- **근거**: 관리자급 사용자가 "패딩"이라는 개발 용어를 모를 수 있음. "자릿수: 2 → 01, 02, ... 99"가 직관적
|
||||
- **코드 내부**: 변수명은 `pad` 유지 (개발자 영역)
|
||||
|
||||
### 6. @dnd-kit으로 드래그 구현
|
||||
|
||||
- **결정**: `@dnd-kit/core` + `@dnd-kit/sortable` 사용
|
||||
- **근거**: 프로젝트에 이미 설치되어 있고(`package.json`), `SortableCodeItem.tsx`, `useDragAndDrop.ts` 등 표준 패턴이 확립되어 있음
|
||||
- **대안 검토**: 위/아래 화살표 버튼으로 순서 변경 → 기각 (프로젝트에 이미 DnD 패턴이 있으므로 일관성 유지)
|
||||
|
||||
### 7. v2-pivot-grid의 format 설정 패턴을 참고
|
||||
|
||||
- **결정**: ConfigPanel에서 설정 → componentConfig에 저장 → 런타임에서 읽어 사용하는 흐름
|
||||
- **근거**: v2-pivot-grid가 필드별 `format`(type, precision, thousandSeparator 등)을 동일한 패턴으로 구현하고 있음. 가장 유사한 선례
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 타입 정의 | `frontend/lib/registry/components/v2-rack-structure/types.ts` | FormatSegment, LocationFormatConfig 타입 |
|
||||
| 기본 설정 | `frontend/lib/registry/components/v2-rack-structure/config.ts` | 기본 세그먼트 상수, buildFormattedString 함수 |
|
||||
| 신규 컴포넌트 | `frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx` | 포맷 편집 UI 서브컴포넌트 |
|
||||
| 설정 패널 | `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | FormatSegmentEditor 배치 |
|
||||
| 런타임 컴포넌트 | `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | generateLocationCode 세그먼트 기반 교체 |
|
||||
| DnD 참고 | `frontend/hooks/useDragAndDrop.ts` | 프로젝트 표준 DnD 패턴 |
|
||||
| DnD 참고 | `frontend/components/admin/SortableCodeItem.tsx` | useSortable 사용 예시 |
|
||||
| 선례 참고 | `frontend/lib/registry/components/v2-pivot-grid/` | ConfigPanel에서 format 설정하는 패턴 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### 세그먼트 기반 문자열 생성 흐름
|
||||
|
||||
```
|
||||
FormatSegment[] → filter(enabled && 값 있음) → map(stripKnownSuffix → showLabel && label이면 라벨 붙임 → 자릿수 → 구분자) → join("") → 최종 문자열
|
||||
```
|
||||
|
||||
### componentConfig 저장/로드 흐름
|
||||
|
||||
```
|
||||
ConfigPanel onChange
|
||||
→ V2PropertiesPanel.onUpdateProperty("componentConfig", mergedConfig)
|
||||
→ layout.components[i].componentConfig.formatConfig
|
||||
→ convertLegacyToV2 → screen_layouts_v2.layout_data.overrides.formatConfig (DB)
|
||||
→ convertV2ToLegacy → componentConfig.formatConfig (런타임)
|
||||
→ RackStructureComponent config.formatConfig (prop)
|
||||
```
|
||||
|
||||
### context 값 참고
|
||||
|
||||
```
|
||||
context.warehouseCode = "WH001" (창고 코드)
|
||||
context.floor = "1층" (층 라벨 - 값 자체에 "층" 포함)
|
||||
context.zone = "A구역" 또는 "A" (구역 라벨 - "구역" 포함 여부 불확실)
|
||||
row = 1, 2, 3, ... (열 번호 - 숫자)
|
||||
level = 1, 2, 3, ... (단 번호 - 숫자)
|
||||
```
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
# [체크리스트] 렉 구조 위치코드/위치명 포맷 사용자 설정
|
||||
|
||||
> 관련 문서: [계획서](./LFC[계획]-위치포맷-사용자설정.md) | [맥락노트](./LFC[맥락]-위치포맷-사용자설정.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (완료)
|
||||
- 현재 단계: 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 1단계: 타입 및 기본값 정의
|
||||
|
||||
- [x] `types.ts`에 `FormatSegment` 인터페이스 추가
|
||||
- [x] `types.ts`에 `LocationFormatConfig` 인터페이스 추가
|
||||
- [x] `types.ts`의 `RackStructureComponentConfig`에 `formatConfig?: LocationFormatConfig` 필드 추가
|
||||
- [x] `config.ts`에 `defaultCodeSegments` 상수 정의 (현재 하드코딩과 동일한 결과)
|
||||
- [x] `config.ts`에 `defaultNameSegments` 상수 정의 (현재 하드코딩과 동일한 결과)
|
||||
- [x] `config.ts`에 `defaultFormatConfig` 상수 정의
|
||||
- [x] `config.ts`에 `buildFormattedString()` 함수 구현 (stripKnownSuffix 방식)
|
||||
|
||||
### 2단계: FormatSegmentEditor 서브컴포넌트 생성
|
||||
|
||||
- [x] `FormatSegmentEditor.tsx` 신규 파일 생성
|
||||
- [x] `@dnd-kit/sortable` 기반 드래그 순서변경 구현
|
||||
- [x] 세그먼트별 체크박스로 한글 라벨 표시/숨김 토글 (showLabel)
|
||||
- [x] 라벨/구분/자릿수 3개 필드 항상 고정 표시 (빈 값이어도 입력 필드 유지)
|
||||
- [x] 최상단 컬럼 헤더 추가 (라벨 / 구분 / 자릿수), 각 행에서 텍스트 라벨 제거
|
||||
- [x] grid 레이아웃으로 정렬 (`grid-cols-[16px_56px_18px_1fr_1fr_1fr]`)
|
||||
- [x] 자릿수 필드: 숫자 타입(열, 단)만 활성화, 비숫자 타입은 disabled + 회색 배경
|
||||
- [x] `buildFormattedString`으로 실시간 미리보기 표시
|
||||
|
||||
### 3단계: ConfigPanel에 포맷 설정 섹션 추가
|
||||
|
||||
- [x] `RackStructureConfigPanel.tsx`에 FormatSegmentEditor import
|
||||
- [x] UI 설정 섹션 아래에 "포맷 설정" 섹션 추가
|
||||
- [x] 위치코드 포맷용 FormatSegmentEditor 배치
|
||||
- [x] 위치명 포맷용 FormatSegmentEditor 배치
|
||||
- [x] `onChange`로 `formatConfig` 업데이트 연결
|
||||
|
||||
### 4단계: 컴포넌트에서 세그먼트 기반 코드 생성
|
||||
|
||||
- [x] `RackStructureComponent.tsx`에서 `defaultFormatConfig` import
|
||||
- [x] `generateLocationCode` 함수를 세그먼트 기반으로 교체
|
||||
- [x] `config.formatConfig || defaultFormatConfig` 폴백 적용
|
||||
|
||||
### 5단계: 검증
|
||||
|
||||
- [x] formatConfig 미설정 시: 기존과 동일한 위치코드/위치명 생성 확인
|
||||
- [x] ConfigPanel에서 구분자 변경: 미리보기에 즉시 반영 확인
|
||||
- [x] ConfigPanel에서 라벨 체크 해제: 한글만 사라지고 값은 유지 확인 (예: "A구역" → "A")
|
||||
- [x] ConfigPanel에서 순서 드래그 변경: 미리보기에 반영 확인
|
||||
- [x] ConfigPanel에서 라벨 텍스트 변경: 미리보기에 반영 확인
|
||||
- [x] 설정 저장 후 화면 재로드: 설정 유지 확인
|
||||
- [x] 렉 구조 모달에서 미리보기 생성: 설정된 포맷으로 생성 확인
|
||||
- [x] 렉 구조 저장: DB에 설정된 포맷의 코드/이름 저장 확인
|
||||
|
||||
### 6단계: 정리
|
||||
|
||||
- [x] 린트 에러 없음 확인
|
||||
- [x] 미사용 import 제거 (FormatSegmentEditor.tsx: useState)
|
||||
- [x] 파일 끝 불필요한 빈 줄 제거 (types.ts, config.ts)
|
||||
- [x] 계획서/맥락노트/체크리스트 최종 반영
|
||||
- [x] 이 체크리스트 완료 표시 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-10 | 계획서, 맥락노트, 체크리스트 작성 완료 |
|
||||
| 2026-03-10 | 1~4단계 구현 완료 (types, config, FormatSegmentEditor, ConfigPanel, Component) |
|
||||
| 2026-03-10 | showLabel 로직 수정: 체크박스가 세그먼트 제거가 아닌 한글 라벨만 표시/숨김 처리 |
|
||||
| 2026-03-10 | 계획서, 맥락노트, 체크리스트에 showLabel 변경사항 반영 |
|
||||
| 2026-03-10 | UI 개선: 3필드 고정표시 + 컬럼 헤더 + grid 레이아웃 + 자릿수 비숫자 비활성화 |
|
||||
| 2026-03-10 | 계획서, 맥락노트, 체크리스트에 UI 개선사항 반영 |
|
||||
| 2026-03-10 | 라벨 필드 비움 시 한글 미제거 버그 수정 (stripKnownSuffix 도입) |
|
||||
| 2026-03-10 | 코드 정리 (미사용 import, 빈 줄) + 문서 최종 반영 |
|
||||
| 2026-03-10 | 5단계 검증 완료, 전체 작업 완료 |
|
||||
|
|
@ -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에 유지 (세션 간 보존).
|
||||
|
|
@ -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_*`
|
||||
|
|
@ -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 스코프) |
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
# [계획서] 렉 구조 등록 - 층(floor) 필수 입력 해제
|
||||
|
||||
> 관련 문서: [맥락노트](./RFO[맥락]-렉구조-층필수해제.md) | [체크리스트](./RFO[체크]-렉구조-층필수해제.md)
|
||||
|
||||
## 개요
|
||||
|
||||
탑씰 회사의 물류관리 > 창고정보 관리 > 렉 구조 등록 모달에서, "층" 필드를 필수 입력에서 선택 입력으로 변경합니다. 현재 "창고 코드 / 층 / 구역" 3개가 모두 필수로 하드코딩되어 있어, 층을 선택하지 않으면 미리보기 생성과 저장이 불가능합니다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 동작
|
||||
|
||||
### 1. 필수 필드 경고 (RackStructureComponent.tsx:291~298)
|
||||
|
||||
층을 선택하지 않으면 빨간 경고가 표시됨:
|
||||
|
||||
```tsx
|
||||
const missingFields = useMemo(() => {
|
||||
const missing: string[] = [];
|
||||
if (!context.warehouseCode) missing.push("창고 코드");
|
||||
if (!context.floor) missing.push("층"); // ← 하드코딩 필수
|
||||
if (!context.zone) missing.push("구역");
|
||||
return missing;
|
||||
}, [context]);
|
||||
```
|
||||
|
||||
> "다음 필드를 먼저 입력해주세요: **층**"
|
||||
|
||||
### 2. 미리보기 생성 차단 (RackStructureComponent.tsx:517~521)
|
||||
|
||||
`missingFields`에 "층"이 포함되어 있으면 `generatePreview()` 실행이 차단됨:
|
||||
|
||||
```tsx
|
||||
if (missingFields.length > 0) {
|
||||
alert(`다음 필드를 먼저 입력해주세요: ${missingFields.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 위치 코드 생성 (RackStructureComponent.tsx:497~513)
|
||||
|
||||
floor가 없으면 기본값 `"1"`을 사용하여 위치 코드를 생성:
|
||||
|
||||
```tsx
|
||||
const floor = context?.floor || "1";
|
||||
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
||||
// 예: WH001-1층A구역-01-1
|
||||
```
|
||||
|
||||
### 4. 기존 데이터 조회 (RackStructureComponent.tsx:378~432)
|
||||
|
||||
floor가 비어있으면 기존 데이터 조회 자체를 건너뜀 → 중복 체크 불가:
|
||||
|
||||
```tsx
|
||||
if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) {
|
||||
setExistingLocations([]);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 렉 구조 화면 감지 (buttonActions.ts:692~698)
|
||||
|
||||
floor가 비어있으면 렉 구조 화면으로 인식하지 않음 → 일반 저장으로 빠짐:
|
||||
|
||||
```tsx
|
||||
const isRackStructureScreen =
|
||||
context.tableName === "warehouse_location" &&
|
||||
context.formData?.floor && // ← floor 없으면 false
|
||||
context.formData?.zone &&
|
||||
!rackStructureLocations;
|
||||
```
|
||||
|
||||
### 6. 저장 전 중복 체크 (buttonActions.ts:2085~2131)
|
||||
|
||||
floor가 없으면 중복 체크 전체를 건너뜀:
|
||||
|
||||
```tsx
|
||||
if (warehouseCode && floor && zone) {
|
||||
// 중복 체크 로직
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 후 동작
|
||||
|
||||
### 1. 필수 필드에서 "층" 제거
|
||||
|
||||
- "창고 코드"와 "구역"만 필수
|
||||
- 층을 선택하지 않아도 경고가 뜨지 않음
|
||||
|
||||
### 2. 미리보기 생성 정상 동작
|
||||
|
||||
- 층 없이도 미리보기 생성 가능
|
||||
- 위치 코드에서 층 부분을 생략하여 깔끔하게 생성
|
||||
|
||||
### 3. 위치 코드 생성 규칙 변경
|
||||
|
||||
- 층 있을 때: `WH001-1층A구역-01-1` (기존과 동일)
|
||||
- 층 없을 때: `WH001-A구역-01-1` (층 부분 생략)
|
||||
|
||||
### 4. 기존 데이터 조회 (중복 체크)
|
||||
|
||||
- 층 있을 때: `warehouse_code + floor + zone`으로 조회 (기존과 동일)
|
||||
- 층 없을 때: `warehouse_code + zone`으로 조회 (floor 조건 제외)
|
||||
|
||||
### 5. 렉 구조 화면 감지
|
||||
|
||||
- floor 유무와 관계없이 `warehouse_location` 테이블 + zone 필드가 있으면 렉 구조 화면으로 인식
|
||||
|
||||
### 6. 저장 시 floor 값
|
||||
|
||||
- 층 선택함: `floor = "1층"` 등 선택한 값 저장
|
||||
- 층 미선택: `floor = NULL`로 저장
|
||||
|
||||
---
|
||||
|
||||
## 시각적 예시
|
||||
|
||||
| 상태 | 경고 메시지 | 미리보기 | 위치 코드 | DB floor 값 |
|
||||
|------|------------|---------|-----------|------------|
|
||||
| 창고+층+구역 모두 선택 | 없음 | 생성 가능 | `WH001-1층A구역-01-1` | `"1층"` |
|
||||
| 창고+구역만 선택 (층 미선택) | 없음 | 생성 가능 | `WH001-A구역-01-1` | `NULL` |
|
||||
| 창고만 선택 | "구역을 먼저 입력해주세요" | 차단 | - | - |
|
||||
| 아무것도 미선택 | "창고 코드, 구역을 먼저 입력해주세요" | 차단 | - | - |
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 데이터 흐름 (변경 전)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[사용자: 창고/층/구역 입력] --> B{필수 필드 검증}
|
||||
B -->|층 없음| C[경고: 층을 입력하세요]
|
||||
B -->|3개 다 있음| D[기존 데이터 조회<br/>warehouse_code + floor + zone]
|
||||
D --> E[미리보기 생성]
|
||||
E --> F{저장 버튼}
|
||||
F --> G[렉 구조 화면 감지<br/>floor && zone 필수]
|
||||
G --> H[중복 체크<br/>warehouse_code + floor + zone]
|
||||
H --> I[일괄 INSERT<br/>floor = 선택값]
|
||||
```
|
||||
|
||||
### 데이터 흐름 (변경 후)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[사용자: 창고/구역 입력<br/>층은 선택사항] --> B{필수 필드 검증}
|
||||
B -->|창고 or 구역 없음| C[경고: 해당 필드를 입력하세요]
|
||||
B -->|창고+구역 있음| D{floor 값 존재?}
|
||||
D -->|있음| E1[기존 데이터 조회<br/>warehouse_code + floor + zone]
|
||||
D -->|없음| E2[기존 데이터 조회<br/>warehouse_code + zone]
|
||||
E1 --> F[미리보기 생성]
|
||||
E2 --> F
|
||||
F --> G{저장 버튼}
|
||||
G --> H[렉 구조 화면 감지<br/>zone만 필수]
|
||||
H --> I{floor 값 존재?}
|
||||
I -->|있음| J1[중복 체크<br/>warehouse_code + floor + zone]
|
||||
I -->|없음| J2[중복 체크<br/>warehouse_code + zone]
|
||||
J1 --> K[일괄 INSERT<br/>floor = 선택값]
|
||||
J2 --> K2[일괄 INSERT<br/>floor = NULL]
|
||||
```
|
||||
|
||||
### 컴포넌트 관계
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph 프론트엔드
|
||||
A[폼 필드<br/>창고/층/구역] -->|formData| B[RackStructureComponent<br/>필수 검증 + 미리보기]
|
||||
B -->|locations 배열| C[buttonActions.ts<br/>화면 감지 + 중복 체크 + 저장]
|
||||
end
|
||||
subgraph 백엔드
|
||||
C -->|POST /dynamic-form/save| D[DynamicFormApi<br/>데이터 저장]
|
||||
D --> E[(warehouse_location<br/>floor: nullable)]
|
||||
end
|
||||
|
||||
style B fill:#fff3cd,stroke:#ffc107
|
||||
style C fill:#fff3cd,stroke:#ffc107
|
||||
```
|
||||
|
||||
> 노란색 = 이번에 수정하는 부분
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상 파일
|
||||
|
||||
| 파일 | 수정 내용 | 수정 규모 |
|
||||
|------|----------|----------|
|
||||
| `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | 필수 검증에서 floor 제거, 위치 코드 생성 로직 수정, 기존 데이터 조회 로직 수정 | ~20줄 |
|
||||
| `frontend/lib/utils/buttonActions.ts` | 렉 구조 화면 감지 조건 수정, 중복 체크 조건 수정 | ~10줄 |
|
||||
|
||||
### 사전 확인 필요
|
||||
|
||||
| 확인 항목 | 내용 |
|
||||
|----------|------|
|
||||
| DB 스키마 | `warehouse_location.floor` 컬럼이 `NULL` 허용인지 확인. NOT NULL이면 `ALTER TABLE` 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 코드 설계
|
||||
|
||||
### 1. 필수 필드 검증 수정 (RackStructureComponent.tsx:291~298)
|
||||
|
||||
```tsx
|
||||
// 변경 전
|
||||
const missingFields = useMemo(() => {
|
||||
const missing: string[] = [];
|
||||
if (!context.warehouseCode) missing.push("창고 코드");
|
||||
if (!context.floor) missing.push("층");
|
||||
if (!context.zone) missing.push("구역");
|
||||
return missing;
|
||||
}, [context]);
|
||||
|
||||
// 변경 후
|
||||
const missingFields = useMemo(() => {
|
||||
const missing: string[] = [];
|
||||
if (!context.warehouseCode) missing.push("창고 코드");
|
||||
if (!context.zone) missing.push("구역");
|
||||
return missing;
|
||||
}, [context]);
|
||||
```
|
||||
|
||||
### 2. 위치 코드 생성 수정 (RackStructureComponent.tsx:497~513)
|
||||
|
||||
```tsx
|
||||
// 변경 전
|
||||
const floor = context?.floor || "1";
|
||||
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
||||
|
||||
// 변경 후
|
||||
const floor = context?.floor;
|
||||
const floorPrefix = floor ? `${floor}` : "";
|
||||
const code = `${warehouseCode}-${floorPrefix}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
||||
// 층 있을 때: WH001-1층A구역-01-1
|
||||
// 층 없을 때: WH001-A구역-01-1
|
||||
```
|
||||
|
||||
### 3. 기존 데이터 조회 수정 (RackStructureComponent.tsx:378~432)
|
||||
|
||||
```tsx
|
||||
// 변경 전
|
||||
if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) {
|
||||
setExistingLocations([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams = {
|
||||
warehouse_code: { value: warehouseCodeForQuery, operator: "equals" },
|
||||
floor: { value: floorForQuery, operator: "equals" },
|
||||
zone: { value: zoneForQuery, operator: "equals" },
|
||||
};
|
||||
|
||||
// 변경 후
|
||||
if (!warehouseCodeForQuery || !zoneForQuery) {
|
||||
setExistingLocations([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams: Record<string, any> = {
|
||||
warehouse_code: { value: warehouseCodeForQuery, operator: "equals" },
|
||||
zone: { value: zoneForQuery, operator: "equals" },
|
||||
};
|
||||
if (floorForQuery) {
|
||||
searchParams.floor = { value: floorForQuery, operator: "equals" };
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 렉 구조 화면 감지 수정 (buttonActions.ts:692~698)
|
||||
|
||||
```tsx
|
||||
// 변경 전
|
||||
const isRackStructureScreen =
|
||||
context.tableName === "warehouse_location" &&
|
||||
context.formData?.floor &&
|
||||
context.formData?.zone &&
|
||||
!rackStructureLocations;
|
||||
|
||||
// 변경 후
|
||||
const isRackStructureScreen =
|
||||
context.tableName === "warehouse_location" &&
|
||||
context.formData?.zone &&
|
||||
!rackStructureLocations;
|
||||
```
|
||||
|
||||
### 5. 저장 전 중복 체크 수정 (buttonActions.ts:2085~2131)
|
||||
|
||||
```tsx
|
||||
// 변경 전
|
||||
if (warehouseCode && floor && zone) {
|
||||
const existingResponse = await DynamicFormApi.getTableData(tableName, {
|
||||
search: {
|
||||
warehouse_code: { value: warehouseCode, operator: "equals" },
|
||||
floor: { value: floor, operator: "equals" },
|
||||
zone: { value: zone, operator: "equals" },
|
||||
},
|
||||
// ...
|
||||
});
|
||||
}
|
||||
|
||||
// 변경 후
|
||||
if (warehouseCode && zone) {
|
||||
const searchParams: Record<string, any> = {
|
||||
warehouse_code: { value: warehouseCode, operator: "equals" },
|
||||
zone: { value: zone, operator: "equals" },
|
||||
};
|
||||
if (floor) {
|
||||
searchParams.floor = { value: floor, operator: "equals" };
|
||||
}
|
||||
|
||||
const existingResponse = await DynamicFormApi.getTableData(tableName, {
|
||||
search: searchParams,
|
||||
// ...
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 적용 범위 및 영향도
|
||||
|
||||
### 이번 변경은 전역 설정
|
||||
|
||||
방법 B는 렉 구조 컴포넌트 코드에서 직접 "층 필수"를 제거하는 방식이므로, 이 컴포넌트를 사용하는 **모든 회사**에 동일하게 적용됩니다.
|
||||
|
||||
| 회사 | 변경 후 |
|
||||
|------|--------|
|
||||
| 탑씰 | 층 안 골라도 됨 (요청 사항) |
|
||||
| 다른 회사 | 층 안 골라도 됨 (동일하게 적용) |
|
||||
|
||||
### 기존 사용자에 대한 영향
|
||||
|
||||
- 층을 안 골라도 **되는** 것이지, 안 골라야 **하는** 것이 아님
|
||||
- 기존처럼 층을 선택하면 **완전히 동일하게** 동작함 (하위 호환 보장)
|
||||
- 즉, 기존 사용 패턴을 유지하는 회사에는 아무런 차이가 없음
|
||||
|
||||
### 회사별 독립 제어가 필요한 경우
|
||||
|
||||
만약 특정 회사는 층을 필수로 유지하고, 다른 회사는 선택으로 해야 하는 상황이 발생하면, 방법 A(설정 기능 추가)로 업그레이드가 필요합니다. 이번 방법 B의 변경은 향후 방법 A로 전환할 때 충돌 없이 확장 가능합니다.
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- "창고 코드"와 "구역"의 필수 검증은 기존과 동일하게 유지
|
||||
- 층을 선택한 경우의 동작은 기존과 완전히 동일 (하위 호환)
|
||||
- 층 미선택 시 위치 코드에서 층 부분을 깔끔하게 생략 (폴백값 "1" 사용하지 않음)
|
||||
- 중복 체크는 가용한 필드 기준으로 수행 (floor 없으면 warehouse_code + zone 기준)
|
||||
- DB에는 NULL로 저장하여 "미입력"을 정확하게 표현 (프로젝트 표준 패턴)
|
||||
- 특수 문자열("상관없음" 등) 사용하지 않음 (프로젝트 관행에 맞지 않으므로)
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
# [맥락노트] 렉 구조 등록 - 층(floor) 필수 입력 해제
|
||||
|
||||
> 관련 문서: [계획서](./RFO[계획]-렉구조-층필수해제.md) | [체크리스트](./RFO[체크]-렉구조-층필수해제.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 탑씰 회사에서 창고 렉 구조 등록 시 "층"을 선택하지 않아도 되게 해달라는 요청
|
||||
- 현재 코드에 창고 코드 / 층 / 구역 3개가 필수로 하드코딩되어 있어, 층 미선택 시 미리보기 생성과 저장이 모두 차단됨
|
||||
- 층 필수 검증이 6곳에 분산되어 있어 한 곳만 고치면 다른 곳에서 오류 발생
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. 방법 B(하드코딩 제거) 채택, 방법 A(설정 기능) 미채택
|
||||
|
||||
- **결정**: 코드에서 floor 필수 조건을 직접 제거
|
||||
- **근거**: 이 프로젝트의 다른 모달/컴포넌트들은 모두 코드에서 직접 "필수/선택"을 정해놓는 방식을 사용. 설정으로 필수 여부를 바꿀 수 있게 만든 패턴은 기존에 없음
|
||||
- **대안 검토**:
|
||||
- 방법 A(ConfigPanel에 requiredFields 설정 추가): 유연하지만 4파일 수정 + 프로젝트에 없던 새 패턴 도입 → 기각
|
||||
- "상관없음" 값 추가 후 null 변환: 프로젝트 어디에서도 magic value → null 변환 패턴을 쓰지 않음 → 기각
|
||||
- "상관없음" 값만 추가 (코드 무변경): DB에 "상관없음" 텍스트가 저장되어 데이터가 지저분함 → 기각
|
||||
- **향후**: 회사별 독립 제어가 필요해지면 방법 A로 확장 가능 (충돌 없음)
|
||||
|
||||
### 2. 전역 적용 (회사별 독립 설정 아님)
|
||||
|
||||
- **결정**: 렉 구조 컴포넌트를 사용하는 모든 회사에 동일 적용
|
||||
- **근거**: 방법 B는 코드 직접 수정이므로 회사별 분기 불가. 단, 기존처럼 층을 선택하면 완전히 동일하게 동작하므로 다른 회사에 실질적 영향 없음 (선택 안 해도 "되는" 것이지, 안 해야 "하는" 것이 아님)
|
||||
|
||||
### 3. floor 미선택 시 NULL 저장 (특수값 아님)
|
||||
|
||||
- **결정**: floor를 선택하지 않으면 DB에 `NULL` 저장
|
||||
- **근거**: 프로젝트 표준 패턴. `UserFormModal`의 `email: formData.email || null`, `EnhancedFormService`의 빈 문자열 → null 자동 변환 등과 동일한 방식
|
||||
- **대안 검토**: "상관없음" 저장 후 null 변환 → 프로젝트에서 미사용 패턴이므로 기각
|
||||
|
||||
### 4. 위치 코드에서 층 부분 생략 (폴백값 "1" 사용 안 함)
|
||||
|
||||
- **결정**: floor 없을 때 위치 코드에서 층 부분을 아예 빼버림
|
||||
- **근거**: 기존 코드는 `context?.floor || "1"`로 폴백하여 1층을 선택한 것처럼 위장됨. 이는 잘못된 데이터를 만들 수 있음
|
||||
- **결과**:
|
||||
- 층 있을 때: `WH001-1층A구역-01-1` (기존과 동일)
|
||||
- 층 없을 때: `WH001-A구역-01-1` (층 부분 없이 깔끔)
|
||||
|
||||
### 5. 중복 체크는 가용 필드 기준으로 수행
|
||||
|
||||
- **결정**: floor 없으면 `warehouse_code + zone`으로 중복 체크, floor 있으면 `warehouse_code + floor + zone`으로 중복 체크
|
||||
- **근거**: 기존 코드는 floor 없으면 중복 체크 전체를 건너뜀 → 중복 데이터 발생 위험. 가용 필드 기준으로 체크하면 floor 유무와 관계없이 안전
|
||||
|
||||
### 6. 렉 구조 화면 감지에서 floor 조건 제거
|
||||
|
||||
- **결정**: `buttonActions.ts`의 `isRackStructureScreen` 조건에서 `context.formData?.floor` 제거
|
||||
- **근거**: floor 없으면 렉 구조 화면으로 인식되지 않아 일반 단건 저장으로 빠짐 → 예기치 않은 동작. zone만으로 감지해야 floor 미선택 시에도 렉 구조 일괄 저장이 정상 동작
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 수정 대상 | `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | 필수 검증, 위치 코드 생성, 기존 데이터 조회 |
|
||||
| 수정 대상 | `frontend/lib/utils/buttonActions.ts` | 화면 감지, 중복 체크 |
|
||||
| 타입 정의 | `frontend/lib/registry/components/v2-rack-structure/types.ts` | RackStructureContext, FieldMapping 등 |
|
||||
| 설정 패널 | `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | 필드 매핑 설정 (이번에 수정 안 함) |
|
||||
| 저장 모달 | `frontend/components/screen/SaveModal.tsx` | 필수 검증 (DB NOT NULL 기반, 별도 확인 필요) |
|
||||
| 사전 확인 | DB `warehouse_location.floor` 컬럼 | NULL 허용 여부 확인, NOT NULL이면 ALTER TABLE 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### 수정 포인트 6곳 요약
|
||||
|
||||
| # | 파일 | 행 | 내용 | 수정 방향 |
|
||||
|---|------|-----|------|----------|
|
||||
| 1 | RackStructureComponent.tsx | 291~298 | missingFields에서 floor 체크 | floor 체크 제거 |
|
||||
| 2 | RackStructureComponent.tsx | 517~521 | 미리보기 생성 차단 | 1번 수정으로 자동 해결 |
|
||||
| 3 | RackStructureComponent.tsx | 497~513 | 위치 코드 생성 `floor \|\| "1"` | 폴백값 제거, 없으면 생략 |
|
||||
| 4 | RackStructureComponent.tsx | 378~432 | 기존 데이터 조회 조건 | floor 없어도 조회 가능하게 |
|
||||
| 5 | buttonActions.ts | 692~698 | 렉 구조 화면 감지 | floor 조건 제거 |
|
||||
| 6 | buttonActions.ts | 2085~2131 | 저장 전 중복 체크 | floor 조건부로 포함 |
|
||||
|
||||
### 프로젝트 표준 optional 필드 처리 패턴
|
||||
|
||||
```
|
||||
빈 값 → null 변환: value || null (UserFormModal)
|
||||
nullable 자동 변환: value === "" && isNullable === "Y" → null (EnhancedFormService)
|
||||
Select placeholder: "__none__" → "" 또는 undefined (여러 ConfigPanel)
|
||||
```
|
||||
|
||||
이번 변경은 위 패턴들과 일관성을 유지합니다.
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# [체크리스트] 렉 구조 등록 - 층(floor) 필수 입력 해제
|
||||
|
||||
> 관련 문서: [계획서](./RFO[계획]-렉구조-층필수해제.md) | [맥락노트](./RFO[맥락]-렉구조-층필수해제.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (완료)
|
||||
- 현재 단계: 전체 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 0단계: 사전 확인
|
||||
|
||||
- [x] DB `warehouse_location.floor` 컬럼 nullable 여부 확인 → 이미 NULL 허용 상태, 변경 불필요
|
||||
|
||||
### 1단계: RackStructureComponent.tsx 수정
|
||||
|
||||
- [x] `missingFields`에서 `if (!context.floor) missing.push("층")` 제거 (291~298행)
|
||||
- [x] `generateLocationCode`에서 `context?.floor || "1"` 폴백 제거, floor 없으면 위치 코드에서 생략 (497~513행)
|
||||
- [x] `loadExistingLocations`에서 floor 없어도 조회 가능하도록 조건 수정 (378~432행)
|
||||
- [x] `searchParams`에 floor를 조건부로 포함하도록 변경
|
||||
|
||||
### 2단계: buttonActions.ts 수정
|
||||
|
||||
- [x] `isRackStructureScreen` 조건에서 `context.formData?.floor` 제거 (692~698행)
|
||||
- [x] `handleRackStructureBatchSave` 중복 체크에서 floor를 조건부로 포함 (2085~2131행)
|
||||
|
||||
### 3단계: 검증
|
||||
|
||||
- [x] 층 선택 + 구역 선택: 기존과 동일하게 동작 확인
|
||||
- [x] 층 미선택 + 구역 선택: 경고 없이 미리보기 생성 가능 확인
|
||||
- [x] 층 미선택 시 위치 코드에 층 부분이 빠져있는지 확인
|
||||
- [x] 층 미선택 시 저장 정상 동작 확인
|
||||
- [x] 층 미선택 시 기존 데이터 중복 체크 정상 동작 확인
|
||||
- [x] 창고 코드 미입력 시 여전히 경고 표시되는지 확인
|
||||
- [x] 구역 미입력 시 여전히 경고 표시되는지 확인
|
||||
|
||||
### 4단계: 정리
|
||||
|
||||
- [x] 린트 에러 없음 확인 (기존 WARNING 1개만 존재, 이번 변경과 무관)
|
||||
- [x] 이 체크리스트 완료 표시 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-10 | 계획서, 맥락노트, 체크리스트 작성 완료 |
|
||||
| 2026-03-10 | 1단계 코드 수정 완료 (RackStructureComponent.tsx) |
|
||||
| 2026-03-10 | 2단계 코드 수정 완료 (buttonActions.ts) |
|
||||
| 2026-03-10 | 린트 에러 확인 완료 |
|
||||
| 2026-03-10 | 사용자 검증 완료, 전체 작업 완료 |
|
||||
|
|
@ -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: 컬럼 순서
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { FormatSegment } from "./types";
|
||||
import { SEGMENT_TYPE_LABELS, buildFormattedString, SAMPLE_VALUES } from "./config";
|
||||
|
||||
// 개별 세그먼트 행
|
||||
interface SortableSegmentRowProps {
|
||||
segment: FormatSegment;
|
||||
index: number;
|
||||
onChange: (index: number, updates: Partial<FormatSegment>) => void;
|
||||
}
|
||||
|
||||
function SortableSegmentRow({ segment, index, onChange }: SortableSegmentRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: `${segment.type}-${index}` });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"grid grid-cols-[16px_56px_18px_1fr_1fr_1fr] items-center gap-1 rounded border bg-white px-2 py-1.5",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
|
||||
<span className="truncate text-xs font-medium">
|
||||
{SEGMENT_TYPE_LABELS[segment.type]}
|
||||
</span>
|
||||
|
||||
<Checkbox
|
||||
checked={segment.showLabel}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange(index, { showLabel: checked === true })
|
||||
}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={segment.label}
|
||||
onChange={(e) => onChange(index, { label: e.target.value })}
|
||||
placeholder=""
|
||||
className={cn(
|
||||
"h-6 px-1 text-xs",
|
||||
!segment.showLabel && "text-gray-400 line-through",
|
||||
)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={segment.separatorAfter}
|
||||
onChange={(e) => onChange(index, { separatorAfter: e.target.value })}
|
||||
placeholder=""
|
||||
className="h-6 px-1 text-center text-xs"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={5}
|
||||
value={segment.pad}
|
||||
onChange={(e) =>
|
||||
onChange(index, { pad: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
disabled={segment.type !== "row" && segment.type !== "level"}
|
||||
className={cn(
|
||||
"h-6 px-1 text-center text-xs",
|
||||
segment.type !== "row" && segment.type !== "level" && "bg-gray-100 opacity-50",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// FormatSegmentEditor 메인 컴포넌트
|
||||
interface FormatSegmentEditorProps {
|
||||
label: string;
|
||||
segments: FormatSegment[];
|
||||
onChange: (segments: FormatSegment[]) => void;
|
||||
sampleValues?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function FormatSegmentEditor({
|
||||
label,
|
||||
segments,
|
||||
onChange,
|
||||
sampleValues = SAMPLE_VALUES,
|
||||
}: FormatSegmentEditorProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const preview = useMemo(
|
||||
() => buildFormattedString(segments, sampleValues),
|
||||
[segments, sampleValues],
|
||||
);
|
||||
|
||||
const sortableIds = useMemo(
|
||||
() => segments.map((seg, i) => `${seg.type}-${i}`),
|
||||
[segments],
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = sortableIds.indexOf(active.id as string);
|
||||
const newIndex = sortableIds.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
onChange(arrayMove([...segments], oldIndex, newIndex));
|
||||
};
|
||||
|
||||
const handleSegmentChange = (index: number, updates: Partial<FormatSegment>) => {
|
||||
const updated = segments.map((seg, i) =>
|
||||
i === index ? { ...seg, ...updates } : seg,
|
||||
);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600">{label}</div>
|
||||
|
||||
<div className="grid grid-cols-[16px_56px_18px_1fr_1fr_1fr] items-center gap-1 px-2 text-[10px] text-gray-500">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span>라벨</span>
|
||||
<span className="text-center">구분</span>
|
||||
<span className="text-center">자릿수</span>
|
||||
</div>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1">
|
||||
{segments.map((segment, index) => (
|
||||
<SortableSegmentRow
|
||||
key={sortableIds[index]}
|
||||
segment={segment}
|
||||
index={index}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<div className="rounded bg-gray-50 px-2 py-1.5">
|
||||
<span className="text-[10px] text-gray-500">미리보기: </span>
|
||||
<span className="text-xs font-medium text-gray-800">
|
||||
{preview || "(빈 값)"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
GeneratedLocation,
|
||||
RackStructureContext,
|
||||
} from "./types";
|
||||
import { defaultFormatConfig, buildFormattedString } from "./config";
|
||||
|
||||
// 기존 위치 데이터 타입
|
||||
interface ExistingLocation {
|
||||
|
|
@ -288,11 +289,10 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
return ctx;
|
||||
}, [propContext, formData, fieldMapping, getCategoryLabel]);
|
||||
|
||||
// 필수 필드 검증
|
||||
// 필수 필드 검증 (층은 선택 입력)
|
||||
const missingFields = useMemo(() => {
|
||||
const missing: string[] = [];
|
||||
if (!context.warehouseCode) missing.push("창고 코드");
|
||||
if (!context.floor) missing.push("층");
|
||||
if (!context.zone) missing.push("구역");
|
||||
return missing;
|
||||
}, [context]);
|
||||
|
|
@ -377,9 +377,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
// 기존 데이터 조회 (창고/층/구역이 변경될 때마다)
|
||||
useEffect(() => {
|
||||
const loadExistingLocations = async () => {
|
||||
// 필수 조건이 충족되지 않으면 기존 데이터 초기화
|
||||
// DB에는 라벨 값(예: "1층", "A구역")으로 저장되어 있으므로 라벨 값 사용
|
||||
if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) {
|
||||
// 창고 코드와 구역은 필수, 층은 선택
|
||||
if (!warehouseCodeForQuery || !zoneForQuery) {
|
||||
setExistingLocations([]);
|
||||
setDuplicateErrors([]);
|
||||
return;
|
||||
|
|
@ -387,14 +386,13 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
|
||||
setIsCheckingDuplicates(true);
|
||||
try {
|
||||
// warehouse_location 테이블에서 해당 창고/층/구역의 기존 데이터 조회
|
||||
// DB에는 라벨 값으로 저장되어 있으므로 라벨 값으로 필터링
|
||||
// equals 연산자를 사용하여 정확한 일치 검색 (ILIKE가 아닌 = 연산자 사용)
|
||||
const searchParams = {
|
||||
const searchParams: Record<string, any> = {
|
||||
warehouse_code: { value: warehouseCodeForQuery, operator: "equals" },
|
||||
floor: { value: floorForQuery, operator: "equals" },
|
||||
zone: { value: zoneForQuery, operator: "equals" },
|
||||
};
|
||||
if (floorForQuery) {
|
||||
searchParams.floor = { value: floorForQuery, operator: "equals" };
|
||||
}
|
||||
|
||||
// 직접 apiClient 사용하여 정확한 형식으로 요청
|
||||
// 백엔드는 search를 객체로 받아서 각 필드를 WHERE 조건으로 처리
|
||||
|
|
@ -493,23 +491,26 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
return { totalLocations, totalRows, maxLevel };
|
||||
}, [conditions]);
|
||||
|
||||
// 위치 코드 생성
|
||||
// 포맷 설정 (ConfigPanel에서 관리자가 설정한 값, 미설정 시 기본값)
|
||||
const formatConfig = config.formatConfig || defaultFormatConfig;
|
||||
|
||||
// 위치 코드 생성 (세그먼트 기반 - 순서/구분자/라벨/자릿수 모두 formatConfig에 따름)
|
||||
const generateLocationCode = useCallback(
|
||||
(row: number, level: number): { code: string; name: string } => {
|
||||
const warehouseCode = context?.warehouseCode || "WH001";
|
||||
const floor = context?.floor || "1";
|
||||
const zone = context?.zone || "A";
|
||||
const values: Record<string, string> = {
|
||||
warehouseCode: context?.warehouseCode || "WH001",
|
||||
floor: context?.floor || "",
|
||||
zone: context?.zone || "A",
|
||||
row: row.toString(),
|
||||
level: level.toString(),
|
||||
};
|
||||
|
||||
// 코드 생성 (예: WH001-1층D구역-01-1)
|
||||
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
||||
|
||||
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
|
||||
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
|
||||
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`;
|
||||
const code = buildFormattedString(formatConfig.codeSegments, values);
|
||||
const name = buildFormattedString(formatConfig.nameSegments, values);
|
||||
|
||||
return { code, name };
|
||||
},
|
||||
[context],
|
||||
[context, formatConfig],
|
||||
);
|
||||
|
||||
// 미리보기 생성
|
||||
|
|
@ -870,7 +871,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="font-mono">{loc.location_code}</TableCell>
|
||||
<TableCell>{loc.location_name}</TableCell>
|
||||
<TableCell className="text-center">{loc.floor || context?.floor || "1"}</TableCell>
|
||||
<TableCell className="text-center">{loc.floor || context?.floor || "-"}</TableCell>
|
||||
<TableCell className="text-center">{loc.zone || context?.zone || "A"}</TableCell>
|
||||
<TableCell className="text-center">{loc.row_num.padStart(2, "0")}</TableCell>
|
||||
<TableCell className="text-center">{loc.level_num}</TableCell>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RackStructureComponentConfig, FieldMapping } from "./types";
|
||||
import { RackStructureComponentConfig, FieldMapping, FormatSegment } from "./types";
|
||||
import { defaultFormatConfig, SAMPLE_VALUES } from "./config";
|
||||
import { FormatSegmentEditor } from "./FormatSegmentEditor";
|
||||
|
||||
interface RackStructureConfigPanelProps {
|
||||
config: RackStructureComponentConfig;
|
||||
|
|
@ -69,6 +71,21 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
|||
|
||||
const fieldMapping = config.fieldMapping || {};
|
||||
|
||||
const formatConfig = config.formatConfig || defaultFormatConfig;
|
||||
|
||||
const handleFormatChange = (
|
||||
key: "codeSegments" | "nameSegments",
|
||||
segments: FormatSegment[],
|
||||
) => {
|
||||
onChange({
|
||||
...config,
|
||||
formatConfig: {
|
||||
...formatConfig,
|
||||
[key]: segments,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 필드 매핑 섹션 */}
|
||||
|
|
@ -282,6 +299,29 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 포맷 설정 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-gray-700">포맷 설정</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고,
|
||||
구분자/라벨을 편집할 수 있습니다
|
||||
</p>
|
||||
|
||||
<FormatSegmentEditor
|
||||
label="위치코드 포맷"
|
||||
segments={formatConfig.codeSegments}
|
||||
onChange={(segs) => handleFormatChange("codeSegments", segs)}
|
||||
sampleValues={SAMPLE_VALUES}
|
||||
/>
|
||||
|
||||
<FormatSegmentEditor
|
||||
label="위치명 포맷"
|
||||
segments={formatConfig.nameSegments}
|
||||
onChange={(segs) => handleFormatChange("nameSegments", segs)}
|
||||
sampleValues={SAMPLE_VALUES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,26 +2,107 @@
|
|||
* 렉 구조 컴포넌트 기본 설정
|
||||
*/
|
||||
|
||||
import { RackStructureComponentConfig } from "./types";
|
||||
import {
|
||||
RackStructureComponentConfig,
|
||||
FormatSegment,
|
||||
FormatSegmentType,
|
||||
LocationFormatConfig,
|
||||
} from "./types";
|
||||
|
||||
// 세그먼트 타입별 한글 표시명
|
||||
export const SEGMENT_TYPE_LABELS: Record<FormatSegmentType, string> = {
|
||||
warehouseCode: "창고코드",
|
||||
floor: "층",
|
||||
zone: "구역",
|
||||
row: "열",
|
||||
level: "단",
|
||||
};
|
||||
|
||||
// 위치코드 기본 세그먼트 (현재 하드코딩과 동일한 결과)
|
||||
export const defaultCodeSegments: FormatSegment[] = [
|
||||
{ type: "warehouseCode", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 0 },
|
||||
{ type: "floor", enabled: true, showLabel: true, label: "층", separatorAfter: "", pad: 0 },
|
||||
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
|
||||
{ type: "row", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 2 },
|
||||
{ type: "level", enabled: true, showLabel: false, label: "", separatorAfter: "", pad: 0 },
|
||||
];
|
||||
|
||||
// 위치명 기본 세그먼트 (현재 하드코딩과 동일한 결과)
|
||||
export const defaultNameSegments: FormatSegment[] = [
|
||||
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
|
||||
{ type: "row", enabled: true, showLabel: true, label: "열", separatorAfter: "-", pad: 2 },
|
||||
{ type: "level", enabled: true, showLabel: true, label: "단", separatorAfter: "", pad: 0 },
|
||||
];
|
||||
|
||||
export const defaultFormatConfig: LocationFormatConfig = {
|
||||
codeSegments: defaultCodeSegments,
|
||||
nameSegments: defaultNameSegments,
|
||||
};
|
||||
|
||||
// 세그먼트 타입별 기본 한글 접미사 (context 값에 포함되어 있는 한글)
|
||||
const KNOWN_SUFFIXES: Partial<Record<FormatSegmentType, string>> = {
|
||||
floor: "층",
|
||||
zone: "구역",
|
||||
};
|
||||
|
||||
// 값에서 알려진 한글 접미사를 제거하여 순수 값만 추출
|
||||
function stripKnownSuffix(type: FormatSegmentType, val: string): string {
|
||||
const suffix = KNOWN_SUFFIXES[type];
|
||||
if (suffix && val.endsWith(suffix)) {
|
||||
return val.slice(0, -suffix.length);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
// 세그먼트 배열로 포맷된 문자열 생성
|
||||
export function buildFormattedString(
|
||||
segments: FormatSegment[],
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
const activeSegments = segments.filter(
|
||||
(seg) => seg.enabled && values[seg.type],
|
||||
);
|
||||
|
||||
return activeSegments
|
||||
.map((seg, idx) => {
|
||||
// 1) 원본 값에서 한글 접미사를 먼저 벗겨냄 ("A구역" → "A", "1층" → "1")
|
||||
let val = stripKnownSuffix(seg.type, values[seg.type]);
|
||||
|
||||
// 2) showLabel이 켜져 있고 label이 있으면 붙임
|
||||
if (seg.showLabel && seg.label) {
|
||||
val += seg.label;
|
||||
}
|
||||
|
||||
if (seg.pad > 0 && !isNaN(Number(val))) {
|
||||
val = val.padStart(seg.pad, "0");
|
||||
}
|
||||
|
||||
if (idx < activeSegments.length - 1) {
|
||||
val += seg.separatorAfter;
|
||||
}
|
||||
return val;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// 미리보기용 샘플 값
|
||||
export const SAMPLE_VALUES: Record<string, string> = {
|
||||
warehouseCode: "WH001",
|
||||
floor: "1층",
|
||||
zone: "A구역",
|
||||
row: "1",
|
||||
level: "1",
|
||||
};
|
||||
|
||||
export const defaultConfig: RackStructureComponentConfig = {
|
||||
// 기본 제한
|
||||
maxConditions: 10,
|
||||
maxRows: 99,
|
||||
maxLevels: 20,
|
||||
|
||||
// 기본 코드 패턴
|
||||
codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}",
|
||||
namePattern: "{zone}구역-{row:02d}열-{level}단",
|
||||
|
||||
// UI 설정
|
||||
showTemplates: true,
|
||||
showPreview: true,
|
||||
showStatistics: true,
|
||||
readonly: false,
|
||||
|
||||
// 초기 조건 없음
|
||||
initialConditions: [],
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,24 @@ export interface FieldMapping {
|
|||
statusField?: string; // 사용 여부로 사용할 폼 필드명
|
||||
}
|
||||
|
||||
// 포맷 세그먼트 (위치코드/위치명의 각 구성요소)
|
||||
export type FormatSegmentType = 'warehouseCode' | 'floor' | 'zone' | 'row' | 'level';
|
||||
|
||||
export interface FormatSegment {
|
||||
type: FormatSegmentType;
|
||||
enabled: boolean; // 이 세그먼트를 포함할지 여부
|
||||
showLabel: boolean; // 한글 라벨 표시 여부 (false면 값에서 라벨 제거)
|
||||
label: string; // 한글 라벨 (예: "층", "구역", "열", "단")
|
||||
separatorAfter: string; // 이 세그먼트 뒤의 구분자 (예: "-", "/", "")
|
||||
pad: number; // 최소 자릿수 (0 = 그대로, 2 = "01"처럼 2자리 맞춤)
|
||||
}
|
||||
|
||||
// 위치코드 + 위치명 포맷 설정
|
||||
export interface LocationFormatConfig {
|
||||
codeSegments: FormatSegment[];
|
||||
nameSegments: FormatSegment[];
|
||||
}
|
||||
|
||||
// 컴포넌트 설정
|
||||
export interface RackStructureComponentConfig {
|
||||
// 기본 설정
|
||||
|
|
@ -54,8 +72,9 @@ export interface RackStructureComponentConfig {
|
|||
fieldMapping?: FieldMapping;
|
||||
|
||||
// 위치 코드 생성 규칙
|
||||
codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}")
|
||||
namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단")
|
||||
codePattern?: string; // 코드 패턴 (하위 호환용 유지)
|
||||
namePattern?: string; // 이름 패턴 (하위 호환용 유지)
|
||||
formatConfig?: LocationFormatConfig; // 구조화된 포맷 설정
|
||||
|
||||
// UI 설정
|
||||
showTemplates?: boolean; // 템플릿 기능 표시
|
||||
|
|
@ -93,5 +112,3 @@ export interface RackStructureComponentProps {
|
|||
isPreview?: boolean;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@
|
|||
|
||||
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 { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
|
||||
// 🖼️ 테이블 셀 이미지 썸네일 컴포넌트
|
||||
// objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용
|
||||
|
|
@ -156,13 +156,8 @@ declare global {
|
|||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
RefreshCw,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
TableIcon,
|
||||
Settings,
|
||||
X,
|
||||
Layers,
|
||||
ChevronDown,
|
||||
|
|
@ -175,14 +170,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,
|
||||
|
|
@ -194,7 +189,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";
|
||||
|
|
@ -202,7 +196,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";
|
||||
|
||||
// ========================================
|
||||
|
|
@ -405,6 +399,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 디버그 로그 제거 (성능 최적화)
|
||||
|
||||
const currentTabId = useTabId();
|
||||
|
||||
const buttonColor = getAdaptiveLabelColor(component.style?.labelColor);
|
||||
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
|
||||
|
||||
|
|
@ -701,7 +697,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 }>
|
||||
|
|
@ -811,11 +838,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);
|
||||
|
|
@ -1619,7 +1647,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;
|
||||
|
|
@ -1917,12 +1945,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) => {
|
||||
|
|
@ -2959,12 +2981,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);
|
||||
}
|
||||
|
|
@ -2979,7 +3000,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
frozenColumnCount,
|
||||
showGridLines,
|
||||
headerFilters,
|
||||
localPageSize,
|
||||
]);
|
||||
|
||||
// 🆕 State Persistence: 통합 상태 복원
|
||||
|
|
@ -2987,7 +3007,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);
|
||||
|
|
@ -2998,7 +3018,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)
|
||||
|
|
@ -3006,7 +3025,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>> = {};
|
||||
|
|
@ -3025,7 +3044,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (!tableStateKey) return;
|
||||
|
||||
try {
|
||||
localStorage.removeItem(tableStateKey);
|
||||
sessionStorage.removeItem(tableStateKey);
|
||||
setColumnWidths({});
|
||||
setColumnOrder([]);
|
||||
setSortColumn(null);
|
||||
|
|
@ -3034,6 +3053,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setFrozenColumns([]);
|
||||
setShowGridLines(true);
|
||||
setHeaderFilters({});
|
||||
setLocalPageSize(20);
|
||||
setPageSizeInputValue("20");
|
||||
toast.success("테이블 설정이 초기화되었습니다.");
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 상태 초기화 실패:", error);
|
||||
|
|
@ -4449,33 +4470,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) {
|
||||
|
|
@ -4489,7 +4513,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("검색 필터 설정이 저장되었습니다");
|
||||
|
||||
|
|
@ -4544,7 +4568,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);
|
||||
}
|
||||
|
|
@ -4624,7 +4648,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setGroupByColumns([]);
|
||||
setCollapsedGroups(new Set());
|
||||
if (groupSettingKey) {
|
||||
localStorage.removeItem(groupSettingKey);
|
||||
sessionStorage.removeItem(groupSettingKey);
|
||||
}
|
||||
toast.success("그룹이 해제되었습니다");
|
||||
}, [groupSettingKey]);
|
||||
|
|
@ -4808,7 +4832,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);
|
||||
|
|
@ -5107,13 +5131,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));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -5128,65 +5150,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">
|
||||
|
|
@ -5261,6 +5251,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
exportToExcel,
|
||||
exportToPdf,
|
||||
localPageSize,
|
||||
pageSizeInputValue,
|
||||
onConfigChange,
|
||||
tableConfig,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -689,11 +689,10 @@ export class ButtonActionExecutor {
|
|||
return false;
|
||||
}
|
||||
|
||||
// 🆕 렉 구조 등록 화면 감지 (warehouse_location 테이블 + floor/zone 필드 있음 + 렉 구조 데이터 없음)
|
||||
// 이 경우 일반 저장을 차단하고 미리보기 생성을 요구
|
||||
// 렉 구조 등록 화면 감지 (warehouse_location 테이블 + zone 필드 있음 + 렉 구조 데이터 없음)
|
||||
// floor는 선택 입력이므로 감지 조건에서 제외
|
||||
const isRackStructureScreen =
|
||||
context.tableName === "warehouse_location" &&
|
||||
context.formData?.floor &&
|
||||
context.formData?.zone &&
|
||||
!rackStructureLocations;
|
||||
|
||||
|
|
@ -2085,15 +2084,18 @@ export class ButtonActionExecutor {
|
|||
const floor = firstLocation.floor;
|
||||
const zone = firstLocation.zone;
|
||||
|
||||
if (warehouseCode && floor && zone) {
|
||||
if (warehouseCode && zone) {
|
||||
try {
|
||||
// search 파라미터를 사용하여 백엔드에서 필터링 (filters는 백엔드에서 처리 안됨)
|
||||
const searchParams: Record<string, any> = {
|
||||
warehouse_code: { value: warehouseCode, operator: "equals" },
|
||||
zone: { value: zone, operator: "equals" },
|
||||
};
|
||||
if (floor) {
|
||||
searchParams.floor = { value: floor, operator: "equals" };
|
||||
}
|
||||
|
||||
const existingResponse = await DynamicFormApi.getTableData(tableName, {
|
||||
search: {
|
||||
warehouse_code: { value: warehouseCode, operator: "equals" },
|
||||
floor: { value: floor, operator: "equals" },
|
||||
zone: { value: zone, operator: "equals" },
|
||||
},
|
||||
search: searchParams,
|
||||
page: 1,
|
||||
pageSize: 1000,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ export const useTabStore = create<TabState>()(
|
|||
},
|
||||
|
||||
refreshTab: (tabId) => {
|
||||
clearTabCache(tabId);
|
||||
set((state) => ({
|
||||
refreshKeys: { ...state.refreshKeys, [tabId]: (state.refreshKeys[tabId] || 0) + 1 },
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in New Issue