Merge branch 'jskim-node' into mhkim-node

Made-with: Cursor
This commit is contained in:
kmh 2026-03-11 12:48:22 +09:00
commit 4d313008c1
22 changed files with 3078 additions and 152 deletions

View File

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

View File

@ -0,0 +1,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 스키마 변경 없음

View File

@ -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, ... (단 번호 - 숫자)
```

View File

@ -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단계 검증 완료, 전체 작업 완료 |

View File

@ -0,0 +1,389 @@
# [계획서] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트
> 관련 문서: [맥락노트](./PGN[맥락]-페이징-단락이동.md) | [체크리스트](./PGN[체크]-페이징-단락이동.md)
## 개요
페이지네이션의 핵심 컨트롤(`<< < [번호들] > >>`)을 **재사용 가능한 공통 컴포넌트 `PageGroupNav`**로 분리합니다.
현재의 단순 `1 / n` 텍스트 표시를 **10개 단위 페이지 번호 버튼 그룹**으로 교체하고, `< >` 버튼을 **단락(그룹) 이동**으로, `<< >>` 버튼을 **첫/끝 단락 이동**으로 변경합니다.
### 접근 전략: C안 (핵심 컨트롤 분리 + 단계적 적용)
- **1단계 (이번 작업)**: `PageGroupNav.tsx` 생성 + v2-table-list에 적용
- **2단계 (별도 작업)**: 나머지 페이징 사용처에 점진적 적용
이 전략을 선택한 이유:
- 레이아웃을 강제하지 않는 순수 컨트롤 컴포넌트 → 어디든 조합 가능
- v2-table-list에서 먼저 검증 후 확산 → 리스크 최소화
- 2단계는 `import` 한 줄로 적용 가능 → 미래 작업 비용 최소
---
## 현재 동작
### 페이지네이션 UI
```
[<<] [<] 1 / 38 [>] [>>]
```
| 버튼 | 현재 동작 |
|------|----------|
| `<<` | 첫 페이지(1)로 이동 |
| `<` | 이전 페이지(`currentPage - 1`)로 이동 |
| 중앙 | `currentPage / totalPages` 텍스트 표시 (클릭 불가) |
| `>` | 다음 페이지(`currentPage + 1`)로 이동 |
| `>>` | 마지막 페이지(`totalPages`)로 이동 |
### 비활성화 조건
- `<<` `<` : `currentPage === 1`
- `>` `>>` : `currentPage >= totalPages`
### 현재 코드 (TableListComponent.tsx, 5139~5182행)
```tsx
{/* 중앙 페이지네이션 컨트롤 */}
<div className="flex items-center gap-2 sm:gap-4">
<Button onClick={() => handlePageChange(1)}
disabled={currentPage === 1 || loading}> {/* << */}
<Button onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}> {/* < */}
<span>{currentPage} / {totalPages || 1}</span>
<Button onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || loading}> {/* > */}
<Button onClick={() => handlePageChange(totalPages)}
disabled={currentPage >= totalPages || loading}> {/* >> */}
</div>
```
---
## 변경 후 동작
### 페이지네이션 UI
```
[<<] [<] [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [>] [>>]
```
| 버튼 | 변경 후 동작 |
|------|-------------|
| `<<` | **첫 번째 단락**으로 이동 (1페이지 선택) |
| `<` | **이전 단락**의 첫 페이지로 이동 |
| 중앙 | 현재 단락의 페이지 번호 버튼 나열 (클릭으로 해당 페이지 이동) |
| `>` | **다음 단락**의 첫 페이지로 이동 |
| `>>` | **마지막 단락**의 첫 페이지로 이동 (마지막 페이지가 아님) |
### 비활성화 조건
- `<<` `<` : **첫 번째 단락**(1~10)을 보고 있을 때
- `>` `>>` : **마지막 단락**을 보고 있을 때
### 단락(그룹) 개념
- 10개 단위로 페이지를 묶어 하나의 "단락"으로 취급
- 단락 1: 1~10, 단락 2: 11~20, 단락 3: 21~30, ...
- 마지막 단락은 10개 미만일 수 있음 (예: 31~38)
### 고정 슬롯 레이아웃 (핵심 제약)
**페이지 번호 영역은 항상 10개 슬롯을 고정 렌더링한다.**
- 각 슬롯은 동일한 고정 너비(`w-9` 등)를 가짐
- 1자리(`1`)든 2자리(`11`)든 3자리(`100`)든 버튼 너비가 동일
- 마지막 단락이 10개 미만이면 남은 슬롯은 빈 공간(투명 placeholder)으로 채움
- 이로써 `< >` 버튼을 연속 클릭해도 **번호 버튼과 화살표 버튼의 위치가 절대 변하지 않음**
```
단락 1~10: [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] ← 10개 모두 채움
단락 11~20: [11][12][13][14][15][16][17][18][19][20] ← 너비 동일
단락 31~38: [31][32][33][34][35][36][37][38][ ][ ] ← 빈 슬롯 2개로 위치 고정
```
---
## 시각적 동작 예시
총 38페이지 기준:
### 단락별 페이지 번호 표시
| 현재 페이지 | 표시 번호 | `<<` `<` | `>` `>>` |
|-------------|-----------|----------|----------|
| 1 | **[1]** [2] [3] ... [10] | 비활성 | 활성 |
| 5 | [1] [2] [3] [4] **[5]** [6] [7] [8] [9] [10] | 비활성 | 활성 |
| 10 | [1] [2] [3] [4] [5] [6] [7] [8] [9] **[10]** | 비활성 | 활성 |
| 11 | **[11]** [12] [13] ... [20] | 활성 | 활성 |
| 25 | [21] [22] [23] [24] **[25]** [26] [27] [28] [29] [30] | 활성 | 활성 |
| 31 | **[31]** [32] [33] ... [38] [ ] [ ] | 활성 | 비활성 |
| 38 | [31] [32] [33] [34] [35] [36] [37] **[38]** [ ] [ ] | 활성 | 비활성 |
### 버튼 클릭 시나리오
| 현재 상태 | 클릭 | 결과 |
|----------|------|------|
| 5페이지 (단락 1~10) | `>` | 11페이지 선택, 단락 11~20 표시 |
| 15페이지 (단락 11~20) | `<` | 1페이지 선택, 단락 1~10 표시 |
| 15페이지 (단락 11~20) | `>>` | 31페이지 선택, 단락 31~38 표시 |
| 35페이지 (단락 31~38) | `<<` | 1페이지 선택, 단락 1~10 표시 |
| 5페이지 (단락 1~10) | `[7]` | 7페이지 선택, 단락 1~10 유지 |
---
## 아키텍처
### 컴포넌트 구조 (C안)
```mermaid
flowchart TD
subgraph PageGroupNav ["PageGroupNav.tsx (새 공통 컴포넌트)"]
Props["props: currentPage, totalPages, onPageChange, disabled, groupSize"]
Logic["단락 계산 + 고정 슬롯 + 비활성화"]
UI["<< < [번호들] > >>"]
Props --> Logic --> UI
end
subgraph Phase1 ["1단계: 이번 작업"]
V2Table["v2-table-list paginationJSX"]
end
subgraph Phase2 ["2단계: 별도 작업 (미래)"]
TableList["table-list (구형)"]
PaginationTsx["Pagination.tsx (관리자)"]
DrillDown["DrillDown 모달"]
Mail["메일 수신/발송"]
Others["감사로그, 배치, DataTable 등"]
end
PageGroupNav --> V2Table
PageGroupNav -.-> TableList
PageGroupNav -.-> PaginationTsx
PageGroupNav -.-> DrillDown
PageGroupNav -.-> Mail
PageGroupNav -.-> Others
```
### v2-table-list 내부 데이터 흐름
```mermaid
flowchart TD
A["currentPage, totalPages (state)"] --> B[PageGroupNav]
B -->|onPageChange| C[handlePageChange]
C --> D[setCurrentPage + onConfigChange]
D --> E[백엔드 API 호출]
E --> F[데이터 갱신]
F --> A
```
### v2-table-list 페이징 바 레이아웃 (변경 없음)
```
┌─────────────────────────────────────────────────────────────────┐
│ [페이지크기 입력] │ << < [PageGroupNav] > >> │ [내보내기][새로고침] │
│ 좌측(유지) │ 중앙(교체) │ 우측(유지) │
└─────────────────────────────────────────────────────────────────┘
```
---
## 변경 대상 파일
### 1단계 (이번 작업)
| 구분 | 파일 | 변경 내용 | 변경 규모 |
|------|------|----------|----------|
| 생성 | `frontend/components/common/PageGroupNav.tsx` | 페이지 그룹 네비게이션 공통 컴포넌트 | 약 80줄 신규 |
| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | `paginationJSX` 중앙 영역을 `PageGroupNav`로 교체 (5139~5182행) | 약 40줄 → 5줄 |
- `handlePageChange` 함수는 기존 것을 그대로 사용 (동작 변경 없음)
- 좌측(페이지크기 입력), 우측(내보내기/새로고침) 영역은 변경하지 않음
- 백엔드 변경 없음, DB 변경 없음
### 1단계 적용 범위
v2-table-list를 사용하는 **모든 동적 화면**에 자동 적용:
- 품목정보, 거래처관리, 판매품목정보, 설비정보 등
### 2단계 적용 대상 (별도 작업, 미래)
| 사용처 | 파일 | 현재 페이징 형태 |
|--------|------|----------------|
| table-list (구형) | `lib/registry/components/table-list/TableListComponent.tsx` | `<< < 현재/총 > >>` |
| 공통 Pagination | `components/common/Pagination.tsx` | 번호 ±2 + `...` |
| 피벗 드릴다운 | `lib/registry/components/pivot-grid/components/DrillDownModal.tsx` | `<< < 현재/총 > >>` |
| v2 피벗 드릴다운 | `lib/registry/components/v2-pivot-grid/components/DrillDownModal.tsx` | 동일 |
| 메일 수신함 | `app/(main)/admin/automaticMng/mail/receive/page.tsx` | 번호 5개 클릭 |
| 메일 발송함 | `app/(main)/admin/automaticMng/mail/sent/page.tsx` | 동일 |
| 감사 로그 | `app/(main)/admin/audit-log/page.tsx` | `< 현재/총 >` |
| 배치 관리 | `app/(main)/admin/automaticMng/batchmngList/page.tsx` | 번호 5개 클릭 |
| DataTable | `components/common/DataTable.tsx` | `<< < > >>` + 텍스트 |
| FlowWidget | `components/screen/widgets/FlowWidget.tsx` | shadcn Pagination |
---
## 코드 설계
### PageGroupNav.tsx 공통 컴포넌트
```tsx
// frontend/components/common/PageGroupNav.tsx
"use client";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { Button } from "@/components/ui/button";
const DEFAULT_GROUP_SIZE = 10;
interface PageGroupNavProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
disabled?: boolean;
groupSize?: number;
}
export function PageGroupNav({
currentPage,
totalPages,
onPageChange,
disabled = false,
groupSize = DEFAULT_GROUP_SIZE,
}: PageGroupNavProps) {
const safeTotal = Math.max(1, totalPages);
const currentGroupIndex = Math.floor((currentPage - 1) / groupSize);
const groupStartPage = currentGroupIndex * groupSize + 1;
const lastGroupIndex = Math.floor((safeTotal - 1) / groupSize);
const lastGroupStartPage = lastGroupIndex * groupSize + 1;
const isFirstGroup = currentGroupIndex === 0;
const isLastGroup = currentGroupIndex === lastGroupIndex;
// 10개 고정 슬롯 배열
const slots: (number | null)[] = [];
for (let i = 0; i < groupSize; i++) {
const page = groupStartPage + i;
slots.push(page <= safeTotal ? page : null);
}
return (
<div className="flex items-center gap-1">
{/* << 단락 */}
<Button variant="outline" size="sm"
onClick={() => onPageChange(1)}
disabled={isFirstGroup || disabled}
className="h-8 w-8 p-0 sm:h-9 sm:w-9">
<ChevronsLeft className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
{/* < 이전 단락 */}
<Button variant="outline" size="sm"
onClick={() => onPageChange((currentGroupIndex - 1) * groupSize + 1)}
disabled={isFirstGroup || disabled}
className="h-8 w-8 p-0 sm:h-9 sm:w-9">
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
{/* 페이지 번호 (고정 슬롯) */}
{slots.map((page, idx) =>
page !== null ? (
<Button key={idx} size="sm"
variant={page === currentPage ? "default" : "outline"}
onClick={() => onPageChange(page)}
disabled={disabled}
className="h-8 w-8 p-0 text-xs sm:h-9 sm:w-9 sm:text-sm">
{page}
</Button>
) : (
<div key={idx} className="h-8 w-8 sm:h-9 sm:w-9" />
)
)}
{/* > 다음 단락 */}
<Button variant="outline" size="sm"
onClick={() => onPageChange((currentGroupIndex + 1) * groupSize + 1)}
disabled={isLastGroup || disabled}
className="h-8 w-8 p-0 sm:h-9 sm:w-9">
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
{/* >> 마지막 단락 */}
<Button variant="outline" size="sm"
onClick={() => onPageChange(lastGroupStartPage)}
disabled={isLastGroup || disabled}
className="h-8 w-8 p-0 sm:h-9 sm:w-9">
<ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
);
}
```
### v2-table-list 통합 (paginationJSX 중앙 영역 교체)
기존 5139~5182행의 `<div className="flex items-center gap-2 sm:gap-4">` 블록을 다음으로 교체:
```tsx
import { PageGroupNav } from "@/components/common/PageGroupNav";
// paginationJSX 내부 중앙 영역
<PageGroupNav
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
disabled={loading}
/>
```
좌측(페이지크기 입력), 우측(내보내기/새로고침)은 기존 코드 그대로 유지.
---
## 설계 원칙
- **레이아웃 무관 컴포넌트**: PageGroupNav는 순수 컨트롤만 담당. 외부 레이아웃(좌측/우측 부가 기능)을 강제하지 않음
- **기존 동작 무변경**: `handlePageChange` 함수는 수정하지 않음. 좌측/우측 영역도 변경하지 않음
- **고정 슬롯 레이아웃**: 페이지 번호 영역은 항상 `groupSize`개(기본 10) 슬롯 고정. 마지막 단락에서 부족하면 빈 div로 채움
- **고정 너비 버튼**: 모든 번호 버튼은 `w-8 sm:w-9` 고정. 1자리/2자리/3자리에 관계없이 동일
- **위치 불변**: `< >` `<< >>` 버튼을 연속 클릭해도 모든 버튼의 위치가 절대 변하지 않음
- **현재 페이지 강조**: `variant="default"`(primary) + `ring-2 ring-primary font-bold`, 나머지 `variant="outline"`
- **엣지 케이스**: totalPages가 0이거나 1일 때도 정상 동작 (`safeTotal = Math.max(1, totalPages)`)
- **빈 슬롯 접근성**: 빈 슬롯에 `cursor-default` 적용 (클릭 가능한 것처럼 보이지 않게)
- **단계적 적용**: 1단계에서 v2-table-list로 검증 후, 2단계에서 나머지 사용처에 점진 적용
---
## 추가 구현: 표시갯수(pageSize) 캐시 정책
### 문제
기존 pageSize는 `onConfigChange`로 부모에 전파되어 DB에 저장되거나, `localStorage`에 저장되어 새로고침해도 사용자가 변경한 값이 남아있었음.
### 해결
| 항목 | 정책 |
|------|------|
| 저장소 | sessionStorage (탭 닫으면 자동 소멸) |
| 키 구조 | `pageSize_{tabId}_{tableName}` (탭별 격리) |
| 기본값 | 20 |
| DB 전파 | 안 함 (onConfigChange 제거) |
| F5 새로고침 | 활성 탭 캐시 삭제 → 기본값 20 |
| 탭 바 새로고침 | 활성 탭 캐시 삭제 → 기본값 20 |
| 비활성 탭 전환 | 캐시에서 복원 |
| 입력 UX | onChange는 표시만, onBlur/Enter로 실제 적용 |
### 테이블 캐시 탭 격리
동일한 정책을 테이블 관련 캐시 전체에 적용:
| 키 | 구조 |
|----|------|
| `tableState_{tabId}_{tableName}` | 컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터 |
| `pageSize_{tabId}_{tableName}` | 표시갯수 |
| `filterSettings_{tabId}_{base}` | 검색 필터 설정 |
| `groupSettings_{tabId}_{base}` | 그룹 설정 |
사용자 설정(컬럼 가시성/순서/정렬 상태)은 localStorage에 유지 (세션 간 보존).

View File

@ -0,0 +1,128 @@
# [맥락노트] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트
> 관련 문서: [계획서](./PGN[계획]-페이징-단락이동.md) | [체크리스트](./PGN[체크]-페이징-단락이동.md)
---
## 왜 이 작업을 하는가
- 현재 페이지네이션은 `1 / 38` 텍스트만 표시하고 `< >`로 한 페이지씩 이동
- 수십 페이지가 있을 때 원하는 페이지로 빠르게 이동할 수 없음
- 페이지 번호를 직접 클릭할 수 있어야 UX가 개선됨
---
## 핵심 결정 사항과 근거
### 1. 공통 컴포넌트로 분리 (C안)
- **결정**: `PageGroupNav.tsx`라는 순수 컨트롤 컴포넌트를 별도 파일로 생성
- **근거**: 프로젝트에 페이징이 15곳 이상 존재. 인라인 수정하면 같은 로직을 복사해야 함
- **대안 검토 A**: v2-table-list 인라인만 수정 → 기각 (미래 확장 시 복사-붙여넣기 기술 부채)
- **대안 검토 B**: 기존 `Pagination.tsx` 업그레이드 → 기각 (전체 행 레이아웃이 포함되어 v2-table-list와 레이아웃 충돌)
- **대안 검토 D**: 전체 한번에 적용 → 기각 (12파일 동시 수정은 블래스트 반경이 큼)
### 2. 레이아웃 무관 설계
- **결정**: PageGroupNav는 `<< < [번호들] > >>`만 렌더링. 외부 레이아웃(페이지크기, 내보내기 등)을 포함하지 않음
- **근거**: 사용처마다 레이아웃이 다름. v2-table-list는 좌측(페이지크기)+중앙(컨트롤)+우측(내보내기), Pagination.tsx는 좌측(페이지정보)+우측(크기선택+컨트롤). 레이아웃을 강제하면 props 분기가 증가하여 복잡해짐
### 3. 10개 단위 단락(그룹)
- **결정**: 페이지를 10개씩 묶어 하나의 단락으로 취급
- **근거**: 사용자에게 익숙한 패턴 (네이버, 구글 등). 5개는 너무 적고, 20개는 너무 많음
- **확장성**: `groupSize` props로 기본값 10을 변경 가능하게 설계
### 4. `< >` = 단락 이동, `<< >>` = 첫/끝 단락
- **결정**: `<`는 이전 단락 첫 페이지, `>`는 다음 단락 첫 페이지. `<<`는 1페이지, `>>`는 마지막 단락 첫 페이지
- **근거**: 사용자 요청. 기존의 "한 페이지씩 이동"은 번호 클릭으로 대체됨
- **주의**: `>>`는 마지막 **페이지**가 아닌 마지막 **단락의 첫 페이지**로 이동. 예: 총 38페이지일 때 `>>` 클릭 → 31페이지 선택 (38이 아님)
### 5. 고정 슬롯 + 고정 너비
- **결정**: 항상 10개 슬롯을 렌더링하고, 모든 버튼은 동일한 고정 너비(`w-8 sm:w-9`)
- **근거**: `< >` 버튼을 연속 클릭할 때 번호 자릿수(1자리→2자리)나 페이지 수(10개→8개) 변화로 버튼 위치가 흔들리면 안 됨
- **구현**: 마지막 단락에서 페이지가 10개 미만이면 남은 슬롯은 동일 크기의 빈 `<div>`로 채움
### 6. 단계적 적용 (1단계: v2-table-list만)
- **결정**: 이번 작업은 v2-table-list에만 적용. 나머지는 별도 작업으로 점진 적용
- **근거**: 15곳 동시 수정은 리스크가 높음. v2-table-list가 가장 많이 사용되므로 여기서 검증 후 확산
### 7. 비활성화 기준은 단락 기준
- **결정**: `<< <`는 첫 번째 단락일 때 비활성화 (currentPage === 1이 아님). `> >>`는 마지막 단락일 때 비활성화
- **근거**: 기존은 currentPage 기준이었지만, 단락 이동이므로 단락 기준으로 변경이 자연스러움. 첫 단락 안에서 5페이지에 있어도 `<`는 비활성화
---
## 관련 파일 위치
| 구분 | 파일 경로 | 설명 |
|------|----------|------|
| 생성 | `frontend/components/common/PageGroupNav.tsx` | 페이지 그룹 네비게이션 공통 컴포넌트 |
| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | paginationJSX 중앙 영역 교체 (5139~5182행) |
| 참고 | `frontend/components/common/Pagination.tsx` | 기존 공통 페이지네이션 (이번에 수정 안 함) |
---
## 기술 참고
### 단락 계산 공식
```
groupSize = 10 (기본값)
currentGroupIndex = Math.floor((currentPage - 1) / groupSize)
groupStartPage = currentGroupIndex * groupSize + 1
groupEndPage = Math.min(groupStartPage + groupSize - 1, totalPages)
lastGroupIndex = Math.floor((totalPages - 1) / groupSize)
lastGroupStartPage = lastGroupIndex * groupSize + 1
isFirstGroup = currentGroupIndex === 0
isLastGroup = currentGroupIndex === lastGroupIndex
```
### 고정 슬롯 배열 생성
```
slots = [groupStart, groupStart+1, ..., groupEnd, null, null, ...] (총 groupSize개)
예: 단락 31~38 → [31, 32, 33, 34, 35, 36, 37, 38, null, null]
```
### handlePageChange 호출 흐름
```
PageGroupNav onPageChange(page)
→ TableListComponent handlePageChange(newPage)
→ setCurrentPage(newPage)
→ useEffect 트리거 → 백엔드 API 재호출 (page 파라미터 변경)
```
- handlePageChange는 `setCurrentPage`만 호출. `onConfigChange` 전파는 제거됨 (pageSize/currentPage는 세션 전용)
- handlePageChange는 기존 함수 그대로 사용. PageGroupNav가 올바른 page 값을 전달하기만 하면 됨
---
## 추가 결정: 표시갯수(pageSize) 캐시 정책
### 8. pageSize는 세션 전용, DB에 저장 안 함
- **결정**: pageSize를 `onConfigChange`로 부모/DB에 전파하지 않음. sessionStorage에만 탭별로 저장
- **근거**: pageSize는 일시적 탐색 설정이지 영구 화면 설정이 아님. DB에 저장하면 다른 사용자에게도 영향이 가고, 새로고침 시 의도치 않은 값이 남음
- **F5 정책**: 활성 탭은 캐시 삭제 → 기본값 20으로 fresh start. 비활성 탭은 캐시 유지
### 9. 테이블 캐시는 탭별 격리 (탭 ID 스코프)
- **결정**: `tableState_*`, `pageSize_*`, `filterSettings_*`, `groupSettings_*` 키를 `{prefix}_{tabId}_{tableName}` 구조로 변경
- **근거**: 같은 테이블이 여러 탭에서 열릴 수 있음. 탭 구분 없으면 "활성 탭 캐시만 삭제" 불가능
- **구현**: `useTabId()` 훅으로 현재 탭 ID 접근. `clearTabCache(tabId)`에서 해당 탭의 모든 관련 키 일괄 삭제
### 10. localStorage vs sessionStorage 분류
- **결정**: 탭별 캐시는 sessionStorage, 사용자 설정은 localStorage
- **근거**: 탭별 캐시(컬럼 너비 캐시, 필터, 그룹, pageSize)는 탭 닫으면 무의미. 사용자 설정(컬럼 가시성, 순서, 정렬)은 사용자가 의도적으로 변경한 환경설정이므로 세션 간 보존
- **분류**:
- sessionStorage: `tableState_*`, `pageSize_*`, `filterSettings_*`, `groupSettings_*`
- localStorage: `table_column_visibility_*`, `table_sort_state_*`, `table_column_order_*`

View File

@ -0,0 +1,90 @@
# [체크리스트] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트
> 관련 문서: [계획서](./PGN[계획]-페이징-단락이동.md) | [맥락노트](./PGN[맥락]-페이징-단락이동.md)
---
## 공정 상태
- 전체 진행률: **100%** (완료)
- 현재 단계: 4단계 완료
---
## 구현 체크리스트
### 1단계: PageGroupNav 공통 컴포넌트 생성
- [x] `frontend/components/common/PageGroupNav.tsx` 파일 생성
- [x] `PageGroupNavProps` 인터페이스 정의 (currentPage, totalPages, onPageChange, disabled, groupSize)
- [x] 단락 계산 로직 구현 (currentGroupIndex, groupStartPage, lastGroupIndex 등)
- [x] 10개 고정 슬롯 배열 생성 (빈 슬롯은 null)
- [x] `<<` 첫 단락 버튼 (isFirstGroup일 때 비활성화)
- [x] `<` 이전 단락 버튼 (isFirstGroup일 때 비활성화)
- [x] 페이지 번호 버튼 렌더링 (현재 페이지 variant="default", 나머지 variant="outline")
- [x] 빈 슬롯 렌더링 (동일 크기 빈 div)
- [x] `>` 다음 단락 버튼 (isLastGroup일 때 비활성화)
- [x] `>>` 마지막 단락 버튼 (isLastGroup일 때 비활성화, 마지막 단락 첫 페이지로 이동)
- [x] 고정 너비 스타일 적용 (h-8 w-8 sm:h-9 sm:w-9)
- [x] totalPages가 0 또는 1일 때 엣지 케이스 처리
### 2단계: v2-table-list 통합
- [x] `TableListComponent.tsx``PageGroupNav` import 추가
- [x] `paginationJSX`의 중앙 컨트롤 영역(5139~5182행)을 `<PageGroupNav>` 호출로 교체
- [x] props 연결: currentPage, totalPages, handlePageChange, loading
- [x] 좌측(페이지크기 입력) 영역 변경 없음 확인
- [x] 우측(내보내기/새로고침) 영역 변경 없음 확인
### 3단계: 검증
- [x] 품목정보 화면에서 페이지 번호 클릭 동작 확인
- [x] `< >` 단락 이동 동작 확인 (1~10 → 11~20 → ...)
- [x] `<< >>` 첫/끝 단락 이동 동작 확인
- [x] `>>` 클릭 시 마지막 단락의 첫 페이지 선택 확인 (마지막 페이지가 아님)
- [x] 첫 단락에서 `<< <` 비활성화 확인
- [x] 마지막 단락에서 `> >>` 비활성화 확인
- [x] 고정 슬롯: 단락 이동 시 버튼 위치 변동 없음 확인
- [x] 고정 너비: 1자리/2자리 숫자에서 버튼 크기 동일 확인
- [x] 마지막 단락이 10개 미만일 때 빈 슬롯으로 위치 고정 확인
- [x] totalPages가 1일 때 정상 동작 확인 (단일 페이지)
- [x] 로딩 중 모든 버튼 비활성화 확인
- [x] 페이지 크기 변경 시 첫 페이지로 리셋 확인
### 4단계: 정리
- [x] 린트 에러 없음 확인
- [x] 이 체크리스트 완료 표시 업데이트
### 5단계: 표시갯수(pageSize) 캐시 정책
- [x] 표시갯수 입력 시 onChange → 표시만 변경, 실제 적용은 onBlur/Enter
- [x] 입력 필드 값 string 타입으로 변경 (백스페이스로 비우기 가능)
- [x] 표시갯수 변경 시 1페이지로 리셋 + 데이터 정상 로드
- [x] onConfigChange로 DB/부모 전파 제거 (pageSize는 세션 전용)
- [x] localStorage → sessionStorage 전환 (탭 닫으면 자동 소멸)
- [x] 키를 탭 ID 스코프로 변경 (`pageSize_{tabId}_{tableName}`)
- [x] F5 새로고침 시 활성 탭 캐시 삭제 → 기본값 20 초기화
- [x] 탭 바 새로고침 버튼 시 캐시 삭제 → 기본값 20 초기화
- [x] 비활성 탭 캐시 유지 (탭 전환 시 복원)
### 6단계: 테이블 캐시 탭 격리
- [x] tableStateKey 탭 ID 스코프 (`tableState_{tabId}_{tableName}`) + sessionStorage
- [x] filterSettingKey 탭 ID 스코프 (`filterSettings_{tabId}_{base}`) + sessionStorage
- [x] groupSettingKey 탭 ID 스코프 (`groupSettings_{tabId}_{base}`) + sessionStorage
- [x] clearTabCache 확장 (tableState_/pageSize_/filterSettings_/groupSettings_ 일괄 삭제)
- [x] TabContent.tsx 모듈 레벨 플래그로 F5 감지 → 활성 탭 캐시만 삭제
- [x] tabStore.refreshTab에 clearTabCache 추가
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 |
| 2026-03-11 | 1단계(PageGroupNav 생성) + 2단계(v2-table-list 통합) + 4단계(린트) 완료. 3단계(수동 검증)은 브라우저에서 확인 필요 |
| 2026-03-11 | 추가 개선: 선택 페이지 강조(ring + font-bold), 빈 슬롯 cursor-default 적용. 3단계 검증 완료. 전체 완료 |
| 2026-03-11 | 5단계: pageSize 입력 UX 개선 + 캐시 정책 (sessionStorage + 탭 스코프 + F5/탭새로고침 초기화) |
| 2026-03-11 | 6단계: 테이블 전체 캐시를 탭별 격리 (localStorage → sessionStorage + 탭 ID 스코프) |

View File

@ -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로 저장하여 "미입력"을 정확하게 표현 (프로젝트 표준 패턴)
- 특수 문자열("상관없음" 등) 사용하지 않음 (프로젝트 관행에 맞지 않으므로)

View File

@ -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)
```
이번 변경은 위 패턴들과 일관성을 유지합니다.

View File

@ -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 | 사용자 검증 완료, 전체 작업 완료 |

View File

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

View File

@ -0,0 +1,109 @@
"use client";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const DEFAULT_GROUP_SIZE = 10;
interface PageGroupNavProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
disabled?: boolean;
groupSize?: number;
}
export function PageGroupNav({
currentPage,
totalPages,
onPageChange,
disabled = false,
groupSize = DEFAULT_GROUP_SIZE,
}: PageGroupNavProps) {
const safeTotal = Math.max(1, totalPages);
const currentGroupIndex = Math.floor((currentPage - 1) / groupSize);
const groupStartPage = currentGroupIndex * groupSize + 1;
const lastGroupIndex = Math.floor((safeTotal - 1) / groupSize);
const lastGroupStartPage = lastGroupIndex * groupSize + 1;
const isFirstGroup = currentGroupIndex === 0;
const isLastGroup = currentGroupIndex === lastGroupIndex;
const slots: (number | null)[] = [];
for (let i = 0; i < groupSize; i++) {
const page = groupStartPage + i;
slots.push(page <= safeTotal ? page : null);
}
return (
<div className="flex items-center gap-1">
{/* << 첫 단락 */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(1)}
disabled={isFirstGroup || disabled}
className="h-8 w-8 p-0 sm:h-9 sm:w-9"
>
<ChevronsLeft className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
{/* < 이전 단락 */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange((currentGroupIndex - 1) * groupSize + 1)}
disabled={isFirstGroup || disabled}
className="h-8 w-8 p-0 sm:h-9 sm:w-9"
>
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
{/* 페이지 번호 (고정 슬롯) */}
{slots.map((page, idx) =>
page !== null ? (
<Button
key={idx}
size="sm"
variant={page === currentPage ? "default" : "outline"}
onClick={() => onPageChange(page)}
disabled={disabled}
className={cn(
"h-8 w-8 p-0 text-xs sm:h-9 sm:w-9 sm:text-sm",
page === currentPage &&
"font-bold ring-2 ring-primary ring-offset-1 ring-offset-background",
)}
>
{page}
</Button>
) : (
<div key={idx} className="h-8 w-8 cursor-default sm:h-9 sm:w-9" />
),
)}
{/* > 다음 단락 */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange((currentGroupIndex + 1) * groupSize + 1)}
disabled={isLastGroup || disabled}
className="h-8 w-8 p-0 sm:h-9 sm:w-9"
>
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
{/* >> 마지막 단락 */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(lastGroupStartPage)}
disabled={isLastGroup || disabled}
className="h-8 w-8 p-0 sm:h-9 sm:w-9"
>
<ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
);
}

View File

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

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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: [],
};

View File

@ -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;
}

View File

@ -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,
]);

View File

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

View File

@ -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,
});

View File

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