mhkim-node #412
|
|
@ -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. **항상 한글로 답변**
|
||||
|
|
@ -947,6 +947,7 @@
|
|||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
|
|
@ -2184,6 +2185,7 @@
|
|||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,478 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
import { getPool } from "../database/db";
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 포장단위 (pkg_unit) CRUD
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export async function getPkgUnits(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
let sql: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
sql = `SELECT * FROM pkg_unit ORDER BY company_code, created_date DESC`;
|
||||
params = [];
|
||||
} else {
|
||||
sql = `SELECT * FROM pkg_unit WHERE company_code = $1 ORDER BY created_date DESC`;
|
||||
params = [companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(sql, params);
|
||||
logger.info("포장단위 목록 조회", { companyCode, count: result.rowCount });
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("포장단위 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPkgUnit(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
const {
|
||||
pkg_code, pkg_name, pkg_type, status,
|
||||
width_mm, length_mm, height_mm,
|
||||
self_weight_kg, max_load_kg, volume_l, remarks,
|
||||
} = req.body;
|
||||
|
||||
if (!pkg_code || !pkg_name) {
|
||||
res.status(400).json({ success: false, message: "포장코드와 포장명은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const dup = await pool.query(
|
||||
`SELECT id FROM pkg_unit WHERE pkg_code = $1 AND company_code = $2`,
|
||||
[pkg_code, companyCode]
|
||||
);
|
||||
if (dup.rowCount && dup.rowCount > 0) {
|
||||
res.status(409).json({ success: false, message: "이미 존재하는 포장코드입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO pkg_unit
|
||||
(company_code, pkg_code, pkg_name, pkg_type, status,
|
||||
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, writer)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
RETURNING *`,
|
||||
[companyCode, pkg_code, pkg_name, pkg_type, status || "ACTIVE",
|
||||
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks,
|
||||
req.user!.userId]
|
||||
);
|
||||
|
||||
logger.info("포장단위 등록", { companyCode, pkg_code });
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("포장단위 등록 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePkgUnit(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
const {
|
||||
pkg_name, pkg_type, status,
|
||||
width_mm, length_mm, height_mm,
|
||||
self_weight_kg, max_load_kg, volume_l, remarks,
|
||||
} = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE pkg_unit SET
|
||||
pkg_name=$1, pkg_type=$2, status=$3,
|
||||
width_mm=$4, length_mm=$5, height_mm=$6,
|
||||
self_weight_kg=$7, max_load_kg=$8, volume_l=$9, remarks=$10,
|
||||
updated_date=NOW(), writer=$11
|
||||
WHERE id=$12 AND company_code=$13
|
||||
RETURNING *`,
|
||||
[pkg_name, pkg_type, status,
|
||||
width_mm, length_mm, height_mm,
|
||||
self_weight_kg, max_load_kg, volume_l, remarks,
|
||||
req.user!.userId, id, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("포장단위 수정", { companyCode, id });
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("포장단위 수정 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePkgUnit(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
await client.query("BEGIN");
|
||||
await client.query(
|
||||
`DELETE FROM pkg_unit_item WHERE pkg_code = (SELECT pkg_code FROM pkg_unit WHERE id=$1 AND company_code=$2) AND company_code=$2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
const result = await client.query(
|
||||
`DELETE FROM pkg_unit WHERE id=$1 AND company_code=$2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("포장단위 삭제", { companyCode, id });
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("포장단위 삭제 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 포장단위 매칭품목 (pkg_unit_item) CRUD
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export async function getPkgUnitItems(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { pkgCode } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM pkg_unit_item WHERE pkg_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
|
||||
[pkgCode, companyCode]
|
||||
);
|
||||
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("매칭품목 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPkgUnitItem(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
const { pkg_code, item_number, pkg_qty } = req.body;
|
||||
|
||||
if (!pkg_code || !item_number) {
|
||||
res.status(400).json({ success: false, message: "포장코드와 품번은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO pkg_unit_item (company_code, pkg_code, item_number, pkg_qty, writer)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
RETURNING *`,
|
||||
[companyCode, pkg_code, item_number, pkg_qty, req.user!.userId]
|
||||
);
|
||||
|
||||
logger.info("매칭품목 추가", { companyCode, pkg_code, item_number });
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("매칭품목 추가 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePkgUnitItem(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("매칭품목 삭제", { companyCode, id });
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("매칭품목 삭제 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 적재함 (loading_unit) CRUD
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export async function getLoadingUnits(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
let sql: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
sql = `SELECT * FROM loading_unit ORDER BY company_code, created_date DESC`;
|
||||
params = [];
|
||||
} else {
|
||||
sql = `SELECT * FROM loading_unit WHERE company_code = $1 ORDER BY created_date DESC`;
|
||||
params = [companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(sql, params);
|
||||
logger.info("적재함 목록 조회", { companyCode, count: result.rowCount });
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("적재함 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLoadingUnit(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
const {
|
||||
loading_code, loading_name, loading_type, status,
|
||||
width_mm, length_mm, height_mm,
|
||||
self_weight_kg, max_load_kg, max_stack, remarks,
|
||||
} = req.body;
|
||||
|
||||
if (!loading_code || !loading_name) {
|
||||
res.status(400).json({ success: false, message: "적재함코드와 적재함명은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const dup = await pool.query(
|
||||
`SELECT id FROM loading_unit WHERE loading_code=$1 AND company_code=$2`,
|
||||
[loading_code, companyCode]
|
||||
);
|
||||
if (dup.rowCount && dup.rowCount > 0) {
|
||||
res.status(409).json({ success: false, message: "이미 존재하는 적재함코드입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO loading_unit
|
||||
(company_code, loading_code, loading_name, loading_type, status,
|
||||
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, writer)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
RETURNING *`,
|
||||
[companyCode, loading_code, loading_name, loading_type, status || "ACTIVE",
|
||||
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks,
|
||||
req.user!.userId]
|
||||
);
|
||||
|
||||
logger.info("적재함 등록", { companyCode, loading_code });
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("적재함 등록 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLoadingUnit(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
const {
|
||||
loading_name, loading_type, status,
|
||||
width_mm, length_mm, height_mm,
|
||||
self_weight_kg, max_load_kg, max_stack, remarks,
|
||||
} = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE loading_unit SET
|
||||
loading_name=$1, loading_type=$2, status=$3,
|
||||
width_mm=$4, length_mm=$5, height_mm=$6,
|
||||
self_weight_kg=$7, max_load_kg=$8, max_stack=$9, remarks=$10,
|
||||
updated_date=NOW(), writer=$11
|
||||
WHERE id=$12 AND company_code=$13
|
||||
RETURNING *`,
|
||||
[loading_name, loading_type, status,
|
||||
width_mm, length_mm, height_mm,
|
||||
self_weight_kg, max_load_kg, max_stack, remarks,
|
||||
req.user!.userId, id, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("적재함 수정", { companyCode, id });
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("적재함 수정 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteLoadingUnit(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
await client.query("BEGIN");
|
||||
await client.query(
|
||||
`DELETE FROM loading_unit_pkg WHERE loading_code = (SELECT loading_code FROM loading_unit WHERE id=$1 AND company_code=$2) AND company_code=$2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
const result = await client.query(
|
||||
`DELETE FROM loading_unit WHERE id=$1 AND company_code=$2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("적재함 삭제", { companyCode, id });
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("적재함 삭제 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 적재함 포장구성 (loading_unit_pkg) CRUD
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export async function getLoadingUnitPkgs(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { loadingCode } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM loading_unit_pkg WHERE loading_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
|
||||
[loadingCode, companyCode]
|
||||
);
|
||||
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("적재구성 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLoadingUnitPkg(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
const { loading_code, pkg_code, max_load_qty, load_method } = req.body;
|
||||
|
||||
if (!loading_code || !pkg_code) {
|
||||
res.status(400).json({ success: false, message: "적재함코드와 포장코드는 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO loading_unit_pkg (company_code, loading_code, pkg_code, max_load_qty, load_method, writer)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
RETURNING *`,
|
||||
[companyCode, loading_code, pkg_code, max_load_qty, load_method, req.user!.userId]
|
||||
);
|
||||
|
||||
logger.info("적재구성 추가", { companyCode, loading_code, pkg_code });
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("적재구성 추가 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteLoadingUnitPkg(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("적재구성 삭제", { companyCode, id });
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("적재구성 삭제 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { logger } from "../../utils/logger";
|
|||
import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService";
|
||||
import { AuthenticatedRequest } from "../../types/auth";
|
||||
import { authenticateToken } from "../../middleware/authMiddleware";
|
||||
import { auditLogService, getClientIp } from "../../services/auditLogService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -124,6 +125,21 @@ router.post("/", async (req: AuthenticatedRequest, res: Response) => {
|
|||
`플로우 저장 성공: ${result.flowId} (회사: ${userCompanyCode})`
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "CREATE",
|
||||
resourceType: "NODE_FLOW",
|
||||
resourceId: String(result.flowId),
|
||||
resourceName: flowName,
|
||||
tableName: "node_flows",
|
||||
summary: `노드 플로우 "${flowName}" 생성`,
|
||||
changes: { after: { flowName, flowDescription } },
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "플로우가 저장되었습니다.",
|
||||
|
|
@ -143,7 +159,7 @@ router.post("/", async (req: AuthenticatedRequest, res: Response) => {
|
|||
/**
|
||||
* 플로우 수정
|
||||
*/
|
||||
router.put("/", async (req: Request, res: Response) => {
|
||||
router.put("/", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { flowId, flowName, flowDescription, flowData } = req.body;
|
||||
|
||||
|
|
@ -154,6 +170,11 @@ router.put("/", async (req: Request, res: Response) => {
|
|||
});
|
||||
}
|
||||
|
||||
const oldFlow = await queryOne(
|
||||
`SELECT flow_name, flow_description FROM node_flows WHERE flow_id = $1`,
|
||||
[flowId]
|
||||
);
|
||||
|
||||
await query(
|
||||
`
|
||||
UPDATE node_flows
|
||||
|
|
@ -168,6 +189,25 @@ router.put("/", async (req: Request, res: Response) => {
|
|||
|
||||
logger.info(`플로우 수정 성공: ${flowId}`);
|
||||
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "UPDATE",
|
||||
resourceType: "NODE_FLOW",
|
||||
resourceId: String(flowId),
|
||||
resourceName: flowName,
|
||||
tableName: "node_flows",
|
||||
summary: `노드 플로우 "${flowName}" 수정`,
|
||||
changes: {
|
||||
before: oldFlow ? { flowName: (oldFlow as any).flow_name, flowDescription: (oldFlow as any).flow_description } : undefined,
|
||||
after: { flowName, flowDescription },
|
||||
},
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "플로우가 수정되었습니다.",
|
||||
|
|
@ -187,10 +227,15 @@ router.put("/", async (req: Request, res: Response) => {
|
|||
/**
|
||||
* 플로우 삭제
|
||||
*/
|
||||
router.delete("/:flowId", async (req: Request, res: Response) => {
|
||||
router.delete("/:flowId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
|
||||
const oldFlow = await queryOne(
|
||||
`SELECT flow_name, flow_description, company_code FROM node_flows WHERE flow_id = $1`,
|
||||
[flowId]
|
||||
);
|
||||
|
||||
await query(
|
||||
`
|
||||
DELETE FROM node_flows
|
||||
|
|
@ -201,6 +246,25 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
|
|||
|
||||
logger.info(`플로우 삭제 성공: ${flowId}`);
|
||||
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
const flowName = (oldFlow as any)?.flow_name || `ID:${flowId}`;
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "DELETE",
|
||||
resourceType: "NODE_FLOW",
|
||||
resourceId: String(flowId),
|
||||
resourceName: flowName,
|
||||
tableName: "node_flows",
|
||||
summary: `노드 플로우 "${flowName}" 삭제`,
|
||||
changes: {
|
||||
before: oldFlow ? { flowName: (oldFlow as any).flow_name, flowDescription: (oldFlow as any).flow_description } : undefined,
|
||||
},
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "플로우가 삭제되었습니다.",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,36 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import {
|
||||
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
|
||||
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
|
||||
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
|
||||
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
|
||||
} from "../controllers/packagingController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// TODO: 포장/적재정보 관리 API 구현 예정
|
||||
// 포장단위
|
||||
router.get("/pkg-units", getPkgUnits);
|
||||
router.post("/pkg-units", createPkgUnit);
|
||||
router.put("/pkg-units/:id", updatePkgUnit);
|
||||
router.delete("/pkg-units/:id", deletePkgUnit);
|
||||
|
||||
// 포장단위 매칭품목
|
||||
router.get("/pkg-unit-items/:pkgCode", getPkgUnitItems);
|
||||
router.post("/pkg-unit-items", createPkgUnitItem);
|
||||
router.delete("/pkg-unit-items/:id", deletePkgUnitItem);
|
||||
|
||||
// 적재함
|
||||
router.get("/loading-units", getLoadingUnits);
|
||||
router.post("/loading-units", createLoadingUnit);
|
||||
router.put("/loading-units/:id", updateLoadingUnit);
|
||||
router.delete("/loading-units/:id", deleteLoadingUnit);
|
||||
|
||||
// 적재함 포장구성
|
||||
router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
|
||||
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
|
||||
router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ export type AuditResourceType =
|
|||
| "DATA"
|
||||
| "TABLE"
|
||||
| "NUMBERING_RULE"
|
||||
| "BATCH";
|
||||
| "BATCH"
|
||||
| "NODE_FLOW";
|
||||
|
||||
export interface AuditLogParams {
|
||||
companyCode: string;
|
||||
|
|
|
|||
|
|
@ -2346,19 +2346,24 @@ export class ScreenManagementService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료)
|
||||
* 메뉴별 화면 목록 조회
|
||||
* company_code 매칭: 본인 회사 할당 + SUPER_ADMIN 글로벌 할당('*') 모두 조회
|
||||
* 본인 회사 할당이 우선, 없으면 글로벌 할당 사용
|
||||
*/
|
||||
async getScreensByMenu(
|
||||
menuObjid: number,
|
||||
companyCode: string,
|
||||
): Promise<ScreenDefinition[]> {
|
||||
const screens = await query<any>(
|
||||
`SELECT sd.* FROM screen_menu_assignments sma
|
||||
`SELECT sd.*
|
||||
FROM screen_menu_assignments sma
|
||||
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
|
||||
WHERE sma.menu_objid = $1
|
||||
AND sma.company_code = $2
|
||||
AND (sma.company_code = $2 OR sma.company_code = '*')
|
||||
AND sma.is_active = 'Y'
|
||||
ORDER BY sma.display_order ASC`,
|
||||
ORDER BY
|
||||
CASE WHEN sma.company_code = $2 THEN 0 ELSE 1 END,
|
||||
sma.display_order ASC`,
|
||||
[menuObjid, companyCode],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -3367,22 +3367,26 @@ export class TableManagementService {
|
|||
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
|
||||
);
|
||||
break;
|
||||
case "in":
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const values = value
|
||||
case "in": {
|
||||
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
||||
if (inArr.length > 0) {
|
||||
const values = inArr
|
||||
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
|
||||
.join(", ");
|
||||
filterConditions.push(`${safeColumn} IN (${values})`);
|
||||
}
|
||||
break;
|
||||
case "not_in":
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const values = value
|
||||
}
|
||||
case "not_in": {
|
||||
const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
||||
if (notInArr.length > 0) {
|
||||
const values = notInArr
|
||||
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
|
||||
.join(", ");
|
||||
filterConditions.push(`${safeColumn} NOT IN (${values})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "contains":
|
||||
filterConditions.push(
|
||||
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'`
|
||||
|
|
|
|||
|
|
@ -98,23 +98,27 @@ export function buildDataFilterWhereClause(
|
|||
paramIndex++;
|
||||
break;
|
||||
|
||||
case "in":
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
|
||||
case "in": {
|
||||
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
||||
if (inArr.length > 0) {
|
||||
const placeholders = inArr.map((_, idx) => `$${paramIndex + idx}`).join(", ");
|
||||
conditions.push(`${columnRef} IN (${placeholders})`);
|
||||
params.push(...value);
|
||||
paramIndex += value.length;
|
||||
params.push(...inArr);
|
||||
paramIndex += inArr.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "not_in":
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
|
||||
case "not_in": {
|
||||
const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
||||
if (notInArr.length > 0) {
|
||||
const placeholders = notInArr.map((_, idx) => `$${paramIndex + idx}`).join(", ");
|
||||
conditions.push(`${columnRef} NOT IN (${placeholders})`);
|
||||
params.push(...value);
|
||||
paramIndex += value.length;
|
||||
params.push(...notInArr);
|
||||
paramIndex += notInArr.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "contains":
|
||||
conditions.push(`${columnRef} LIKE $${paramIndex}`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
# [계획서] 카테고리 트리 대분류 추가 모달 - 연속 등록 모드 수정
|
||||
|
||||
> 관련 문서: [맥락노트](./CCA[맥락]-카테고리-연속등록모드.md) | [체크리스트](./CCA[체크]-카테고리-연속등록모드.md)
|
||||
|
||||
## 개요
|
||||
|
||||
기준정보 - 옵션설정 화면에서 트리 구조 카테고리(예: 품목정보 > 재고단위)의 "대분류 추가" 모달이 저장 후 닫히지 않는 버그를 수정합니다.
|
||||
평면 목록용 추가 모달(`CategoryValueAddDialog.tsx`)과 동일한 연속 입력 패턴을 적용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 동작
|
||||
|
||||
- 대분류 추가 모달에서 값 입력 후 "추가" 클릭 시 **값은 정상 저장됨**
|
||||
- 저장 후 **모달이 닫히지 않고** 폼만 초기화됨 (항상 연속 입력 상태)
|
||||
- "연속 입력" 체크박스 UI가 **없음** → 사용자가 모드를 끌 수 없음
|
||||
- 모달을 닫으려면 "닫기" 버튼 또는 외부 클릭을 해야 함
|
||||
|
||||
### 현재 코드 (CategoryValueManagerTree.tsx - handleAdd, 512~530행)
|
||||
|
||||
```tsx
|
||||
if (response.success) {
|
||||
toast.success("카테고리가 추가되었습니다");
|
||||
// 폼 초기화 (모달은 닫지 않고 연속 입력)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
valueCode: "",
|
||||
valueLabel: "",
|
||||
description: "",
|
||||
color: "",
|
||||
}));
|
||||
setTimeout(() => addNameRef.current?.focus(), 50);
|
||||
await loadTree(true);
|
||||
if (parentValue) {
|
||||
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 현재 DialogFooter (809~821행)
|
||||
|
||||
```tsx
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setIsAddModalOpen(false)} ...>
|
||||
닫기
|
||||
</Button>
|
||||
<Button onClick={handleAdd} ...>
|
||||
추가
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 후 동작
|
||||
|
||||
### 1. 기본 동작: 저장 후 모달 닫힘
|
||||
|
||||
- "추가" 클릭 → 저장 성공 → 모달 닫힘 + 트리 새로고침
|
||||
- `CategoryValueAddDialog.tsx`(평면 목록 추가 모달)와 동일한 기본 동작
|
||||
|
||||
### 2. 연속 입력 체크박스 추가
|
||||
|
||||
- DialogFooter 좌측에 "연속 입력" 체크박스 표시
|
||||
- 기본값: 체크 해제 (OFF)
|
||||
- 체크 시: 저장 후 폼만 초기화, 모달 유지, 이름 필드에 포커스
|
||||
- 체크 해제 시: 저장 후 모달 닫힘
|
||||
|
||||
---
|
||||
|
||||
## 시각적 예시
|
||||
|
||||
| 상태 | 연속 입력 체크 | 추가 버튼 클릭 후 |
|
||||
|------|---------------|-----------------|
|
||||
| 기본 (체크 해제) | [ ] 연속 입력 | 저장 → 모달 닫힘 → 트리 갱신 |
|
||||
| 연속 모드 (체크) | [x] 연속 입력 | 저장 → 폼 초기화 → 모달 유지 → 이름 필드 포커스 |
|
||||
|
||||
### 모달 하단 레이아웃 (ScreenModal.tsx 패턴)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [닫기] [추가] │ ← DialogFooter (버튼만)
|
||||
├─────────────────────────────────────────┤
|
||||
│ [x] 저장 후 계속 입력 (연속 등록 모드) │ ← border-t 구분선 아래 별도 영역
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["사용자: '추가' 클릭"] --> B["handleAdd()"]
|
||||
B --> C{"API 호출 성공?"}
|
||||
C -- 실패 --> D["toast.error → 모달 유지"]
|
||||
C -- 성공 --> E["toast.success + loadTree"]
|
||||
E --> F{"continuousAdd?"}
|
||||
F -- true --> G["폼 초기화 + 이름 필드 포커스\n모달 유지"]
|
||||
F -- false --> H["폼 초기화 + 모달 닫힘"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상 파일
|
||||
|
||||
| 파일 | 역할 | 변경 내용 |
|
||||
|------|------|----------|
|
||||
| `frontend/components/table-category/CategoryValueManagerTree.tsx` | 트리형 카테고리 값 관리 | 상태 추가, handleAdd 분기, DialogFooter UI |
|
||||
|
||||
- **변경 규모**: 약 20줄 내외 소규모 변경
|
||||
- **참고 파일**: `frontend/components/table-category/CategoryValueAddDialog.tsx` (동일 패턴)
|
||||
|
||||
---
|
||||
|
||||
## 코드 설계
|
||||
|
||||
### 1. 상태 추가 (286행 근처, 모달 상태 선언부)
|
||||
|
||||
```tsx
|
||||
const [continuousAdd, setContinuousAdd] = useState(false);
|
||||
```
|
||||
|
||||
### 2. handleAdd 성공 분기 수정 (512~530행 대체)
|
||||
|
||||
```tsx
|
||||
if (response.success) {
|
||||
toast.success("카테고리가 추가되었습니다");
|
||||
await loadTree(true);
|
||||
if (parentValue) {
|
||||
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
|
||||
}
|
||||
|
||||
if (continuousAdd) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
valueCode: "",
|
||||
valueLabel: "",
|
||||
description: "",
|
||||
color: "",
|
||||
}));
|
||||
setTimeout(() => addNameRef.current?.focus(), 50);
|
||||
} else {
|
||||
setFormData({ valueCode: "", valueLabel: "", description: "", color: "", isActive: true });
|
||||
setIsAddModalOpen(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. DialogFooter + 연속 등록 체크박스 수정 (809~821행 대체)
|
||||
|
||||
DialogFooter는 버튼만 유지하고, 그 아래에 `border-t` 구분선과 체크박스를 별도 영역으로 배치합니다.
|
||||
`ScreenModal.tsx` (1287~1303행) 패턴 그대로입니다.
|
||||
|
||||
```tsx
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsAddModalOpen(false)}
|
||||
className="h-9 flex-1 text-sm sm:flex-none"
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
<Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none">
|
||||
추가
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
{/* 연속 등록 모드 체크박스 - ScreenModal.tsx 패턴 */}
|
||||
<div className="border-t px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="tree-continuous-add"
|
||||
checked={continuousAdd}
|
||||
onCheckedChange={(checked) => setContinuousAdd(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="tree-continuous-add" className="cursor-pointer text-sm font-normal select-none">
|
||||
저장 후 계속 입력 (연속 등록 모드)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 예상 문제 및 대응
|
||||
|
||||
`CategoryValueAddDialog.tsx`와 동일한 패턴이므로 별도 예상 문제 없음.
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- `CategoryValueAddDialog.tsx`(같은 폴더, 같은 목적)의 패턴을 그대로 따름
|
||||
- 기존 수정/삭제 모달 동작은 변경하지 않음
|
||||
- 하위 추가(중분류/소분류) 모달도 동일한 `handleAdd`를 사용하므로 자동 적용
|
||||
- `Checkbox` import는 이미 존재 (24행)하므로 추가 import 불필요
|
||||
- `Label` import는 이미 존재 (53행)하므로 추가 import 불필요
|
||||
- 체크박스 위치/라벨/className 모두 `ScreenModal.tsx` (1287~1303행)과 동일
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
# [맥락노트] 카테고리 트리 대분류 추가 모달 - 연속 등록 모드 수정
|
||||
|
||||
> 관련 문서: [계획서](./CCA[계획]-카테고리-연속등록모드.md) | [체크리스트](./CCA[체크]-카테고리-연속등록모드.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 기준정보 - 옵션설정에서 트리 구조 카테고리(품목정보 > 재고단위 등)의 "대분류 추가" 모달이 저장 후 닫히지 않음
|
||||
- 연속 등록 모드가 하드코딩되어 항상 ON 상태이고, 끌 수 있는 UI가 없음
|
||||
- 같은 폴더의 평면 목록 모달(`CategoryValueAddDialog.tsx`)은 이미 올바르게 구현되어 있음
|
||||
- 동일 패턴을 적용하여 일관성 확보
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. 기본값: 연속 등록 OFF (모달 닫힘)
|
||||
|
||||
- **결정**: `continuousAdd` 초기값을 `false`로 설정
|
||||
- **근거**: 대부분의 사용자는 한 건 추가 후 결과를 확인하려 함. 연속 입력은 선택적 기능
|
||||
|
||||
### 2. 체크박스 위치: DialogFooter 아래, border-t 구분선 별도 영역
|
||||
|
||||
- **결정**: `ScreenModal.tsx` (1287~1303행) 패턴 그대로 적용
|
||||
- **근거**: "기준정보 - 부서관리" 추가 모달과 동일한 디자인. 프로젝트 관행 준수
|
||||
- **대안 검토**: `CategoryValueAddDialog.tsx`는 DialogFooter 안에 체크박스 배치 → 부서 모달과 다른 디자인이므로 기각
|
||||
|
||||
### 3. 라벨: "저장 후 계속 입력 (연속 등록 모드)"
|
||||
|
||||
- **결정**: `ScreenModal.tsx`와 동일한 라벨 텍스트 사용
|
||||
- **근거**: 부서 추가 모달과 동일한 문구로 사용자 혼란 방지
|
||||
|
||||
### 4. localStorage 미사용
|
||||
|
||||
- **결정**: 컴포넌트 state만 사용, localStorage 영속화 안 함
|
||||
- **근거**: `CategoryValueAddDialog.tsx`(같은 폴더 형제 컴포넌트)가 localStorage를 쓰지 않음. `ScreenModal.tsx`는 사용하지만 동적 화면 모달 전용 기능이므로 범위가 다름
|
||||
|
||||
### 5. 수정 대상: handleAdd 함수만
|
||||
|
||||
- **결정**: 저장 성공 분기에서만 `continuousAdd` 체크
|
||||
- **근거**: 실패 시에는 원래대로 모달 유지 + 에러 표시. 분기가 필요한 건 성공 시뿐
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 수정 대상 | `frontend/components/table-category/CategoryValueManagerTree.tsx` | 트리형 카테고리 값 관리 (대분류/중분류/소분류) |
|
||||
| 참고 패턴 (로직) | `frontend/components/table-category/CategoryValueAddDialog.tsx` | 평면 목록 추가 모달 - continuousAdd 분기 로직 |
|
||||
| 참고 패턴 (UI) | `frontend/components/common/ScreenModal.tsx` | 동적 화면 모달 - 체크박스 위치/라벨/스타일 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### 현재 handleAdd 흐름
|
||||
|
||||
```
|
||||
handleAdd() → API 호출 → 성공 시:
|
||||
1. toast.success
|
||||
2. 폼 초기화 (모달 유지 - 하드코딩)
|
||||
3. addNameRef 포커스
|
||||
4. loadTree(true) - 펼침 상태 유지
|
||||
5. parentValue 있으면 해당 노드 펼침
|
||||
```
|
||||
|
||||
### 변경 후 handleAdd 흐름
|
||||
|
||||
```
|
||||
handleAdd() → API 호출 → 성공 시:
|
||||
1. toast.success
|
||||
2. loadTree(true) + parentValue 펼침
|
||||
3. continuousAdd 체크:
|
||||
- true: 폼 초기화 + addNameRef 포커스 (모달 유지)
|
||||
- false: 폼 초기화 + setIsAddModalOpen(false) (모달 닫힘)
|
||||
```
|
||||
|
||||
### import 현황
|
||||
|
||||
- `Checkbox`: 24행에서 이미 import (`@/components/ui/checkbox`)
|
||||
- `Label`: 53행에서 이미 import (`@/components/ui/label`)
|
||||
- 추가 import 불필요
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# [체크리스트] 카테고리 트리 대분류 추가 모달 - 연속 등록 모드 수정
|
||||
|
||||
> 관련 문서: [계획서](./CCA[계획]-카테고리-연속등록모드.md) | [맥락노트](./CCA[맥락]-카테고리-연속등록모드.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (구현 완료)
|
||||
- 현재 단계: 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 1단계: 상태 추가
|
||||
|
||||
- [x] `CategoryValueManagerTree.tsx` 모달 상태 선언부(286행 근처)에 `continuousAdd` 상태 추가
|
||||
|
||||
### 2단계: handleAdd 분기 수정
|
||||
|
||||
- [x] `handleAdd` 성공 분기(512~530행)에서 `continuousAdd` 체크 분기 추가
|
||||
- [x] `continuousAdd === true`: 폼 초기화 + addNameRef 포커스 (모달 유지)
|
||||
- [x] `continuousAdd === false`: 폼 초기화 + `setIsAddModalOpen(false)` (모달 닫힘)
|
||||
|
||||
### 3단계: DialogFooter UI 수정
|
||||
|
||||
- [x] DialogFooter(809~821행)는 버튼만 유지
|
||||
- [x] DialogFooter 아래에 `border-t px-4 py-3` 영역 추가
|
||||
- [x] "저장 후 계속 입력 (연속 등록 모드)" 체크박스 배치
|
||||
- [x] ScreenModal.tsx (1287~1303행) 패턴과 동일한 className/라벨 사용
|
||||
|
||||
### 4단계: 검증
|
||||
|
||||
- [ ] 대분류 추가: 체크 해제 상태에서 추가 → 모달 닫힘 확인
|
||||
- [ ] 대분류 추가: 체크 상태에서 추가 → 모달 유지 + 폼 초기화 + 포커스 확인
|
||||
- [ ] 하위 추가(중분류/소분류): 동일하게 동작하는지 확인
|
||||
- [ ] 수정/삭제 모달: 기존 동작 변화 없음 확인
|
||||
|
||||
### 5단계: 정리
|
||||
|
||||
- [x] 린트 에러 없음 확인
|
||||
- [x] 이 체크리스트 완료 표시 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 |
|
||||
| 2026-03-11 | 구현 완료 (1~3단계, 5단계 정리). 4단계 검증은 수동 테스트 필요 |
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
# [계획서] 카테고리 드롭다운 - 3단계 깊이 구분 표시
|
||||
|
||||
> 관련 문서: [맥락노트](./CTI[맥락]-카테고리-깊이구분.md) | [체크리스트](./CTI[체크]-카테고리-깊이구분.md)
|
||||
>
|
||||
> 상태: **완료** (2026-03-11)
|
||||
|
||||
## 개요
|
||||
|
||||
카테고리 타입(`source="category"`) 드롭다운에서 3단계 계층(대분류 > 중분류 > 소분류)의 들여쓰기가 시각적으로 구분되지 않는 문제를 수정합니다.
|
||||
|
||||
---
|
||||
|
||||
## 변경 전 동작
|
||||
|
||||
- `category_values` 테이블은 `parent_value_id`, `depth` 컬럼으로 3단계 계층 구조를 지원
|
||||
- 백엔드 `buildHierarchy()`가 트리 구조를 정상적으로 반환
|
||||
- 프론트엔드 `flattenTree()`가 트리를 평탄화하면서 **일반 ASCII 공백(`" "`)** 으로 들여쓰기 생성
|
||||
- HTML이 연속 공백을 하나로 축소(collapse)하여 depth 1과 depth 2가 동일하게 렌더링됨
|
||||
|
||||
### 변경 전 코드 (flattenTree)
|
||||
|
||||
```tsx
|
||||
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
|
||||
```
|
||||
|
||||
### 변경 전 렌더링 결과
|
||||
|
||||
```
|
||||
신예철
|
||||
└ 신2
|
||||
└ 신22 ← depth 2인데 depth 1과 구분 불가
|
||||
└ 신3
|
||||
└ 신4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 후 동작
|
||||
|
||||
### 일반 공백을 Non-Breaking Space(`\u00A0`)로 교체
|
||||
|
||||
- `\u00A0`는 HTML에서 축소되지 않으므로 depth별 들여쓰기가 정확히 유지됨
|
||||
- depth당 3칸(`\u00A0\u00A0\u00A0`)으로 시각적 계층 구분을 명확히 함
|
||||
- 백엔드 변경 없음 (트리 구조는 이미 정상)
|
||||
|
||||
### 변경 후 코드 (flattenTree)
|
||||
|
||||
```tsx
|
||||
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 시각적 예시
|
||||
|
||||
| depth | prefix | 드롭다운 표시 |
|
||||
|-------|--------|-------------|
|
||||
| 0 (대분류) | `""` | `신예철` |
|
||||
| 1 (중분류) | `"\u00A0\u00A0\u00A0└ "` | `···└ 신2` |
|
||||
| 2 (소분류) | `"\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0└ "` | `······└ 신22` |
|
||||
|
||||
### 변경 전후 비교
|
||||
|
||||
```
|
||||
변경 전: 변경 후:
|
||||
신예철 신예철
|
||||
└ 신2 └ 신2
|
||||
└ 신22 ← 구분 불가 └ 신22 ← 명확히 구분
|
||||
└ 신3 └ 신3
|
||||
└ 신4 └ 신4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[category_values 테이블] -->|parent_value_id, depth| B[백엔드 buildHierarchy]
|
||||
B -->|트리 JSON 응답| C[프론트엔드 API 호출]
|
||||
C --> D[flattenTree 함수]
|
||||
D -->|"depth별 \u00A0 prefix 생성"| E[SelectOption 배열]
|
||||
E --> F{렌더링 모드}
|
||||
F -->|비검색| G[SelectItem - label 표시]
|
||||
F -->|검색| H[CommandItem - displayLabel 표시]
|
||||
|
||||
style D fill:#f96,stroke:#333,color:#000
|
||||
```
|
||||
|
||||
**변경 지점**: `flattenTree` 함수 내 prefix 생성 로직 (주황색 표시)
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상 파일
|
||||
|
||||
| 파일 경로 | 변경 내용 | 변경 규모 |
|
||||
|-----------|----------|----------|
|
||||
| `frontend/components/v2/V2Select.tsx` (904행) | `flattenTree` prefix를 `\u00A0` 기반으로 변경 | 1줄 |
|
||||
| `frontend/components/unified/UnifiedSelect.tsx` (632행) | 동일한 `flattenTree` prefix 변경 | 1줄 |
|
||||
|
||||
---
|
||||
|
||||
## 영향받는 기존 로직
|
||||
|
||||
V2Select.tsx의 `resolvedValue`(979행)에서 prefix를 제거하는 정규식:
|
||||
|
||||
```tsx
|
||||
const cleanLabel = o.label.replace(/^[\s└]+/, "").trim();
|
||||
```
|
||||
|
||||
- JavaScript `\s`는 `\u00A0`를 포함하므로 기존 정규식이 정상 동작함
|
||||
- 추가 수정 불필요
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- 백엔드 변경 없이 프론트엔드 표시 로직만 수정
|
||||
- `flattenTree` 공통 함수 수정이므로 카테고리 타입 드롭다운 전체에 자동 적용
|
||||
- DB 저장값(`valueCode`)에는 영향 없음 — `label`만 변경
|
||||
- 기존 prefix strip 정규식(`/^[\s└]+/`)과 호환 유지
|
||||
- `V2Select`와 `UnifiedSelect` 두 곳의 동일 패턴을 일관되게 수정
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
# [맥락노트] 카테고리 드롭다운 - 3단계 깊이 구분 표시
|
||||
|
||||
> 관련 문서: [계획서](./CTI[계획]-카테고리-깊이구분.md) | [체크리스트](./CTI[체크]-카테고리-깊이구분.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 품목정보 등록 모달의 "재고단위" 등 카테고리 드롭다운에서 3단계 계층이 시각적으로 구분되지 않음
|
||||
- 예: "신22"가 "신2"의 하위인데, "신3", "신4"와 같은 레벨로 보임
|
||||
- 사용자가 대분류/중분류/소분류 관계를 파악할 수 없어 잘못된 항목을 선택할 위험
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. 원인: HTML 공백 축소(collapse)
|
||||
|
||||
- **현상**: `flattenTree`에서 `" ".repeat(depth)`로 들여쓰기를 만들지만, HTML이 연속 공백을 하나로 합침
|
||||
- **결과**: depth 1(`" └ "`)과 depth 2(`" └ "`)가 동일하게 렌더링됨
|
||||
- **확인**: `SelectItem`, `CommandItem` 모두 `white-space: pre` 미적용 상태
|
||||
|
||||
### 2. 해결: Non-Breaking Space(`\u00A0`) 사용
|
||||
|
||||
- **결정**: 일반 공백 `" "`를 `"\u00A0"`로 교체
|
||||
- **근거**: `\u00A0`는 HTML에서 축소되지 않아 depth별 들여쓰기가 정확히 유지됨
|
||||
- **대안 검토**:
|
||||
- `white-space: pre` CSS 적용 → 기각 (SelectItem, CommandItem 양쪽 모두 수정 필요, shadcn 기본 스타일 오버라이드 부담)
|
||||
- CSS `padding-left` 사용 → 기각 (label 문자열 기반 옵션 구조에서 개별 아이템에 스타일 전달 어려움)
|
||||
- 트리 문자(`│`, `├`, `└`) 조합 → 기각 (과도한 시각 정보, 단순 들여쓰기면 충분)
|
||||
|
||||
### 3. depth당 3칸 `\u00A0`
|
||||
|
||||
- **결정**: `"\u00A0\u00A0\u00A0".repeat(depth)` (depth당 3칸)
|
||||
- **근거**: 기존 2칸은 `\u00A0`로 바꿔도 depth간 차이가 작음. 3칸이 시각적 구분에 적절
|
||||
|
||||
### 4. 두 파일 동시 수정
|
||||
|
||||
- **결정**: `V2Select.tsx`와 `UnifiedSelect.tsx` 모두 수정
|
||||
- **근거**: 동일한 `flattenTree` 패턴이 두 컴포넌트에 존재. 하나만 수정하면 불일치 발생
|
||||
|
||||
### 5. 기존 prefix strip 정규식 호환
|
||||
|
||||
- **확인**: V2Select.tsx 979행의 `o.label.replace(/^[\s└]+/, "").trim()`
|
||||
- **근거**: JavaScript `\s`는 `\u00A0`를 포함하므로 추가 수정 불필요
|
||||
|
||||
---
|
||||
|
||||
## 구현 중 발견한 사항
|
||||
|
||||
### CAT_ vs CATEGORY_ 접두사 불일치
|
||||
|
||||
테스트 과정에서 최고 관리자 계정으로 리스트 조회 시 `CAT_MMLL6U02_QH2V` 같은 코드가 그대로 표시되는 현상 발견.
|
||||
|
||||
- **원인**: 카테고리 값 생성 함수가 두 곳에 존재하며 접두사가 다름
|
||||
- `CategoryValueAddDialog.tsx`: `CATEGORY_` 접두사
|
||||
- `CategoryValueManagerTree.tsx`: `CAT_` 접두사
|
||||
- **영향**: 리스트 해석 로직(`V2Repeater`, `InteractiveDataTable`, `UnifiedRepeater`)이 `CATEGORY_` 접두사만 인식하여 `CAT_` 코드는 라벨 변환 실패
|
||||
- **판단**: 일반 회사 계정에서는 정상 동작 확인. 본 작업(들여쓰기 표시) 범위 외로 별도 이슈로 분리
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 수정 완료 | `frontend/components/v2/V2Select.tsx` | flattenTree 함수 (904행) |
|
||||
| 수정 완료 | `frontend/components/unified/UnifiedSelect.tsx` | flattenTree 함수 (632행) |
|
||||
| 백엔드 (변경 없음) | `backend-node/src/services/tableCategoryValueService.ts` | buildHierarchy 메서드 |
|
||||
| UI 컴포넌트 (변경 없음) | `frontend/components/ui/select.tsx` | SelectItem 렌더링 |
|
||||
| UI 컴포넌트 (변경 없음) | `frontend/components/ui/command.tsx` | CommandItem 렌더링 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### flattenTree 동작 흐름
|
||||
|
||||
```
|
||||
백엔드 API 응답 (트리 구조):
|
||||
{
|
||||
valueCode: "CAT_001", valueLabel: "신예철", children: [
|
||||
{ valueCode: "CAT_002", valueLabel: "신2", children: [
|
||||
{ valueCode: "CAT_003", valueLabel: "신22", children: [] }
|
||||
]},
|
||||
{ valueCode: "CAT_004", valueLabel: "신3", children: [] },
|
||||
{ valueCode: "CAT_005", valueLabel: "신4", children: [] }
|
||||
]
|
||||
}
|
||||
|
||||
→ flattenTree 변환 후 (SelectOption 배열):
|
||||
[
|
||||
{ value: "CAT_001", label: "신예철" },
|
||||
{ value: "CAT_002", label: "\u00A0\u00A0\u00A0└ 신2" },
|
||||
{ value: "CAT_003", label: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0└ 신22" },
|
||||
{ value: "CAT_004", label: "\u00A0\u00A0\u00A0└ 신3" },
|
||||
{ value: "CAT_005", label: "\u00A0\u00A0\u00A0└ 신4" }
|
||||
]
|
||||
```
|
||||
|
||||
### value vs label 분리
|
||||
|
||||
- `value` (저장값): `valueCode` — DB에 저장되는 값, 들여쓰기 없음
|
||||
- `label` (표시값): prefix + `valueLabel` — 화면에만 보이는 값, 들여쓰기 포함
|
||||
- 데이터 무결성에 영향 없음
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# [체크리스트] 카테고리 드롭다운 - 3단계 깊이 구분 표시
|
||||
|
||||
> 관련 문서: [계획서](./CTI[계획]-카테고리-깊이구분.md) | [맥락노트](./CTI[맥락]-카테고리-깊이구분.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (완료)
|
||||
- 현재 단계: 전체 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 1단계: 코드 수정
|
||||
|
||||
- [x] `V2Select.tsx` 904행 — `flattenTree` prefix를 `\u00A0` 기반으로 변경
|
||||
- [x] `UnifiedSelect.tsx` 632행 — 동일한 `flattenTree` prefix 변경
|
||||
|
||||
### 2단계: 검증
|
||||
|
||||
- [x] depth 1 항목: 3칸 들여쓰기 + `└` 표시 확인
|
||||
- [x] depth 2 항목: 6칸 들여쓰기 + `└` 표시, depth 1과 명확히 구분됨 확인
|
||||
- [x] depth 0 항목: 들여쓰기 없이 원래대로 표시 확인
|
||||
- [x] 항목 선택 후 값이 정상 저장되는지 확인 (valueCode 기준)
|
||||
- [x] 기존 prefix strip 로직 정상 동작 확인 — JS `\s`가 `\u00A0` 포함하므로 호환
|
||||
- [x] 검색 가능 모드(Combobox): 정상 동작 확인
|
||||
- [x] 비검색 모드(Select): 렌더링 정상 확인
|
||||
|
||||
### 3단계: 정리
|
||||
|
||||
- [x] 린트 에러 없음 확인 (기존 에러 제외)
|
||||
- [x] 계맥체 문서 최신화
|
||||
|
||||
---
|
||||
|
||||
## 참고: 최고 관리자 계정 표시 이슈
|
||||
|
||||
- 최고 관리자(`company_code = "*"`)로 리스트 조회 시 `CAT_MMLL6U02_QH2V` 같은 코드값이 그대로 노출되는 현상 발견
|
||||
- 원인: `CategoryValueManagerTree.tsx`의 `generateCode()`가 `CAT_` 접두사를 사용하나, 리스트 해석 로직은 `CATEGORY_` 접두사만 인식
|
||||
- 일반 회사 계정에서는 정상 표시됨을 확인
|
||||
- 본 작업 범위 외로 판단하여 별도 이슈로 분리
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 |
|
||||
| 2026-03-11 | 1단계 코드 수정 완료 (V2Select.tsx, UnifiedSelect.tsx) |
|
||||
| 2026-03-11 | 2단계 검증 완료, 3단계 문서 정리 완료 |
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
# [계획서] 렉 구조 위치코드/위치명 포맷 사용자 설정
|
||||
|
||||
> 관련 문서: [맥락노트](./LFC[맥락]-위치포맷-사용자설정.md) | [체크리스트](./LFC[체크]-위치포맷-사용자설정.md)
|
||||
|
||||
## 개요
|
||||
|
||||
물류관리 > 창고정보 관리 > 렉 구조 등록 모달에서 생성되는 **위치코드(`location_code`)와 위치명(`location_name`)의 포맷을 관리자가 화면 디자이너에서 자유롭게 설정**할 수 있도록 합니다.
|
||||
|
||||
현재 위치코드/위치명 생성 로직은 하드코딩되어 있어, 구분자("-"), 세그먼트 순서(창고코드-층-구역-열-단), 한글 접미사("구역", "열", "단") 등을 변경할 수 없습니다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 동작
|
||||
|
||||
### 1. 타입/설정에 패턴 필드가 정의되어 있지만 사용하지 않음
|
||||
|
||||
`types.ts`(57~58행)에 `codePattern`/`namePattern`이 정의되어 있고, `config.ts`(14~15행)에 기본값도 있으나, 실제 컴포넌트에서는 **전혀 참조하지 않음**:
|
||||
|
||||
```typescript
|
||||
// types.ts:57~58 - 정의만 있음
|
||||
codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}")
|
||||
namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단")
|
||||
|
||||
// config.ts:14~15 - 기본값만 있음
|
||||
codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}",
|
||||
namePattern: "{zone}구역-{row:02d}열-{level}단",
|
||||
```
|
||||
|
||||
### 2. 위치 코드 생성 하드코딩 (RackStructureComponent.tsx:494~510)
|
||||
|
||||
```tsx
|
||||
const generateLocationCode = useCallback(
|
||||
(row: number, level: number): { code: string; name: string } => {
|
||||
const warehouseCode = context?.warehouseCode || "WH001";
|
||||
const floor = context?.floor;
|
||||
const zone = context?.zone || "A";
|
||||
|
||||
const floorPrefix = floor ? `${floor}` : "";
|
||||
const code = `${warehouseCode}-${floorPrefix}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
||||
|
||||
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
|
||||
const floorNamePrefix = floor ? `${floor}-` : "";
|
||||
const name = `${floorNamePrefix}${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`;
|
||||
|
||||
return { code, name };
|
||||
},
|
||||
[context],
|
||||
);
|
||||
```
|
||||
|
||||
### 3. ConfigPanel에 포맷 관련 설정 UI 없음
|
||||
|
||||
`RackStructureConfigPanel.tsx`에는 필드 매핑, 제한 설정, UI 설정만 있고, `codePattern`/`namePattern`을 편집하는 UI가 없음.
|
||||
|
||||
---
|
||||
|
||||
## 변경 후 동작
|
||||
|
||||
### 1. ConfigPanel에 "포맷 설정" 섹션 추가
|
||||
|
||||
화면 디자이너 좌측 속성 패널의 v2-rack-structure ConfigPanel에 새 섹션이 추가됨:
|
||||
|
||||
- 위치코드/위치명 각각의 세그먼트 목록
|
||||
- 최상단에 컬럼 헤더(`라벨` / `구분` / `자릿수`) 표시
|
||||
- 세그먼트별로 **드래그 순서변경**, **체크박스로 한글 라벨 표시/숨김**, **라벨 텍스트 입력**, **구분자 입력**, **자릿수 입력**
|
||||
- 자릿수 필드는 숫자 타입(열, 단)만 활성화, 나머지(창고코드, 층, 구역)는 비활성화
|
||||
- 변경 시 실시간 미리보기로 결과 확인
|
||||
|
||||
### 2. 컴포넌트에서 config 기반 코드 생성
|
||||
|
||||
`RackStructureComponent`의 `generateLocationCode`가 하드코딩 대신 `config.formatConfig`의 세그먼트 배열을 순회하며 동적으로 코드/이름 생성.
|
||||
|
||||
### 3. 기본값은 현재 하드코딩과 동일
|
||||
|
||||
`formatConfig`가 설정되지 않으면 기본 세그먼트가 적용되어 현재와 완전히 동일한 결과 생성 (하위 호환).
|
||||
|
||||
---
|
||||
|
||||
## 시각적 예시
|
||||
|
||||
### ConfigPanel UI (화면 디자이너 좌측 속성 패널)
|
||||
|
||||
```
|
||||
┌─ 포맷 설정 ──────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 위치코드 포맷 │
|
||||
│ 라벨 구분 자릿수 │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ ☰ 창고코드 [✓] [ ] [ - ] [ 0 ] (비활성) │ │
|
||||
│ │ ☰ 층 [✓] [ 층 ] [ ] [ 0 ] (비활성) │ │
|
||||
│ │ ☰ 구역 [✓] [구역 ] [ - ] [ 0 ] (비활성) │ │
|
||||
│ │ ☰ 열 [✓] [ ] [ - ] [ 2 ] │ │
|
||||
│ │ ☰ 단 [✓] [ ] [ ] [ 0 ] │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ 미리보기: WH001-1층A구역-01-1 │
|
||||
│ │
|
||||
│ 위치명 포맷 │
|
||||
│ 라벨 구분 자릿수 │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ ☰ 구역 [✓] [구역 ] [ - ] [ 0 ] (비활성) │ │
|
||||
│ │ ☰ 열 [✓] [ 열 ] [ - ] [ 2 ] │ │
|
||||
│ │ ☰ 단 [✓] [ 단 ] [ ] [ 0 ] │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ 미리보기: A구역-01열-1단 │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 사용자 커스터마이징 예시
|
||||
|
||||
| 설정 변경 | 위치코드 결과 | 위치명 결과 |
|
||||
|-----------|-------------|------------|
|
||||
| 기본값 (변경 없음) | `WH001-1층A구역-01-1` | `A구역-01열-1단` |
|
||||
| 구분자를 "/" 로 변경 | `WH001/1층A구역/01/1` | `A구역/01열/1단` |
|
||||
| 층 라벨 해제 | `WH001-1A구역-01-1` | `A구역-01열-1단` |
|
||||
| 구역+열 라벨 해제 | `WH001-1층A-01-1` | `A-01-1단` |
|
||||
| 순서를 구역→층→열→단 으로 변경 | `WH001-A구역1층-01-1` | `A구역-1층-01열-1단` |
|
||||
| 한글 라벨 모두 해제 | `WH001-1A-01-1` | `A-01-1` |
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 데이터 흐름
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["관리자: 화면 디자이너 열기"] --> B["RackStructureConfigPanel\n포맷 세그먼트 편집"]
|
||||
B --> C["componentConfig.formatConfig\n에 세그먼트 배열 저장"]
|
||||
C --> D["screen_layouts_v2.layout_data\nDB JSONB에 영구 저장"]
|
||||
D --> E["엔드유저: 렉 구조 모달 열기"]
|
||||
E --> F["RackStructureComponent\nconfig.formatConfig 읽기"]
|
||||
F --> G["generateLocationCode\n세그먼트 배열 순회하며 동적 생성"]
|
||||
G --> H["미리보기 테이블에 표시\nlocation_code / location_name"]
|
||||
```
|
||||
|
||||
### 컴포넌트 관계
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph designer ["화면 디자이너 (관리자)"]
|
||||
CP["RackStructureConfigPanel"]
|
||||
FE["FormatSegmentEditor\n(신규 서브컴포넌트)"]
|
||||
CP --> FE
|
||||
end
|
||||
subgraph runtime ["렉 구조 모달 (엔드유저)"]
|
||||
RC["RackStructureComponent"]
|
||||
GL["generateLocationCode\n(세그먼트 기반으로 교체)"]
|
||||
RC --> GL
|
||||
end
|
||||
subgraph storage ["저장소"]
|
||||
DB["screen_layouts_v2\nlayout_data.overrides.formatConfig"]
|
||||
end
|
||||
|
||||
FE -->|"onChange → componentConfig"| DB
|
||||
DB -->|"config prop 전달"| RC
|
||||
```
|
||||
|
||||
> 노란색 영역은 없음. 기존 설정-저장-전달 파이프라인을 그대로 활용.
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상 파일
|
||||
|
||||
| 파일 | 수정 내용 | 수정 규모 |
|
||||
|------|----------|----------|
|
||||
| `frontend/lib/registry/components/v2-rack-structure/types.ts` | `FormatSegment`, `LocationFormatConfig` 타입 추가, `RackStructureComponentConfig`에 `formatConfig` 필드 추가 | ~25줄 |
|
||||
| `frontend/lib/registry/components/v2-rack-structure/config.ts` | 기본 코드/이름 세그먼트 상수 정의 | ~40줄 |
|
||||
| `frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx` | **신규** - grid 레이아웃 + 컬럼 헤더 + 드래그 순서변경 + showLabel 체크박스 + 라벨/구분/자릿수 고정 필드 + 자릿수 비숫자 타입 비활성화 + 미리보기 | ~200줄 |
|
||||
| `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | "포맷 설정" 섹션 추가, FormatSegmentEditor 배치 | ~30줄 |
|
||||
| `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | `generateLocationCode`를 세그먼트 기반으로 교체 | ~20줄 |
|
||||
|
||||
### 변경하지 않는 파일
|
||||
|
||||
- `buttonActions.ts` - 생성된 `location_code`/`location_name`을 그대로 저장하므로 변경 불필요
|
||||
- 백엔드 전체 - 포맷은 프론트엔드에서만 처리
|
||||
- DB 스키마 - `screen_layouts_v2.layout_data` JSONB에 자동 포함
|
||||
|
||||
---
|
||||
|
||||
## 코드 설계
|
||||
|
||||
### 1. 타입 추가 (types.ts)
|
||||
|
||||
```typescript
|
||||
// 포맷 세그먼트 (위치코드/위치명의 각 구성요소)
|
||||
export interface FormatSegment {
|
||||
type: 'warehouseCode' | 'floor' | 'zone' | 'row' | 'level';
|
||||
enabled: boolean; // 이 세그먼트를 포함할지 여부
|
||||
showLabel: boolean; // 한글 라벨 표시 여부 (false면 값에서 라벨 제거)
|
||||
label: string; // 한글 라벨 (예: "층", "구역", "열", "단")
|
||||
separatorAfter: string; // 이 세그먼트 뒤의 구분자 (예: "-", "/", "")
|
||||
pad: number; // 최소 자릿수 (0 = 그대로, 2 = "01"처럼 2자리 맞춤)
|
||||
}
|
||||
|
||||
// 위치코드 + 위치명 포맷 설정
|
||||
export interface LocationFormatConfig {
|
||||
codeSegments: FormatSegment[];
|
||||
nameSegments: FormatSegment[];
|
||||
}
|
||||
```
|
||||
|
||||
`RackStructureComponentConfig`에 필드 추가:
|
||||
|
||||
```typescript
|
||||
export interface RackStructureComponentConfig {
|
||||
// ... 기존 필드 유지 ...
|
||||
codePattern?: string; // (기존, 하위 호환용 유지)
|
||||
namePattern?: string; // (기존, 하위 호환용 유지)
|
||||
formatConfig?: LocationFormatConfig; // 신규: 구조화된 포맷 설정
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 기본 세그먼트 상수 (config.ts)
|
||||
|
||||
```typescript
|
||||
import { FormatSegment, LocationFormatConfig } from "./types";
|
||||
|
||||
// 위치코드 기본 세그먼트 (현재 하드코딩과 동일한 결과)
|
||||
export const defaultCodeSegments: FormatSegment[] = [
|
||||
{ type: "warehouseCode", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 0 },
|
||||
{ type: "floor", enabled: true, showLabel: true, label: "층", separatorAfter: "", pad: 0 },
|
||||
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
|
||||
{ type: "row", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 2 },
|
||||
{ type: "level", enabled: true, showLabel: false, label: "", separatorAfter: "", pad: 0 },
|
||||
];
|
||||
|
||||
// 위치명 기본 세그먼트 (현재 하드코딩과 동일한 결과)
|
||||
export const defaultNameSegments: FormatSegment[] = [
|
||||
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
|
||||
{ type: "row", enabled: true, showLabel: true, label: "열", separatorAfter: "-", pad: 2 },
|
||||
{ type: "level", enabled: true, showLabel: true, label: "단", separatorAfter: "", pad: 0 },
|
||||
];
|
||||
|
||||
export const defaultFormatConfig: LocationFormatConfig = {
|
||||
codeSegments: defaultCodeSegments,
|
||||
nameSegments: defaultNameSegments,
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 세그먼트 기반 문자열 생성 함수 (config.ts)
|
||||
|
||||
```typescript
|
||||
// context 값에 포함된 한글 접미사 ("1층", "A구역")
|
||||
const KNOWN_SUFFIXES: Partial<Record<FormatSegmentType, string>> = {
|
||||
floor: "층",
|
||||
zone: "구역",
|
||||
};
|
||||
|
||||
function stripKnownSuffix(type: FormatSegmentType, val: string): string {
|
||||
const suffix = KNOWN_SUFFIXES[type];
|
||||
if (suffix && val.endsWith(suffix)) {
|
||||
return val.slice(0, -suffix.length);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
export function buildFormattedString(
|
||||
segments: FormatSegment[],
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
const activeSegments = segments.filter(
|
||||
(seg) => seg.enabled && values[seg.type],
|
||||
);
|
||||
|
||||
return activeSegments
|
||||
.map((seg, idx) => {
|
||||
// 1) 원본 값에서 한글 접미사를 먼저 벗겨냄 ("A구역" → "A", "1층" → "1")
|
||||
let val = stripKnownSuffix(seg.type, values[seg.type]);
|
||||
|
||||
// 2) showLabel이 켜져 있고 label이 있으면 붙임
|
||||
if (seg.showLabel && seg.label) {
|
||||
val += seg.label;
|
||||
}
|
||||
|
||||
if (seg.pad > 0 && !isNaN(Number(val))) {
|
||||
val = val.padStart(seg.pad, "0");
|
||||
}
|
||||
|
||||
if (idx < activeSegments.length - 1) {
|
||||
val += seg.separatorAfter;
|
||||
}
|
||||
return val;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
```
|
||||
|
||||
### 4. generateLocationCode 교체 (RackStructureComponent.tsx:494~510)
|
||||
|
||||
```typescript
|
||||
// 변경 전 (하드코딩)
|
||||
const generateLocationCode = useCallback(
|
||||
(row: number, level: number): { code: string; name: string } => {
|
||||
const warehouseCode = context?.warehouseCode || "WH001";
|
||||
const floor = context?.floor;
|
||||
const zone = context?.zone || "A";
|
||||
const floorPrefix = floor ? `${floor}` : "";
|
||||
const code = `${warehouseCode}-${floorPrefix}${zone}-...`;
|
||||
// ...
|
||||
},
|
||||
[context],
|
||||
);
|
||||
|
||||
// 변경 후 (세그먼트 기반)
|
||||
const formatConfig = config.formatConfig || defaultFormatConfig;
|
||||
|
||||
const generateLocationCode = useCallback(
|
||||
(row: number, level: number): { code: string; name: string } => {
|
||||
const values: Record<string, string> = {
|
||||
warehouseCode: context?.warehouseCode || "WH001",
|
||||
floor: context?.floor || "",
|
||||
zone: context?.zone || "A",
|
||||
row: row.toString(),
|
||||
level: level.toString(),
|
||||
};
|
||||
|
||||
const code = buildFormattedString(formatConfig.codeSegments, values);
|
||||
const name = buildFormattedString(formatConfig.nameSegments, values);
|
||||
|
||||
return { code, name };
|
||||
},
|
||||
[context, formatConfig],
|
||||
);
|
||||
```
|
||||
|
||||
### 5. ConfigPanel에 포맷 설정 섹션 추가 (RackStructureConfigPanel.tsx:284행 위)
|
||||
|
||||
```tsx
|
||||
{/* 포맷 설정 - UI 설정 섹션 아래에 추가 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-gray-700">포맷 설정</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고,
|
||||
구분자/라벨을 편집할 수 있습니다
|
||||
</p>
|
||||
|
||||
<FormatSegmentEditor
|
||||
label="위치코드 포맷"
|
||||
segments={formatConfig.codeSegments}
|
||||
onChange={(segs) => handleFormatChange("codeSegments", segs)}
|
||||
sampleValues={sampleValues}
|
||||
/>
|
||||
|
||||
<FormatSegmentEditor
|
||||
label="위치명 포맷"
|
||||
segments={formatConfig.nameSegments}
|
||||
onChange={(segs) => handleFormatChange("nameSegments", segs)}
|
||||
sampleValues={sampleValues}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 6. FormatSegmentEditor 서브컴포넌트 (신규 파일)
|
||||
|
||||
- `@dnd-kit/core` + `@dnd-kit/sortable`로 드래그 순서변경
|
||||
- 프로젝트 표준 패턴: `useSortable`, `DndContext`, `SortableContext` 사용
|
||||
- **grid 레이아웃** (`grid-cols-[16px_56px_18px_1fr_1fr_1fr]`): 드래그핸들 / 타입명 / 체크박스 / 라벨 / 구분 / 자릿수
|
||||
- 최상단에 **컬럼 헤더** (`라벨` / `구분` / `자릿수`) 표시 — 각 행에서 텍스트 라벨 제거하여 공간 절약
|
||||
- 라벨/구분/자릿수 3개 필드는 **항상 고정 표시** (빈 값이어도 입력 필드가 사라지지 않음)
|
||||
- 자릿수 필드는 숫자 타입(열, 단)만 활성화, 비숫자 타입은 `disabled` + 회색 배경
|
||||
- 하단에 `buildFormattedString`으로 실시간 미리보기 표시
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- `formatConfig` 미설정 시 `defaultFormatConfig` 적용으로 **기존 동작 100% 유지** (하위 호환)
|
||||
- 포맷 설정은 **화면 디자이너 ConfigPanel에서만** 편집 (프로젝트의 설정-사용 분리 관행 준수)
|
||||
- `componentConfig` → `screen_layouts_v2.layout_data` 저장 파이프라인을 **그대로 활용** (추가 인프라 불필요)
|
||||
- 기존 `codePattern`/`namePattern` 문자열 필드는 삭제하지 않고 유지 (하위 호환)
|
||||
- v2-pivot-grid의 `format` 설정 패턴과 동일한 구조: ConfigPanel에서 설정 → 런타임에서 읽어 사용
|
||||
- `@dnd-kit` 드래그 구현은 `SortableCodeItem.tsx`, `useDragAndDrop.ts`의 기존 패턴 재사용
|
||||
- 백엔드 변경 없음, DB 스키마 변경 없음
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
# [맥락노트] 렉 구조 위치코드/위치명 포맷 사용자 설정
|
||||
|
||||
> 관련 문서: [계획서](./LFC[계획]-위치포맷-사용자설정.md) | [체크리스트](./LFC[체크]-위치포맷-사용자설정.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 위치코드(`WH001-1층A구역-01-1`)와 위치명(`A구역-01열-1단`)의 포맷이 하드코딩되어 있음
|
||||
- 회사마다 구분자("-" vs "/"), 세그먼트 순서, 한글 라벨 유무 등 요구사항이 다름
|
||||
- 현재는 코드를 직접 수정하지 않으면 포맷 변경 불가 → 관리자가 화면 디자이너에서 설정할 수 있어야 함
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. 엔드유저 모달이 아닌 화면 디자이너 ConfigPanel에 설정 UI 배치
|
||||
|
||||
- **결정**: 포맷 편집 UI를 렉 구조 등록 모달이 아닌 화면 디자이너 좌측 속성 패널(ConfigPanel)에 배치
|
||||
- **근거**: 프로젝트의 설정-사용 분리 패턴 준수. 모든 v2 컴포넌트가 ConfigPanel에서 설정하고 런타임에서 읽기만 하는 구조를 따름
|
||||
- **대안 검토**: 모달 안에 포맷 편집 UI 배치(방법 B) → 기각 (프로젝트 관행에 맞지 않음, 매번 설정해야 함, 설정이 휘발됨)
|
||||
|
||||
### 2. 패턴 문자열이 아닌 구조화된 세그먼트 배열 사용
|
||||
|
||||
- **결정**: `"{warehouseCode}-{floor}{zone}-{row:02d}-{level}"` 같은 문자열 대신 `FormatSegment[]` 배열로 포맷 정의
|
||||
- **근거**: 관리자가 패턴 문법을 알 필요 없이 드래그/토글/Input으로 직관적 편집 가능
|
||||
- **대안 검토**: 기존 `codePattern`/`namePattern` 문자열 활용 → 기각 (관리자가 패턴 문법을 모를 수 있고, 오타 가능성 높음)
|
||||
|
||||
### 2-1. 체크박스는 한글 라벨 표시/숨김 제어 (showLabel)
|
||||
|
||||
- **결정**: 세그먼트의 체크박스는 `showLabel` 속성을 토글하며, 세그먼트 자체를 제거하지 않음
|
||||
- **근거**: "A구역-01열-1단"에서 "구역", "열" 체크 해제 시 → "A-01-1단"이 되어야 함 (값은 유지, 한글만 제거)
|
||||
- **주의**: `enabled`는 세그먼트 자체의 포함 여부, `showLabel`은 한글 라벨만 표시/숨김. 혼동하지 않도록 분리
|
||||
|
||||
### 2-2. 라벨/구분/자릿수 3개 필드 항상 고정 표시
|
||||
|
||||
- **결정**: 라벨 필드를 비워도 입력 필드가 사라지지 않고, 3개 필드(라벨, 구분, 자릿수)가 모든 세그먼트에 항상 표시
|
||||
- **근거**: 라벨을 지웠을 때 "라벨 없음"이 뜨면서 입력 필드가 사라지면 다시 라벨을 추가할 수 없는 문제 발생
|
||||
- **UI 개선**: 컬럼 헤더를 최상단에 배치하고, 각 행에서는 "구분", "자릿수" 텍스트를 제거하여 공간 확보
|
||||
|
||||
### 2-3. stripKnownSuffix로 원본 값의 한글 접미사를 먼저 벗긴 뒤 라벨 붙임
|
||||
|
||||
- **결정**: `buildFormattedString`에서 값을 처리할 때, 먼저 `KNOWN_SUFFIXES`(층, 구역)를 벗겨내고 순수 값만 남긴 뒤, `showLabel && label`일 때만 라벨을 붙이는 구조
|
||||
- **근거**: context 값이 "1층", "A구역"처럼 한글이 이미 포함된 상태로 들어옴. 이전 방식(`if (seg.label)`)은 라벨 필드가 빈 문자열이면 조건을 건너뛰어서 한글이 제거되지 않는 버그 발생
|
||||
- **핵심 흐름**: 원본 값 → `stripKnownSuffix` → 순수 값 → `showLabel && label`이면 라벨 붙임
|
||||
|
||||
### 2-4. 자릿수 필드는 숫자 타입만 활성화
|
||||
|
||||
- **결정**: 자릿수(pad) 필드는 열(row), 단(level)만 편집 가능, 나머지(창고코드, 층, 구역)는 disabled + 회색 배경
|
||||
- **근거**: 자릿수(zero-padding)는 숫자 값에만 의미가 있음. 비숫자 타입에 자릿수를 설정하면 혼란을 줄 수 있음
|
||||
|
||||
### 3. 기존 codePattern/namePattern 필드는 삭제하지 않고 유지
|
||||
|
||||
- **결정**: `types.ts`의 `codePattern`, `namePattern` 필드를 삭제하지 않음
|
||||
- **근거**: 하위 호환. 기존에 이 필드를 참조하는 코드가 없지만, 향후 다른 용도로 활용될 수 있음
|
||||
|
||||
### 4. formatConfig 미설정 시 기본값으로 현재 동작 유지
|
||||
|
||||
- **결정**: `config.formatConfig`가 없으면 `defaultFormatConfig` 사용
|
||||
- **근거**: 기존 화면 설정을 수정하지 않아도 현재와 동일한 위치코드/위치명이 생성됨 (무중단 배포 가능)
|
||||
|
||||
### 5. UI 라벨에서 "패딩" 대신 "자릿수" 사용
|
||||
|
||||
- **결정**: ConfigPanel UI에서 숫자 제로패딩 설정을 "자릿수"로 표시
|
||||
- **근거**: 관리자급 사용자가 "패딩"이라는 개발 용어를 모를 수 있음. "자릿수: 2 → 01, 02, ... 99"가 직관적
|
||||
- **코드 내부**: 변수명은 `pad` 유지 (개발자 영역)
|
||||
|
||||
### 6. @dnd-kit으로 드래그 구현
|
||||
|
||||
- **결정**: `@dnd-kit/core` + `@dnd-kit/sortable` 사용
|
||||
- **근거**: 프로젝트에 이미 설치되어 있고(`package.json`), `SortableCodeItem.tsx`, `useDragAndDrop.ts` 등 표준 패턴이 확립되어 있음
|
||||
- **대안 검토**: 위/아래 화살표 버튼으로 순서 변경 → 기각 (프로젝트에 이미 DnD 패턴이 있으므로 일관성 유지)
|
||||
|
||||
### 7. v2-pivot-grid의 format 설정 패턴을 참고
|
||||
|
||||
- **결정**: ConfigPanel에서 설정 → componentConfig에 저장 → 런타임에서 읽어 사용하는 흐름
|
||||
- **근거**: v2-pivot-grid가 필드별 `format`(type, precision, thousandSeparator 등)을 동일한 패턴으로 구현하고 있음. 가장 유사한 선례
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 타입 정의 | `frontend/lib/registry/components/v2-rack-structure/types.ts` | FormatSegment, LocationFormatConfig 타입 |
|
||||
| 기본 설정 | `frontend/lib/registry/components/v2-rack-structure/config.ts` | 기본 세그먼트 상수, buildFormattedString 함수 |
|
||||
| 신규 컴포넌트 | `frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx` | 포맷 편집 UI 서브컴포넌트 |
|
||||
| 설정 패널 | `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | FormatSegmentEditor 배치 |
|
||||
| 런타임 컴포넌트 | `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | generateLocationCode 세그먼트 기반 교체 |
|
||||
| DnD 참고 | `frontend/hooks/useDragAndDrop.ts` | 프로젝트 표준 DnD 패턴 |
|
||||
| DnD 참고 | `frontend/components/admin/SortableCodeItem.tsx` | useSortable 사용 예시 |
|
||||
| 선례 참고 | `frontend/lib/registry/components/v2-pivot-grid/` | ConfigPanel에서 format 설정하는 패턴 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### 세그먼트 기반 문자열 생성 흐름
|
||||
|
||||
```
|
||||
FormatSegment[] → filter(enabled && 값 있음) → map(stripKnownSuffix → showLabel && label이면 라벨 붙임 → 자릿수 → 구분자) → join("") → 최종 문자열
|
||||
```
|
||||
|
||||
### componentConfig 저장/로드 흐름
|
||||
|
||||
```
|
||||
ConfigPanel onChange
|
||||
→ V2PropertiesPanel.onUpdateProperty("componentConfig", mergedConfig)
|
||||
→ layout.components[i].componentConfig.formatConfig
|
||||
→ convertLegacyToV2 → screen_layouts_v2.layout_data.overrides.formatConfig (DB)
|
||||
→ convertV2ToLegacy → componentConfig.formatConfig (런타임)
|
||||
→ RackStructureComponent config.formatConfig (prop)
|
||||
```
|
||||
|
||||
### context 값 참고
|
||||
|
||||
```
|
||||
context.warehouseCode = "WH001" (창고 코드)
|
||||
context.floor = "1층" (층 라벨 - 값 자체에 "층" 포함)
|
||||
context.zone = "A구역" 또는 "A" (구역 라벨 - "구역" 포함 여부 불확실)
|
||||
row = 1, 2, 3, ... (열 번호 - 숫자)
|
||||
level = 1, 2, 3, ... (단 번호 - 숫자)
|
||||
```
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
# [체크리스트] 렉 구조 위치코드/위치명 포맷 사용자 설정
|
||||
|
||||
> 관련 문서: [계획서](./LFC[계획]-위치포맷-사용자설정.md) | [맥락노트](./LFC[맥락]-위치포맷-사용자설정.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (완료)
|
||||
- 현재 단계: 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 1단계: 타입 및 기본값 정의
|
||||
|
||||
- [x] `types.ts`에 `FormatSegment` 인터페이스 추가
|
||||
- [x] `types.ts`에 `LocationFormatConfig` 인터페이스 추가
|
||||
- [x] `types.ts`의 `RackStructureComponentConfig`에 `formatConfig?: LocationFormatConfig` 필드 추가
|
||||
- [x] `config.ts`에 `defaultCodeSegments` 상수 정의 (현재 하드코딩과 동일한 결과)
|
||||
- [x] `config.ts`에 `defaultNameSegments` 상수 정의 (현재 하드코딩과 동일한 결과)
|
||||
- [x] `config.ts`에 `defaultFormatConfig` 상수 정의
|
||||
- [x] `config.ts`에 `buildFormattedString()` 함수 구현 (stripKnownSuffix 방식)
|
||||
|
||||
### 2단계: FormatSegmentEditor 서브컴포넌트 생성
|
||||
|
||||
- [x] `FormatSegmentEditor.tsx` 신규 파일 생성
|
||||
- [x] `@dnd-kit/sortable` 기반 드래그 순서변경 구현
|
||||
- [x] 세그먼트별 체크박스로 한글 라벨 표시/숨김 토글 (showLabel)
|
||||
- [x] 라벨/구분/자릿수 3개 필드 항상 고정 표시 (빈 값이어도 입력 필드 유지)
|
||||
- [x] 최상단 컬럼 헤더 추가 (라벨 / 구분 / 자릿수), 각 행에서 텍스트 라벨 제거
|
||||
- [x] grid 레이아웃으로 정렬 (`grid-cols-[16px_56px_18px_1fr_1fr_1fr]`)
|
||||
- [x] 자릿수 필드: 숫자 타입(열, 단)만 활성화, 비숫자 타입은 disabled + 회색 배경
|
||||
- [x] `buildFormattedString`으로 실시간 미리보기 표시
|
||||
|
||||
### 3단계: ConfigPanel에 포맷 설정 섹션 추가
|
||||
|
||||
- [x] `RackStructureConfigPanel.tsx`에 FormatSegmentEditor import
|
||||
- [x] UI 설정 섹션 아래에 "포맷 설정" 섹션 추가
|
||||
- [x] 위치코드 포맷용 FormatSegmentEditor 배치
|
||||
- [x] 위치명 포맷용 FormatSegmentEditor 배치
|
||||
- [x] `onChange`로 `formatConfig` 업데이트 연결
|
||||
|
||||
### 4단계: 컴포넌트에서 세그먼트 기반 코드 생성
|
||||
|
||||
- [x] `RackStructureComponent.tsx`에서 `defaultFormatConfig` import
|
||||
- [x] `generateLocationCode` 함수를 세그먼트 기반으로 교체
|
||||
- [x] `config.formatConfig || defaultFormatConfig` 폴백 적용
|
||||
|
||||
### 5단계: 검증
|
||||
|
||||
- [x] formatConfig 미설정 시: 기존과 동일한 위치코드/위치명 생성 확인
|
||||
- [x] ConfigPanel에서 구분자 변경: 미리보기에 즉시 반영 확인
|
||||
- [x] ConfigPanel에서 라벨 체크 해제: 한글만 사라지고 값은 유지 확인 (예: "A구역" → "A")
|
||||
- [x] ConfigPanel에서 순서 드래그 변경: 미리보기에 반영 확인
|
||||
- [x] ConfigPanel에서 라벨 텍스트 변경: 미리보기에 반영 확인
|
||||
- [x] 설정 저장 후 화면 재로드: 설정 유지 확인
|
||||
- [x] 렉 구조 모달에서 미리보기 생성: 설정된 포맷으로 생성 확인
|
||||
- [x] 렉 구조 저장: DB에 설정된 포맷의 코드/이름 저장 확인
|
||||
|
||||
### 6단계: 정리
|
||||
|
||||
- [x] 린트 에러 없음 확인
|
||||
- [x] 미사용 import 제거 (FormatSegmentEditor.tsx: useState)
|
||||
- [x] 파일 끝 불필요한 빈 줄 제거 (types.ts, config.ts)
|
||||
- [x] 계획서/맥락노트/체크리스트 최종 반영
|
||||
- [x] 이 체크리스트 완료 표시 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-10 | 계획서, 맥락노트, 체크리스트 작성 완료 |
|
||||
| 2026-03-10 | 1~4단계 구현 완료 (types, config, FormatSegmentEditor, ConfigPanel, Component) |
|
||||
| 2026-03-10 | showLabel 로직 수정: 체크박스가 세그먼트 제거가 아닌 한글 라벨만 표시/숨김 처리 |
|
||||
| 2026-03-10 | 계획서, 맥락노트, 체크리스트에 showLabel 변경사항 반영 |
|
||||
| 2026-03-10 | UI 개선: 3필드 고정표시 + 컬럼 헤더 + grid 레이아웃 + 자릿수 비숫자 비활성화 |
|
||||
| 2026-03-10 | 계획서, 맥락노트, 체크리스트에 UI 개선사항 반영 |
|
||||
| 2026-03-10 | 라벨 필드 비움 시 한글 미제거 버그 수정 (stripKnownSuffix 도입) |
|
||||
| 2026-03-10 | 코드 정리 (미사용 import, 빈 줄) + 문서 최종 반영 |
|
||||
| 2026-03-10 | 5단계 검증 완료, 전체 작업 완료 |
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
# [계획서] 페이징 - 페이지 번호 직접 입력 네비게이션
|
||||
|
||||
> 관련 문서: [맥락노트](./PGN[맥락]-페이징-직접입력.md) | [체크리스트](./PGN[체크]-페이징-직접입력.md)
|
||||
|
||||
## 개요
|
||||
|
||||
v2-table-list 컴포넌트의 하단 페이지네이션 중앙 영역에서, 현재 페이지 번호를 **읽기 전용 텍스트**에서 **입력 가능한 필드**로 변경합니다.
|
||||
사용자가 원하는 페이지 번호를 키보드로 직접 입력하여 빠르게 이동할 수 있게 합니다.
|
||||
|
||||
### 이전 설계(10개 번호 버튼 그룹) 폐기 사유
|
||||
|
||||
- 10개 버튼은 공간을 많이 차지하고, 모바일에서 렌더링이 어려움
|
||||
- 고정 슬롯/고정 너비 등 복잡한 레이아웃 제약이 발생
|
||||
- 입력 필드 방식이 더 직관적이고 공간 효율적
|
||||
|
||||
---
|
||||
|
||||
## 변경 전 → 변경 후
|
||||
|
||||
### 페이지네이션 UI
|
||||
|
||||
```
|
||||
변경 전: [<<] [<] 1 / 38 [>] [>>] ← 읽기 전용 텍스트
|
||||
변경 후: [<<] [<] [ 15 ] / 49 [>] [>>] ← 입력 가능 필드
|
||||
```
|
||||
|
||||
| 버튼 | 동작 (변경 없음) |
|
||||
|------|-----------------|
|
||||
| `<<` | 첫 페이지(1)로 이동 |
|
||||
| `<` | 이전 페이지(`currentPage - 1`)로 이동 |
|
||||
| 중앙 | **입력 필드** `/` **총 페이지** — 사용자가 원하는 페이지 번호를 직접 입력 |
|
||||
| `>` | 다음 페이지(`currentPage + 1`)로 이동 |
|
||||
| `>>` | 마지막 페이지(`totalPages`)로 이동 |
|
||||
|
||||
### 입력 필드 동작 규칙
|
||||
|
||||
| 동작 | 설명 |
|
||||
|------|------|
|
||||
| 클릭 | 입력 필드에 포커스, 기존 숫자 전체 선택(select all) |
|
||||
| 숫자 입력 | 자유롭게 타이핑 가능 (입력 중에는 페이지 이동 안 함) |
|
||||
| Enter | 입력한 페이지로 이동 + 포커스 해제 |
|
||||
| 포커스 아웃 (blur) | 입력한 페이지로 이동 |
|
||||
| 유효 범위 보정 | 1 미만 → 1, totalPages 초과 → totalPages, 빈 값/비숫자 → 현재 페이지 유지 |
|
||||
| `< >` 클릭 | 기존대로 한 페이지씩 이동 (입력 필드 값도 갱신) |
|
||||
| `<< >>` 클릭 | 기존대로 첫/끝 페이지 이동 (입력 필드 값도 갱신) |
|
||||
|
||||
### 비활성화 조건 (기존과 동일)
|
||||
|
||||
- `<<` `<` : `currentPage === 1`
|
||||
- `>` `>>` : `currentPage >= totalPages`
|
||||
|
||||
---
|
||||
|
||||
## 시각적 동작 예시
|
||||
|
||||
총 49페이지 기준:
|
||||
|
||||
| 사용자 동작 | 입력 필드 표시 | 결과 |
|
||||
|------------|---------------|------|
|
||||
| 초기 상태 | `1 / 49` | 1페이지 표시 |
|
||||
| 입력 필드 클릭 | `[1]` 전체 선택됨 | 타이핑 대기 |
|
||||
| `28` 입력 후 Enter | `28 / 49` | 28페이지로 이동 |
|
||||
| `0` 입력 후 Enter | `1 / 49` | 1로 보정 |
|
||||
| `999` 입력 후 Enter | `49 / 49` | 49로 보정 |
|
||||
| 빈 값으로 blur | `28 / 49` | 이전 페이지(28) 유지 |
|
||||
| `abc` 입력 후 Enter | `28 / 49` | 이전 페이지(28) 유지 |
|
||||
| `>` 클릭 | `29 / 49` | 29페이지로 이동 |
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 데이터 흐름
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["currentPage (state, 단일 소스)"] --> B["입력 필드 표시값 (pageInputValue)"]
|
||||
B -->|"사용자 타이핑"| C["pageInputValue 갱신 (표시만)"]
|
||||
C -->|"Enter 또는 blur"| D["유효 범위 보정 (1~totalPages)"]
|
||||
D -->|"보정된 값"| E[handlePageChange]
|
||||
E --> F["setCurrentPage → useEffect → fetchTableDataDebounced"]
|
||||
F --> G[백엔드 API 호출]
|
||||
G --> H[데이터 갱신]
|
||||
H --> A
|
||||
|
||||
I["<< < > >> 클릭"] --> E
|
||||
J["페이지크기 변경"] --> K["setCurrentPage(1) + setLocalPageSize + onConfigChange"]
|
||||
K --> F
|
||||
```
|
||||
|
||||
### 페이징 바 레이아웃
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ [페이지크기 입력] │ << < [__입력__] / n > >> │ [내보내기][새로고침] │
|
||||
│ 좌측(유지) │ 중앙(입력필드 교체) │ 우측(유지) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상 파일
|
||||
|
||||
| 구분 | 파일 | 변경 내용 |
|
||||
|------|------|----------|
|
||||
| 수정 | `TableListComponent.tsx` | (1) `pageInputValue` 상태 + `useEffect` 동기화 + `commitPageInput` 핸들러 추가 |
|
||||
| | | (2) paginationJSX 중앙 `<span>` → `<input>` + `/` + `<span>` 교체 |
|
||||
| | | (3) `handlePageSizeChange`에 `onConfigChange` 호출 추가 |
|
||||
| | | (4) `fetchTableDataInternal`에서 `currentPage`를 단일 소스로 사용 |
|
||||
| | | (5) `useMemo` 의존성에 `pageInputValue` 추가 |
|
||||
| 삭제 | `PageGroupNav.tsx` | 이전 설계 산출물 삭제 (이미 삭제됨) |
|
||||
|
||||
- 신규 파일 생성 없음
|
||||
- 백엔드 변경 없음, DB 변경 없음
|
||||
- v2-table-list를 사용하는 **모든 동적 화면**에 자동 적용
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- **최소 변경**: `<span>` 1개를 `<input>` + 유효성 검증으로 교체. 나머지 전부 유지
|
||||
- **기존 버튼 동작 무변경**: `<< < > >>` 4개 버튼의 onClick/disabled 로직은 그대로
|
||||
- **`handlePageChange` 재사용**: 기존 함수를 그대로 호출
|
||||
- **입력 중 페이지 이동 안 함**: onChange는 표시만 변경, Enter/blur로 실제 적용
|
||||
- **유효 범위 자동 보정**: 1 미만 → 1, totalPages 초과 → totalPages, 비숫자 → 현재 값 유지
|
||||
- **포커스 시 전체 선택**: 클릭하면 바로 타이핑 가능
|
||||
- **`currentPage`가 단일 소스**: fetch 시 `tableConfig.pagination?.currentPage` 대신 로컬 `currentPage`만 사용 (비동기 전파 문제 방지)
|
||||
- **페이지크기 변경 시 1페이지로 리셋**: `handlePageSizeChange`가 `onConfigChange`를 호출하여 부모/백엔드 동기화
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
# [맥락노트] 페이징 - 페이지 번호 직접 입력 네비게이션
|
||||
|
||||
> 관련 문서: [계획서](./PGN[계획]-페이징-직접입력.md) | [체크리스트](./PGN[체크]-페이징-직접입력.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 현재 페이지네이션은 `1 / 38` 읽기 전용 텍스트만 표시
|
||||
- 수십 페이지가 있을 때 원하는 페이지로 빠르게 이동할 수 없음 (`>` 연타 필요)
|
||||
- 페이지 번호를 직접 입력하여 즉시 이동할 수 있어야 UX가 개선됨
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. 10개 번호 버튼 그룹 → 입력 필드로 설계 변경
|
||||
|
||||
- **결정**: 이전 설계(10개 페이지 번호 버튼 나열)를 폐기하고, 기존 `현재/총` 텍스트에서 현재 부분을 입력 필드로 교체
|
||||
- **근거**: 10개 버튼은 공간을 많이 차지하고 고정 슬롯/고정 너비 등 복잡한 레이아웃 제약이 발생. 입력 필드 방식이 더 직관적이고 공간 효율적
|
||||
- **이전 산출물**: `PageGroupNav.tsx` → 삭제 완료
|
||||
|
||||
### 2. `<< < > >>` 버튼 동작 유지
|
||||
|
||||
- **결정**: 4개 화살표 버튼의 동작은 기존과 완전히 동일하게 유지
|
||||
- **근거**: 입력 필드가 "원하는 페이지로 점프" 역할을 하므로, 버튼은 기존의 순차 이동(+1/-1, 첫/끝) 그대로 유지하는 것이 자연스러움
|
||||
|
||||
### 3. 입력 중에는 페이지 이동 안 함
|
||||
|
||||
- **결정**: onChange는 입력 필드 표시만 변경. Enter 또는 blur로 실제 페이지 이동
|
||||
- **근거**: `28`을 입력하려면 `2`를 먼저 치는데, `2`에서 바로 이동하면 안 됨
|
||||
|
||||
### 4. 포커스 시 전체 선택 (select all)
|
||||
|
||||
- **결정**: 입력 필드 클릭 시 기존 숫자를 전체 선택
|
||||
- **근거**: 사용자가 "15페이지로 가고 싶다" → 클릭 → 바로 `15` 타이핑. 기존 값을 지우는 추가 동작 불필요
|
||||
|
||||
### 5. 유효 범위 자동 보정
|
||||
|
||||
- **결정**: 1 미만 → 1, totalPages 초과 → totalPages, 빈 값/비숫자 → 현재 페이지 유지
|
||||
- **근거**: 에러 메시지보다 자동 보정이 UX에 유리
|
||||
- **대안 검토**: 입력 자체를 숫자만 허용 → 기각 (백스페이스로 비울 때 불편)
|
||||
|
||||
### 6. `inputMode="numeric"` 사용
|
||||
|
||||
- **결정**: `type="text"` + `inputMode="numeric"`
|
||||
- **근거**: `type="number"`는 브라우저별 스피너 UI가 추가되고, 빈 값 처리가 어려움. `inputMode="numeric"`은 모바일에서 숫자 키보드를 띄우면서 text 입력의 유연성 유지
|
||||
|
||||
### 7. 신규 컴포넌트 분리 안 함
|
||||
|
||||
- **결정**: v2-table-list의 paginationJSX 내부에 인라인으로 구현
|
||||
- **근거**: 변경이 `<span>` → `<input>` + 핸들러 약 30줄 수준으로 매우 작음
|
||||
|
||||
### 8. `currentPage`를 fetch의 단일 소스로 사용
|
||||
|
||||
- **결정**: `fetchTableDataInternal`에서 `tableConfig.pagination?.currentPage || currentPage` 대신 `currentPage`만 사용
|
||||
- **근거**: `handlePageSizeChange`에서 `setCurrentPage(1)` + `onConfigChange(...)` 호출 시, `onConfigChange`를 통한 부모의 `tableConfig` 갱신은 다음 렌더 사이클에서 전파됨. fetch가 실행되는 시점에 `tableConfig.pagination?.currentPage`가 아직 이전 값(예: 4)이고 truthy이므로 로컬 `currentPage`(1) 대신 4를 사용하게 되는 문제 발생. 로컬 `currentPage`는 `setCurrentPage`로 즉시 갱신되므로 이 문제가 없음
|
||||
- **발견 과정**: 페이지 크기를 20→40으로 변경하면 1페이지로 설정되지만 리스트가 빈 상태로 표시되는 버그로 발견
|
||||
|
||||
### 9. `handlePageSizeChange`에서 `onConfigChange` 호출 필수
|
||||
|
||||
- **결정**: 페이지 크기 변경 시 `onConfigChange`로 `{ pageSize, currentPage: 1 }`을 부모에게 전달
|
||||
- **근거**: 기존 코드는 `setLocalPageSize` + `setCurrentPage(1)`만 호출하고 `onConfigChange`를 호출하지 않았음. 이로 인해 부모 컴포넌트의 `tableConfig.pagination`이 갱신되지 않아 후속 동작에서 stale 값 참조 가능
|
||||
- **발견 과정**: 위 8번과 같은 맥락에서 발견
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | paginationJSX 중앙 입력 필드 + fetch 소스 수정 |
|
||||
| 삭제 | `frontend/components/common/PageGroupNav.tsx` | 이전 설계 산출물 (삭제 완료) |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### 로컬 입력 상태와 실제 페이지 상태 분리
|
||||
|
||||
```
|
||||
pageInputValue (string) — 입력 필드에 표시되는 값 (사용자가 타이핑 중일 수 있음)
|
||||
currentPage (number) — 실제 현재 페이지 (API 호출의 단일 소스)
|
||||
|
||||
동기화:
|
||||
- currentPage 변경 시 → useEffect → setPageInputValue(String(currentPage))
|
||||
- Enter/blur 시 → commitPageInput → parseInt + clamp → handlePageChange(보정된 값)
|
||||
```
|
||||
|
||||
### handlePageChange 호출 흐름
|
||||
|
||||
```
|
||||
입력 필드 Enter/blur
|
||||
→ commitPageInput()
|
||||
→ parseInt + clamp(1, totalPages)
|
||||
→ handlePageChange(clampedPage)
|
||||
→ setCurrentPage(clampedPage) + onConfigChange
|
||||
→ useEffect 트리거 → fetchTableDataDebounced
|
||||
→ fetchTableDataInternal(page = currentPage)
|
||||
→ 백엔드 API 호출
|
||||
```
|
||||
|
||||
### handlePageSizeChange 호출 흐름
|
||||
|
||||
```
|
||||
좌측 페이지크기 입력 onChange/onBlur
|
||||
→ handlePageSizeChange(newSize)
|
||||
→ setLocalPageSize(newSize)
|
||||
→ setCurrentPage(1)
|
||||
→ sessionStorage 저장
|
||||
→ onConfigChange({ pageSize: newSize, currentPage: 1 })
|
||||
→ useEffect 트리거 → fetchTableDataDebounced
|
||||
→ fetchTableDataInternal(page = 1, pageSize = newSize)
|
||||
→ 백엔드 API 호출
|
||||
```
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# [체크리스트] 페이징 - 페이지 번호 직접 입력 네비게이션
|
||||
|
||||
> 관련 문서: [계획서](./PGN[계획]-페이징-직접입력.md) | [맥락노트](./PGN[맥락]-페이징-직접입력.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (완료)
|
||||
- 현재 단계: 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 1단계: 이전 설계 산출물 정리
|
||||
|
||||
- [x] `frontend/components/common/PageGroupNav.tsx` 삭제
|
||||
- [x] `TableListComponent.tsx`에서 `PageGroupNav` import 제거 (있으면) — 이미 없음
|
||||
|
||||
### 2단계: 입력 필드 구현
|
||||
|
||||
- [x] `pageInputValue` 로컬 상태 추가 (`useState<string>`)
|
||||
- [x] `currentPage` 변경 시 `pageInputValue` 동기화 (`useEffect`)
|
||||
- [x] `commitPageInput` 핸들러 구현 (parseInt + clamp + handlePageChange)
|
||||
- [x] paginationJSX 중앙의 `<span>` → `<input>` + `/` + `<span>` 교체
|
||||
- [x] `inputMode="numeric"` 적용
|
||||
- [x] `onFocus`에 전체 선택 (`e.target.select()`)
|
||||
- [x] `onChange`에 `setPageInputValue` (표시만 변경)
|
||||
- [x] `onKeyDown` Enter에 `commitPageInput` + `blur()`
|
||||
- [x] `onBlur`에 `commitPageInput`
|
||||
- [x] `disabled={loading}` 적용
|
||||
- [x] 기존 좌측 페이지크기 입력과 일관된 스타일 적용
|
||||
|
||||
### 3단계: 버그 수정
|
||||
|
||||
- [x] `handlePageSizeChange`에 `onConfigChange` 호출 추가 (`pageSize` + `currentPage: 1` 전달)
|
||||
- [x] `fetchTableDataInternal`에서 `currentPage`를 단일 소스로 변경 (stale `tableConfig.pagination?.currentPage` 문제 해결)
|
||||
- [x] `useCallback` 의존성에서 `tableConfig.pagination?.currentPage` 제거
|
||||
- [x] `useMemo` 의존성에 `pageInputValue` 추가
|
||||
|
||||
### 4단계: 검증
|
||||
|
||||
- [x] 입력 필드에 숫자 입력 후 Enter → 해당 페이지로 이동
|
||||
- [x] 입력 필드에 숫자 입력 후 포커스 아웃 → 해당 페이지로 이동
|
||||
- [x] 0 입력 → 1로 보정
|
||||
- [x] totalPages 초과 입력 → totalPages로 보정
|
||||
- [x] 빈 값으로 blur → 현재 페이지 유지
|
||||
- [x] 비숫자(abc) 입력 후 Enter → 현재 페이지 유지
|
||||
- [x] 입력 필드 클릭 시 기존 숫자 전체 선택 확인
|
||||
- [x] `< >` 버튼 클릭 시 입력 필드 값도 갱신 확인
|
||||
- [x] `<< >>` 버튼 클릭 시 입력 필드 값도 갱신 확인
|
||||
- [x] 로딩 중 입력 필드 비활성화 확인
|
||||
- [x] 좌측 페이지크기 입력과 스타일 일관성 확인
|
||||
- [x] 기존 `<< < > >>` 버튼 동작 변화 없음 확인
|
||||
- [x] 페이지크기 변경 시 1페이지로 리셋 + 데이터 정상 로딩 확인
|
||||
|
||||
### 5단계: 정리
|
||||
|
||||
- [x] 린트 에러 없음 확인 (기존 에러만 존재, 신규 없음)
|
||||
- [x] 문서(계획서/맥락노트/체크리스트) 최신화 완료
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-11 | 최초 설계: 10개 번호 버튼 그룹 (PageGroupNav) |
|
||||
| 2026-03-11 | 설계 변경: 입력 필드 방식으로 전면 재작성 |
|
||||
| 2026-03-11 | 구현 완료: 입력 필드 + 유효성 검증 |
|
||||
| 2026-03-11 | 버그 수정: 페이지크기 변경 시 빈 데이터 문제 (onConfigChange 누락 + stale currentPage) |
|
||||
| 2026-03-11 | 문서 최신화: 버그 수정 내역 반영, 코드 설계 섹션 제거 (구현 완료) |
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
# [계획서] 렉 구조 등록 - 층(floor) 필수 입력 해제
|
||||
|
||||
> 관련 문서: [맥락노트](./RFO[맥락]-렉구조-층필수해제.md) | [체크리스트](./RFO[체크]-렉구조-층필수해제.md)
|
||||
|
||||
## 개요
|
||||
|
||||
탑씰 회사의 물류관리 > 창고정보 관리 > 렉 구조 등록 모달에서, "층" 필드를 필수 입력에서 선택 입력으로 변경합니다. 현재 "창고 코드 / 층 / 구역" 3개가 모두 필수로 하드코딩되어 있어, 층을 선택하지 않으면 미리보기 생성과 저장이 불가능합니다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 동작
|
||||
|
||||
### 1. 필수 필드 경고 (RackStructureComponent.tsx:291~298)
|
||||
|
||||
층을 선택하지 않으면 빨간 경고가 표시됨:
|
||||
|
||||
```tsx
|
||||
const missingFields = useMemo(() => {
|
||||
const missing: string[] = [];
|
||||
if (!context.warehouseCode) missing.push("창고 코드");
|
||||
if (!context.floor) missing.push("층"); // ← 하드코딩 필수
|
||||
if (!context.zone) missing.push("구역");
|
||||
return missing;
|
||||
}, [context]);
|
||||
```
|
||||
|
||||
> "다음 필드를 먼저 입력해주세요: **층**"
|
||||
|
||||
### 2. 미리보기 생성 차단 (RackStructureComponent.tsx:517~521)
|
||||
|
||||
`missingFields`에 "층"이 포함되어 있으면 `generatePreview()` 실행이 차단됨:
|
||||
|
||||
```tsx
|
||||
if (missingFields.length > 0) {
|
||||
alert(`다음 필드를 먼저 입력해주세요: ${missingFields.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 위치 코드 생성 (RackStructureComponent.tsx:497~513)
|
||||
|
||||
floor가 없으면 기본값 `"1"`을 사용하여 위치 코드를 생성:
|
||||
|
||||
```tsx
|
||||
const floor = context?.floor || "1";
|
||||
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
||||
// 예: WH001-1층A구역-01-1
|
||||
```
|
||||
|
||||
### 4. 기존 데이터 조회 (RackStructureComponent.tsx:378~432)
|
||||
|
||||
floor가 비어있으면 기존 데이터 조회 자체를 건너뜀 → 중복 체크 불가:
|
||||
|
||||
```tsx
|
||||
if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) {
|
||||
setExistingLocations([]);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 렉 구조 화면 감지 (buttonActions.ts:692~698)
|
||||
|
||||
floor가 비어있으면 렉 구조 화면으로 인식하지 않음 → 일반 저장으로 빠짐:
|
||||
|
||||
```tsx
|
||||
const isRackStructureScreen =
|
||||
context.tableName === "warehouse_location" &&
|
||||
context.formData?.floor && // ← floor 없으면 false
|
||||
context.formData?.zone &&
|
||||
!rackStructureLocations;
|
||||
```
|
||||
|
||||
### 6. 저장 전 중복 체크 (buttonActions.ts:2085~2131)
|
||||
|
||||
floor가 없으면 중복 체크 전체를 건너뜀:
|
||||
|
||||
```tsx
|
||||
if (warehouseCode && floor && zone) {
|
||||
// 중복 체크 로직
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 후 동작
|
||||
|
||||
### 1. 필수 필드에서 "층" 제거
|
||||
|
||||
- "창고 코드"와 "구역"만 필수
|
||||
- 층을 선택하지 않아도 경고가 뜨지 않음
|
||||
|
||||
### 2. 미리보기 생성 정상 동작
|
||||
|
||||
- 층 없이도 미리보기 생성 가능
|
||||
- 위치 코드에서 층 부분을 생략하여 깔끔하게 생성
|
||||
|
||||
### 3. 위치 코드 생성 규칙 변경
|
||||
|
||||
- 층 있을 때: `WH001-1층A구역-01-1` (기존과 동일)
|
||||
- 층 없을 때: `WH001-A구역-01-1` (층 부분 생략)
|
||||
|
||||
### 4. 기존 데이터 조회 (중복 체크)
|
||||
|
||||
- 층 있을 때: `warehouse_code + floor + zone`으로 조회 (기존과 동일)
|
||||
- 층 없을 때: `warehouse_code + zone`으로 조회 (floor 조건 제외)
|
||||
|
||||
### 5. 렉 구조 화면 감지
|
||||
|
||||
- floor 유무와 관계없이 `warehouse_location` 테이블 + zone 필드가 있으면 렉 구조 화면으로 인식
|
||||
|
||||
### 6. 저장 시 floor 값
|
||||
|
||||
- 층 선택함: `floor = "1층"` 등 선택한 값 저장
|
||||
- 층 미선택: `floor = NULL`로 저장
|
||||
|
||||
---
|
||||
|
||||
## 시각적 예시
|
||||
|
||||
| 상태 | 경고 메시지 | 미리보기 | 위치 코드 | DB floor 값 |
|
||||
|------|------------|---------|-----------|------------|
|
||||
| 창고+층+구역 모두 선택 | 없음 | 생성 가능 | `WH001-1층A구역-01-1` | `"1층"` |
|
||||
| 창고+구역만 선택 (층 미선택) | 없음 | 생성 가능 | `WH001-A구역-01-1` | `NULL` |
|
||||
| 창고만 선택 | "구역을 먼저 입력해주세요" | 차단 | - | - |
|
||||
| 아무것도 미선택 | "창고 코드, 구역을 먼저 입력해주세요" | 차단 | - | - |
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 데이터 흐름 (변경 전)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[사용자: 창고/층/구역 입력] --> B{필수 필드 검증}
|
||||
B -->|층 없음| C[경고: 층을 입력하세요]
|
||||
B -->|3개 다 있음| D[기존 데이터 조회<br/>warehouse_code + floor + zone]
|
||||
D --> E[미리보기 생성]
|
||||
E --> F{저장 버튼}
|
||||
F --> G[렉 구조 화면 감지<br/>floor && zone 필수]
|
||||
G --> H[중복 체크<br/>warehouse_code + floor + zone]
|
||||
H --> I[일괄 INSERT<br/>floor = 선택값]
|
||||
```
|
||||
|
||||
### 데이터 흐름 (변경 후)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[사용자: 창고/구역 입력<br/>층은 선택사항] --> B{필수 필드 검증}
|
||||
B -->|창고 or 구역 없음| C[경고: 해당 필드를 입력하세요]
|
||||
B -->|창고+구역 있음| D{floor 값 존재?}
|
||||
D -->|있음| E1[기존 데이터 조회<br/>warehouse_code + floor + zone]
|
||||
D -->|없음| E2[기존 데이터 조회<br/>warehouse_code + zone]
|
||||
E1 --> F[미리보기 생성]
|
||||
E2 --> F
|
||||
F --> G{저장 버튼}
|
||||
G --> H[렉 구조 화면 감지<br/>zone만 필수]
|
||||
H --> I{floor 값 존재?}
|
||||
I -->|있음| J1[중복 체크<br/>warehouse_code + floor + zone]
|
||||
I -->|없음| J2[중복 체크<br/>warehouse_code + zone]
|
||||
J1 --> K[일괄 INSERT<br/>floor = 선택값]
|
||||
J2 --> K2[일괄 INSERT<br/>floor = NULL]
|
||||
```
|
||||
|
||||
### 컴포넌트 관계
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph 프론트엔드
|
||||
A[폼 필드<br/>창고/층/구역] -->|formData| B[RackStructureComponent<br/>필수 검증 + 미리보기]
|
||||
B -->|locations 배열| C[buttonActions.ts<br/>화면 감지 + 중복 체크 + 저장]
|
||||
end
|
||||
subgraph 백엔드
|
||||
C -->|POST /dynamic-form/save| D[DynamicFormApi<br/>데이터 저장]
|
||||
D --> E[(warehouse_location<br/>floor: nullable)]
|
||||
end
|
||||
|
||||
style B fill:#fff3cd,stroke:#ffc107
|
||||
style C fill:#fff3cd,stroke:#ffc107
|
||||
```
|
||||
|
||||
> 노란색 = 이번에 수정하는 부분
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상 파일
|
||||
|
||||
| 파일 | 수정 내용 | 수정 규모 |
|
||||
|------|----------|----------|
|
||||
| `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | 필수 검증에서 floor 제거, 위치 코드 생성 로직 수정, 기존 데이터 조회 로직 수정 | ~20줄 |
|
||||
| `frontend/lib/utils/buttonActions.ts` | 렉 구조 화면 감지 조건 수정, 중복 체크 조건 수정 | ~10줄 |
|
||||
|
||||
### 사전 확인 필요
|
||||
|
||||
| 확인 항목 | 내용 |
|
||||
|----------|------|
|
||||
| DB 스키마 | `warehouse_location.floor` 컬럼이 `NULL` 허용인지 확인. NOT NULL이면 `ALTER TABLE` 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 코드 설계
|
||||
|
||||
### 1. 필수 필드 검증 수정 (RackStructureComponent.tsx:291~298)
|
||||
|
||||
```tsx
|
||||
// 변경 전
|
||||
const missingFields = useMemo(() => {
|
||||
const missing: string[] = [];
|
||||
if (!context.warehouseCode) missing.push("창고 코드");
|
||||
if (!context.floor) missing.push("층");
|
||||
if (!context.zone) missing.push("구역");
|
||||
return missing;
|
||||
}, [context]);
|
||||
|
||||
// 변경 후
|
||||
const missingFields = useMemo(() => {
|
||||
const missing: string[] = [];
|
||||
if (!context.warehouseCode) missing.push("창고 코드");
|
||||
if (!context.zone) missing.push("구역");
|
||||
return missing;
|
||||
}, [context]);
|
||||
```
|
||||
|
||||
### 2. 위치 코드 생성 수정 (RackStructureComponent.tsx:497~513)
|
||||
|
||||
```tsx
|
||||
// 변경 전
|
||||
const floor = context?.floor || "1";
|
||||
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
||||
|
||||
// 변경 후
|
||||
const floor = context?.floor;
|
||||
const floorPrefix = floor ? `${floor}` : "";
|
||||
const code = `${warehouseCode}-${floorPrefix}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
||||
// 층 있을 때: WH001-1층A구역-01-1
|
||||
// 층 없을 때: WH001-A구역-01-1
|
||||
```
|
||||
|
||||
### 3. 기존 데이터 조회 수정 (RackStructureComponent.tsx:378~432)
|
||||
|
||||
```tsx
|
||||
// 변경 전
|
||||
if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) {
|
||||
setExistingLocations([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams = {
|
||||
warehouse_code: { value: warehouseCodeForQuery, operator: "equals" },
|
||||
floor: { value: floorForQuery, operator: "equals" },
|
||||
zone: { value: zoneForQuery, operator: "equals" },
|
||||
};
|
||||
|
||||
// 변경 후
|
||||
if (!warehouseCodeForQuery || !zoneForQuery) {
|
||||
setExistingLocations([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams: Record<string, any> = {
|
||||
warehouse_code: { value: warehouseCodeForQuery, operator: "equals" },
|
||||
zone: { value: zoneForQuery, operator: "equals" },
|
||||
};
|
||||
if (floorForQuery) {
|
||||
searchParams.floor = { value: floorForQuery, operator: "equals" };
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 렉 구조 화면 감지 수정 (buttonActions.ts:692~698)
|
||||
|
||||
```tsx
|
||||
// 변경 전
|
||||
const isRackStructureScreen =
|
||||
context.tableName === "warehouse_location" &&
|
||||
context.formData?.floor &&
|
||||
context.formData?.zone &&
|
||||
!rackStructureLocations;
|
||||
|
||||
// 변경 후
|
||||
const isRackStructureScreen =
|
||||
context.tableName === "warehouse_location" &&
|
||||
context.formData?.zone &&
|
||||
!rackStructureLocations;
|
||||
```
|
||||
|
||||
### 5. 저장 전 중복 체크 수정 (buttonActions.ts:2085~2131)
|
||||
|
||||
```tsx
|
||||
// 변경 전
|
||||
if (warehouseCode && floor && zone) {
|
||||
const existingResponse = await DynamicFormApi.getTableData(tableName, {
|
||||
search: {
|
||||
warehouse_code: { value: warehouseCode, operator: "equals" },
|
||||
floor: { value: floor, operator: "equals" },
|
||||
zone: { value: zone, operator: "equals" },
|
||||
},
|
||||
// ...
|
||||
});
|
||||
}
|
||||
|
||||
// 변경 후
|
||||
if (warehouseCode && zone) {
|
||||
const searchParams: Record<string, any> = {
|
||||
warehouse_code: { value: warehouseCode, operator: "equals" },
|
||||
zone: { value: zone, operator: "equals" },
|
||||
};
|
||||
if (floor) {
|
||||
searchParams.floor = { value: floor, operator: "equals" };
|
||||
}
|
||||
|
||||
const existingResponse = await DynamicFormApi.getTableData(tableName, {
|
||||
search: searchParams,
|
||||
// ...
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 적용 범위 및 영향도
|
||||
|
||||
### 이번 변경은 전역 설정
|
||||
|
||||
방법 B는 렉 구조 컴포넌트 코드에서 직접 "층 필수"를 제거하는 방식이므로, 이 컴포넌트를 사용하는 **모든 회사**에 동일하게 적용됩니다.
|
||||
|
||||
| 회사 | 변경 후 |
|
||||
|------|--------|
|
||||
| 탑씰 | 층 안 골라도 됨 (요청 사항) |
|
||||
| 다른 회사 | 층 안 골라도 됨 (동일하게 적용) |
|
||||
|
||||
### 기존 사용자에 대한 영향
|
||||
|
||||
- 층을 안 골라도 **되는** 것이지, 안 골라야 **하는** 것이 아님
|
||||
- 기존처럼 층을 선택하면 **완전히 동일하게** 동작함 (하위 호환 보장)
|
||||
- 즉, 기존 사용 패턴을 유지하는 회사에는 아무런 차이가 없음
|
||||
|
||||
### 회사별 독립 제어가 필요한 경우
|
||||
|
||||
만약 특정 회사는 층을 필수로 유지하고, 다른 회사는 선택으로 해야 하는 상황이 발생하면, 방법 A(설정 기능 추가)로 업그레이드가 필요합니다. 이번 방법 B의 변경은 향후 방법 A로 전환할 때 충돌 없이 확장 가능합니다.
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- "창고 코드"와 "구역"의 필수 검증은 기존과 동일하게 유지
|
||||
- 층을 선택한 경우의 동작은 기존과 완전히 동일 (하위 호환)
|
||||
- 층 미선택 시 위치 코드에서 층 부분을 깔끔하게 생략 (폴백값 "1" 사용하지 않음)
|
||||
- 중복 체크는 가용한 필드 기준으로 수행 (floor 없으면 warehouse_code + zone 기준)
|
||||
- DB에는 NULL로 저장하여 "미입력"을 정확하게 표현 (프로젝트 표준 패턴)
|
||||
- 특수 문자열("상관없음" 등) 사용하지 않음 (프로젝트 관행에 맞지 않으므로)
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
# [맥락노트] 렉 구조 등록 - 층(floor) 필수 입력 해제
|
||||
|
||||
> 관련 문서: [계획서](./RFO[계획]-렉구조-층필수해제.md) | [체크리스트](./RFO[체크]-렉구조-층필수해제.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 탑씰 회사에서 창고 렉 구조 등록 시 "층"을 선택하지 않아도 되게 해달라는 요청
|
||||
- 현재 코드에 창고 코드 / 층 / 구역 3개가 필수로 하드코딩되어 있어, 층 미선택 시 미리보기 생성과 저장이 모두 차단됨
|
||||
- 층 필수 검증이 6곳에 분산되어 있어 한 곳만 고치면 다른 곳에서 오류 발생
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. 방법 B(하드코딩 제거) 채택, 방법 A(설정 기능) 미채택
|
||||
|
||||
- **결정**: 코드에서 floor 필수 조건을 직접 제거
|
||||
- **근거**: 이 프로젝트의 다른 모달/컴포넌트들은 모두 코드에서 직접 "필수/선택"을 정해놓는 방식을 사용. 설정으로 필수 여부를 바꿀 수 있게 만든 패턴은 기존에 없음
|
||||
- **대안 검토**:
|
||||
- 방법 A(ConfigPanel에 requiredFields 설정 추가): 유연하지만 4파일 수정 + 프로젝트에 없던 새 패턴 도입 → 기각
|
||||
- "상관없음" 값 추가 후 null 변환: 프로젝트 어디에서도 magic value → null 변환 패턴을 쓰지 않음 → 기각
|
||||
- "상관없음" 값만 추가 (코드 무변경): DB에 "상관없음" 텍스트가 저장되어 데이터가 지저분함 → 기각
|
||||
- **향후**: 회사별 독립 제어가 필요해지면 방법 A로 확장 가능 (충돌 없음)
|
||||
|
||||
### 2. 전역 적용 (회사별 독립 설정 아님)
|
||||
|
||||
- **결정**: 렉 구조 컴포넌트를 사용하는 모든 회사에 동일 적용
|
||||
- **근거**: 방법 B는 코드 직접 수정이므로 회사별 분기 불가. 단, 기존처럼 층을 선택하면 완전히 동일하게 동작하므로 다른 회사에 실질적 영향 없음 (선택 안 해도 "되는" 것이지, 안 해야 "하는" 것이 아님)
|
||||
|
||||
### 3. floor 미선택 시 NULL 저장 (특수값 아님)
|
||||
|
||||
- **결정**: floor를 선택하지 않으면 DB에 `NULL` 저장
|
||||
- **근거**: 프로젝트 표준 패턴. `UserFormModal`의 `email: formData.email || null`, `EnhancedFormService`의 빈 문자열 → null 자동 변환 등과 동일한 방식
|
||||
- **대안 검토**: "상관없음" 저장 후 null 변환 → 프로젝트에서 미사용 패턴이므로 기각
|
||||
|
||||
### 4. 위치 코드에서 층 부분 생략 (폴백값 "1" 사용 안 함)
|
||||
|
||||
- **결정**: floor 없을 때 위치 코드에서 층 부분을 아예 빼버림
|
||||
- **근거**: 기존 코드는 `context?.floor || "1"`로 폴백하여 1층을 선택한 것처럼 위장됨. 이는 잘못된 데이터를 만들 수 있음
|
||||
- **결과**:
|
||||
- 층 있을 때: `WH001-1층A구역-01-1` (기존과 동일)
|
||||
- 층 없을 때: `WH001-A구역-01-1` (층 부분 없이 깔끔)
|
||||
|
||||
### 5. 중복 체크는 가용 필드 기준으로 수행
|
||||
|
||||
- **결정**: floor 없으면 `warehouse_code + zone`으로 중복 체크, floor 있으면 `warehouse_code + floor + zone`으로 중복 체크
|
||||
- **근거**: 기존 코드는 floor 없으면 중복 체크 전체를 건너뜀 → 중복 데이터 발생 위험. 가용 필드 기준으로 체크하면 floor 유무와 관계없이 안전
|
||||
|
||||
### 6. 렉 구조 화면 감지에서 floor 조건 제거
|
||||
|
||||
- **결정**: `buttonActions.ts`의 `isRackStructureScreen` 조건에서 `context.formData?.floor` 제거
|
||||
- **근거**: floor 없으면 렉 구조 화면으로 인식되지 않아 일반 단건 저장으로 빠짐 → 예기치 않은 동작. zone만으로 감지해야 floor 미선택 시에도 렉 구조 일괄 저장이 정상 동작
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 수정 대상 | `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | 필수 검증, 위치 코드 생성, 기존 데이터 조회 |
|
||||
| 수정 대상 | `frontend/lib/utils/buttonActions.ts` | 화면 감지, 중복 체크 |
|
||||
| 타입 정의 | `frontend/lib/registry/components/v2-rack-structure/types.ts` | RackStructureContext, FieldMapping 등 |
|
||||
| 설정 패널 | `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | 필드 매핑 설정 (이번에 수정 안 함) |
|
||||
| 저장 모달 | `frontend/components/screen/SaveModal.tsx` | 필수 검증 (DB NOT NULL 기반, 별도 확인 필요) |
|
||||
| 사전 확인 | DB `warehouse_location.floor` 컬럼 | NULL 허용 여부 확인, NOT NULL이면 ALTER TABLE 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### 수정 포인트 6곳 요약
|
||||
|
||||
| # | 파일 | 행 | 내용 | 수정 방향 |
|
||||
|---|------|-----|------|----------|
|
||||
| 1 | RackStructureComponent.tsx | 291~298 | missingFields에서 floor 체크 | floor 체크 제거 |
|
||||
| 2 | RackStructureComponent.tsx | 517~521 | 미리보기 생성 차단 | 1번 수정으로 자동 해결 |
|
||||
| 3 | RackStructureComponent.tsx | 497~513 | 위치 코드 생성 `floor \|\| "1"` | 폴백값 제거, 없으면 생략 |
|
||||
| 4 | RackStructureComponent.tsx | 378~432 | 기존 데이터 조회 조건 | floor 없어도 조회 가능하게 |
|
||||
| 5 | buttonActions.ts | 692~698 | 렉 구조 화면 감지 | floor 조건 제거 |
|
||||
| 6 | buttonActions.ts | 2085~2131 | 저장 전 중복 체크 | floor 조건부로 포함 |
|
||||
|
||||
### 프로젝트 표준 optional 필드 처리 패턴
|
||||
|
||||
```
|
||||
빈 값 → null 변환: value || null (UserFormModal)
|
||||
nullable 자동 변환: value === "" && isNullable === "Y" → null (EnhancedFormService)
|
||||
Select placeholder: "__none__" → "" 또는 undefined (여러 ConfigPanel)
|
||||
```
|
||||
|
||||
이번 변경은 위 패턴들과 일관성을 유지합니다.
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# [체크리스트] 렉 구조 등록 - 층(floor) 필수 입력 해제
|
||||
|
||||
> 관련 문서: [계획서](./RFO[계획]-렉구조-층필수해제.md) | [맥락노트](./RFO[맥락]-렉구조-층필수해제.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (완료)
|
||||
- 현재 단계: 전체 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 0단계: 사전 확인
|
||||
|
||||
- [x] DB `warehouse_location.floor` 컬럼 nullable 여부 확인 → 이미 NULL 허용 상태, 변경 불필요
|
||||
|
||||
### 1단계: RackStructureComponent.tsx 수정
|
||||
|
||||
- [x] `missingFields`에서 `if (!context.floor) missing.push("층")` 제거 (291~298행)
|
||||
- [x] `generateLocationCode`에서 `context?.floor || "1"` 폴백 제거, floor 없으면 위치 코드에서 생략 (497~513행)
|
||||
- [x] `loadExistingLocations`에서 floor 없어도 조회 가능하도록 조건 수정 (378~432행)
|
||||
- [x] `searchParams`에 floor를 조건부로 포함하도록 변경
|
||||
|
||||
### 2단계: buttonActions.ts 수정
|
||||
|
||||
- [x] `isRackStructureScreen` 조건에서 `context.formData?.floor` 제거 (692~698행)
|
||||
- [x] `handleRackStructureBatchSave` 중복 체크에서 floor를 조건부로 포함 (2085~2131행)
|
||||
|
||||
### 3단계: 검증
|
||||
|
||||
- [x] 층 선택 + 구역 선택: 기존과 동일하게 동작 확인
|
||||
- [x] 층 미선택 + 구역 선택: 경고 없이 미리보기 생성 가능 확인
|
||||
- [x] 층 미선택 시 위치 코드에 층 부분이 빠져있는지 확인
|
||||
- [x] 층 미선택 시 저장 정상 동작 확인
|
||||
- [x] 층 미선택 시 기존 데이터 중복 체크 정상 동작 확인
|
||||
- [x] 창고 코드 미입력 시 여전히 경고 표시되는지 확인
|
||||
- [x] 구역 미입력 시 여전히 경고 표시되는지 확인
|
||||
|
||||
### 4단계: 정리
|
||||
|
||||
- [x] 린트 에러 없음 확인 (기존 WARNING 1개만 존재, 이번 변경과 무관)
|
||||
- [x] 이 체크리스트 완료 표시 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-10 | 계획서, 맥락노트, 체크리스트 작성 완료 |
|
||||
| 2026-03-10 | 1단계 코드 수정 완료 (RackStructureComponent.tsx) |
|
||||
| 2026-03-10 | 2단계 코드 수정 완료 (buttonActions.ts) |
|
||||
| 2026-03-10 | 린트 에러 확인 완료 |
|
||||
| 2026-03-10 | 사용자 검증 완료, 전체 작업 완료 |
|
||||
|
|
@ -123,15 +123,49 @@
|
|||
- [ ] 비활성 탭: 캐시에서 복원
|
||||
- [ ] 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제
|
||||
|
||||
### 6-3. 캐시 키 관리 (clearTabStateCache)
|
||||
### 6-3. 캐시 키 관리 (clearTabCache)
|
||||
|
||||
탭 닫기/새로고침 시 관련 sessionStorage 키 일괄 제거:
|
||||
- `tab-cache-{screenId}-{menuObjid}`
|
||||
- `page-scroll-{screenId}-{menuObjid}`
|
||||
- `tsp-{screenId}-*`, `table-state-{screenId}-*`
|
||||
- `split-sel-{screenId}-*`, `catval-sel-{screenId}-*`
|
||||
- `bom-tree-{screenId}-*`
|
||||
- URL 탭: `tsp-{urlHash}-*`, `admin-scroll-{url}`
|
||||
- `tab-cache-{tabId}` (폼/스크롤 캐시)
|
||||
- `tableState_{tabId}_*` (컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터)
|
||||
- `pageSize_{tabId}_*` (표시갯수)
|
||||
- `filterSettings_{tabId}_*` (검색 필터 설정)
|
||||
- `groupSettings_{tabId}_*` (그룹 설정)
|
||||
|
||||
### 6-4. F5 새로고침 시 캐시 정책 (구현 완료)
|
||||
|
||||
| 탭 상태 | F5 시 동작 |
|
||||
|---------|-----------|
|
||||
| **활성 탭** | `clearTabCache(activeTabId)` → 캐시 전체 삭제 → fresh API 호출 |
|
||||
| **비활성 탭** | 캐시 유지 → 탭 전환 시 복원 |
|
||||
|
||||
**구현 방식**: `TabContent.tsx`에 모듈 레벨 플래그(`hasHandledPageLoad`)를 사용.
|
||||
전체 페이지 로드 시 모듈이 재실행되어 플래그가 `false`로 리셋.
|
||||
SPA 내비게이션에서는 모듈이 유지되므로 `true`로 남아 중복 실행 방지.
|
||||
|
||||
### 6-5. 탭 바 새로고침 버튼 (구현 완료)
|
||||
|
||||
`tabStore.refreshTab(tabId)` 호출 시:
|
||||
1. `clearTabCache(tabId)` → 해당 탭의 모든 sessionStorage 캐시 삭제
|
||||
2. `refreshKey` 증가 → 컴포넌트 리마운트 → 기본값으로 초기화
|
||||
|
||||
### 6-6. 저장소 분류 기준 (구현 완료)
|
||||
|
||||
| 데이터 성격 | 저장소 | 키 구조 | 비고 |
|
||||
|------------|--------|---------|------|
|
||||
| 탭별 캐시 | sessionStorage | `{prefix}_{tabId}_{tableName}` | 탭 닫으면 소멸 |
|
||||
| 사용자 설정 | localStorage | `{prefix}_{tableName}_{userId}` | 세션 간 보존 |
|
||||
|
||||
**탭별 캐시 (sessionStorage)**:
|
||||
- tableState: 컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터
|
||||
- pageSize: 표시갯수
|
||||
- filterSettings: 검색 필터 설정
|
||||
- groupSettings: 그룹 설정
|
||||
|
||||
**사용자 설정 (localStorage)**:
|
||||
- table_column_visibility: 컬럼 표시/숨김
|
||||
- table_sort_state: 정렬 상태
|
||||
- table_column_order: 컬럼 순서
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ const RESOURCE_TYPE_CONFIG: Record<
|
|||
SCREEN_LAYOUT: { label: "레이아웃", icon: Monitor, color: "bg-purple-100 text-purple-700" },
|
||||
FLOW: { label: "플로우", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" },
|
||||
FLOW_STEP: { label: "플로우 스텝", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" },
|
||||
NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" },
|
||||
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
|
||||
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||
PERMISSION: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||
|
|
|
|||
|
|
@ -1,24 +1,7 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 제어 시스템 페이지 (리다이렉트)
|
||||
* 이 페이지는 /admin/dataflow로 리다이렉트됩니다.
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import DataFlowPage from "../page";
|
||||
|
||||
export default function NodeEditorPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// /admin/dataflow 메인 페이지로 리다이렉트
|
||||
router.replace("/admin/systemMng/dataflow");
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-muted">
|
||||
<div className="text-muted-foreground">제어 관리 페이지로 이동중...</div>
|
||||
</div>
|
||||
);
|
||||
return <DataFlowPage />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -458,6 +458,14 @@ select {
|
|||
border-color: hsl(var(--destructive)) !important;
|
||||
}
|
||||
|
||||
|
||||
/* 채번 세그먼트 포커스 스타일 (shadcn Input과 동일한 3단 구조) */
|
||||
.numbering-segment:focus-within {
|
||||
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.5);
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 필수 입력 경고 문구 (입력 필드 아래, 레이아웃 영향 없음) */
|
||||
.validation-error-msg-wrapper {
|
||||
height: 0;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
const LoadingFallback = () => (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
@ -10,11 +12,52 @@ const LoadingFallback = () => (
|
|||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리.
|
||||
* 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다.
|
||||
* 매핑되지 않은 URL은 catch-all fallback으로 처리된다.
|
||||
*/
|
||||
function ScreenCodeResolver({ screenCode }: { screenCode: string }) {
|
||||
const [screenId, setScreenId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const numericId = parseInt(screenCode);
|
||||
if (!isNaN(numericId)) {
|
||||
setScreenId(numericId);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const resolve = async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/screen-management/screens", {
|
||||
params: { searchTerm: screenCode, size: 50 },
|
||||
});
|
||||
const items = res.data?.data?.data || res.data?.data || [];
|
||||
const arr = Array.isArray(items) ? items : [];
|
||||
const exact = arr.find((s: any) => s.screenCode === screenCode);
|
||||
const target = exact || arr[0];
|
||||
if (target) setScreenId(target.screenId || target.screen_id);
|
||||
} catch {
|
||||
console.error("스크린 코드 변환 실패:", screenCode);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
resolve();
|
||||
}, [screenCode]);
|
||||
|
||||
if (loading) return <LoadingFallback />;
|
||||
if (!screenId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">화면을 찾을 수 없습니다 (코드: {screenCode})</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <ScreenViewPageWrapper screenIdProp={screenId} />;
|
||||
}
|
||||
|
||||
const DashboardViewPage = dynamic(
|
||||
() => import("@/app/(main)/dashboard/[dashboardId]/page"),
|
||||
{ ssr: false, loading: LoadingFallback },
|
||||
);
|
||||
|
||||
const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
// 관리자 메인
|
||||
"/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
|
@ -39,7 +82,9 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
|||
"/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/systemMng/cascading-managementList": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/systemMng/dataflow/node-editorList": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 자동화 관리
|
||||
"/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
|
@ -62,29 +107,163 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
|||
"/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 결재 관리
|
||||
"/admin/approvalTemplate": dynamic(() => import("@/app/(main)/admin/approvalTemplate/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/approvalBox": dynamic(() => import("@/app/(main)/admin/approvalBox/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/approvalMng": dynamic(() => import("@/app/(main)/admin/approvalMng/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 시스템
|
||||
"/admin/audit-log": dynamic(() => import("@/app/(main)/admin/audit-log/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/system-notices": dynamic(() => import("@/app/(main)/admin/system-notices/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/aiAssistant": dynamic(() => import("@/app/(main)/admin/aiAssistant/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 기타
|
||||
"/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
};
|
||||
|
||||
// 매핑되지 않은 URL용 Fallback
|
||||
const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
||||
"/admin/aiAssistant/dashboard": () => import("@/app/(main)/admin/aiAssistant/dashboard/page"),
|
||||
"/admin/aiAssistant/history": () => import("@/app/(main)/admin/aiAssistant/history/page"),
|
||||
"/admin/aiAssistant/api-keys": () => import("@/app/(main)/admin/aiAssistant/api-keys/page"),
|
||||
"/admin/aiAssistant/api-test": () => import("@/app/(main)/admin/aiAssistant/api-test/page"),
|
||||
"/admin/aiAssistant/usage": () => import("@/app/(main)/admin/aiAssistant/usage/page"),
|
||||
"/admin/aiAssistant/chat": () => import("@/app/(main)/admin/aiAssistant/chat/page"),
|
||||
"/admin/screenMng/barcodeList": () => import("@/app/(main)/admin/screenMng/barcodeList/page"),
|
||||
"/admin/automaticMng/batchmngList/create": () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"),
|
||||
"/admin/systemMng/dataflow/node-editorList": () => import("@/app/(main)/admin/systemMng/dataflow/page"),
|
||||
"/admin/standards/new": () => import("@/app/(main)/admin/standards/new/page"),
|
||||
};
|
||||
|
||||
const DYNAMIC_ADMIN_PATTERNS: Array<{
|
||||
pattern: RegExp;
|
||||
getImport: (match: RegExpMatchArray) => Promise<any>;
|
||||
extractParams: (match: RegExpMatchArray) => Record<string, string>;
|
||||
}> = [
|
||||
{
|
||||
pattern: /^\/admin\/userMng\/rolesList\/([^/]+)$/,
|
||||
getImport: () => import("@/app/(main)/admin/userMng/rolesList/[id]/page"),
|
||||
extractParams: (m) => ({ id: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/screenMng\/dashboardList\/([^/]+)$/,
|
||||
getImport: () => import("@/app/(main)/admin/screenMng/dashboardList/[id]/page"),
|
||||
extractParams: (m) => ({ id: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/automaticMng\/flowMgmtList\/([^/]+)$/,
|
||||
getImport: () => import("@/app/(main)/admin/automaticMng/flowMgmtList/[id]/page"),
|
||||
extractParams: (m) => ({ id: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/automaticMng\/batchmngList\/edit\/([^/]+)$/,
|
||||
getImport: () => import("@/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page"),
|
||||
extractParams: (m) => ({ id: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/screenMng\/barcodeList\/designer\/([^/]+)$/,
|
||||
getImport: () => import("@/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page"),
|
||||
extractParams: (m) => ({ labelId: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/screenMng\/reportList\/designer\/([^/]+)$/,
|
||||
getImport: () => import("@/app/(main)/admin/screenMng/reportList/designer/[reportId]/page"),
|
||||
extractParams: (m) => ({ reportId: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/systemMng\/dataflow\/edit\/([^/]+)$/,
|
||||
getImport: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"),
|
||||
extractParams: (m) => ({ diagramId: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/,
|
||||
getImport: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"),
|
||||
extractParams: (m) => ({ companyCode: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/standards\/([^/]+)\/edit$/,
|
||||
getImport: () => import("@/app/(main)/admin/standards/[webType]/edit/page"),
|
||||
extractParams: (m) => ({ webType: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/standards\/([^/]+)$/,
|
||||
getImport: () => import("@/app/(main)/admin/standards/[webType]/page"),
|
||||
extractParams: (m) => ({ webType: m[1] }),
|
||||
},
|
||||
];
|
||||
|
||||
function DynamicAdminLoader({ url, params }: { url: string; params?: Record<string, string> }) {
|
||||
const [Component, setComponent] = useState<React.ComponentType<any> | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const tryLoad = async () => {
|
||||
// 1) 정적 import 목록
|
||||
const staticImport = DYNAMIC_ADMIN_IMPORTS[url];
|
||||
if (staticImport) {
|
||||
try {
|
||||
const mod = await staticImport();
|
||||
if (!cancelled) setComponent(() => mod.default);
|
||||
} catch {
|
||||
if (!cancelled) setFailed(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) 동적 라우트 패턴 매칭
|
||||
for (const { pattern, getImport } of DYNAMIC_ADMIN_PATTERNS) {
|
||||
const match = url.match(pattern);
|
||||
if (match) {
|
||||
try {
|
||||
const mod = await getImport();
|
||||
if (!cancelled) setComponent(() => mod.default);
|
||||
} catch {
|
||||
if (!cancelled) setFailed(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) URL 경로 기반 자동 import 시도
|
||||
const pagePath = url.replace(/^\//, "");
|
||||
try {
|
||||
const mod = await import(
|
||||
/* webpackMode: "lazy" */
|
||||
/* webpackInclude: /\/page\.tsx$/ */
|
||||
`@/app/(main)/${pagePath}/page`
|
||||
);
|
||||
if (!cancelled) setComponent(() => mod.default);
|
||||
} catch {
|
||||
console.warn("[DynamicAdminLoader] 자동 import 실패:", url);
|
||||
if (!cancelled) setFailed(true);
|
||||
}
|
||||
};
|
||||
|
||||
tryLoad();
|
||||
return () => { cancelled = true; };
|
||||
}, [url]);
|
||||
|
||||
if (failed) return <AdminPageFallback url={url} />;
|
||||
if (!Component) return <LoadingFallback />;
|
||||
if (params) return <Component params={Promise.resolve(params)} />;
|
||||
return <Component />;
|
||||
}
|
||||
|
||||
function AdminPageFallback({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-foreground">페이지 로딩 불가</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
경로: {url}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
AdminPageRenderer 레지스트리에 이 URL을 추가해주세요.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">경로: {url}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">해당 페이지가 존재하지 않습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -95,15 +274,53 @@ interface AdminPageRendererProps {
|
|||
}
|
||||
|
||||
export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||
const PageComponent = useMemo(() => {
|
||||
// URL에서 쿼리스트링/해시 제거 후 매칭
|
||||
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
|
||||
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
|
||||
}, [url]);
|
||||
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
|
||||
|
||||
if (!PageComponent) {
|
||||
return <AdminPageFallback url={url} />;
|
||||
console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl });
|
||||
|
||||
// 화면 할당: /screens/[id]
|
||||
const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/);
|
||||
if (screensIdMatch) {
|
||||
console.log("[AdminPageRenderer] → /screens/[id] 매칭:", screensIdMatch[1]);
|
||||
return <ScreenViewPageWrapper screenIdProp={parseInt(screensIdMatch[1])} />;
|
||||
}
|
||||
|
||||
return <PageComponent />;
|
||||
// 화면 할당: /screen/[code] (구 형식)
|
||||
const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/);
|
||||
if (screenCodeMatch) {
|
||||
console.log("[AdminPageRenderer] → /screen/[code] 매칭:", screenCodeMatch[1]);
|
||||
return <ScreenCodeResolver screenCode={screenCodeMatch[1]} />;
|
||||
}
|
||||
|
||||
// 대시보드 할당: /dashboard/[id]
|
||||
const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/);
|
||||
if (dashboardMatch) {
|
||||
console.log("[AdminPageRenderer] → /dashboard/[id] 매칭:", dashboardMatch[1]);
|
||||
return <DashboardViewPage params={Promise.resolve({ dashboardId: dashboardMatch[1] })} />;
|
||||
}
|
||||
|
||||
// URL 직접 입력: 레지스트리 매칭
|
||||
const PageComponent = useMemo(() => {
|
||||
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
|
||||
}, [cleanUrl]);
|
||||
|
||||
if (PageComponent) {
|
||||
console.log("[AdminPageRenderer] → 레지스트리 매칭:", cleanUrl);
|
||||
return <PageComponent />;
|
||||
}
|
||||
|
||||
// 레지스트리에 없으면 동적 import 시도
|
||||
// 동적 라우트 패턴 매칭 (params 추출)
|
||||
for (const { pattern, extractParams } of DYNAMIC_ADMIN_PATTERNS) {
|
||||
const match = cleanUrl.match(pattern);
|
||||
if (match) {
|
||||
const params = extractParams(match);
|
||||
console.log("[AdminPageRenderer] → 동적 라우트 매칭:", cleanUrl, params);
|
||||
return <DynamicAdminLoader url={cleanUrl} params={params} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 레지스트리/패턴에 없으면 DynamicAdminLoader가 자동 import 시도
|
||||
console.log("[AdminPageRenderer] → 자동 import 시도:", cleanUrl);
|
||||
return <DynamicAdminLoader url={cleanUrl} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,12 +202,26 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
|
|||
|
||||
const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle);
|
||||
|
||||
const menuUrl = menu.menu_url || menu.MENU_URL || "#";
|
||||
const screenCode = menu.screen_code || menu.SCREEN_CODE || null;
|
||||
const menuType = String(menu.menu_type ?? menu.MENU_TYPE ?? "");
|
||||
|
||||
let screenId: number | null = null;
|
||||
const screensMatch = menuUrl.match(/^\/screens\/(\d+)/);
|
||||
if (screensMatch) {
|
||||
screenId = parseInt(screensMatch[1]);
|
||||
}
|
||||
|
||||
return {
|
||||
id: menuId,
|
||||
objid: menuId,
|
||||
name: displayName,
|
||||
tabTitle,
|
||||
icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON),
|
||||
url: menu.menu_url || menu.MENU_URL || "#",
|
||||
url: menuUrl,
|
||||
screenCode,
|
||||
screenId,
|
||||
menuType,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
hasChildren: children.length > 0,
|
||||
};
|
||||
|
|
@ -341,42 +355,76 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
const handleMenuClick = async (menu: any) => {
|
||||
if (menu.hasChildren) {
|
||||
toggleMenu(menu.id);
|
||||
} else {
|
||||
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("currentMenuName", menuName);
|
||||
return;
|
||||
}
|
||||
|
||||
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("currentMenuName", menuName);
|
||||
}
|
||||
|
||||
const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0");
|
||||
const isAdminMenu = menu.menuType === "0";
|
||||
|
||||
console.log("[handleMenuClick] 메뉴 클릭:", {
|
||||
menuName,
|
||||
menuObjid,
|
||||
menuType: menu.menuType,
|
||||
isAdminMenu,
|
||||
screenId: menu.screenId,
|
||||
screenCode: menu.screenCode,
|
||||
url: menu.url,
|
||||
fullMenu: menu,
|
||||
});
|
||||
|
||||
// 관리자 메뉴 (menu_type = 0): URL 직접 입력 → admin 탭
|
||||
if (isAdminMenu) {
|
||||
if (menu.url && menu.url !== "#") {
|
||||
console.log("[handleMenuClick] → admin 탭:", menu.url);
|
||||
openTab({ type: "admin", title: menuName, adminUrl: menu.url });
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
} else {
|
||||
toast.warning("이 메뉴에는 연결된 페이지가 없습니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 메뉴 (menu_type = 1, 2): 화면/대시보드 할당
|
||||
// 1) screenId가 메뉴 URL에서 추출된 경우 바로 screen 탭
|
||||
if (menu.screenId) {
|
||||
console.log("[handleMenuClick] → screen 탭 (URL에서 screenId 추출):", menu.screenId);
|
||||
openTab({ type: "screen", title: menuName, screenId: menu.screenId, menuObjid });
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) screen_menu_assignments 테이블 조회
|
||||
if (menuObjid) {
|
||||
try {
|
||||
const menuObjid = menu.objid || menu.id;
|
||||
console.log("[handleMenuClick] → screen_menu_assignments 조회 시도, menuObjid:", menuObjid);
|
||||
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
|
||||
|
||||
console.log("[handleMenuClick] → 조회 결과:", assignedScreens);
|
||||
if (assignedScreens.length > 0) {
|
||||
const firstScreen = assignedScreens[0];
|
||||
openTab({
|
||||
type: "screen",
|
||||
title: menuName,
|
||||
screenId: firstScreen.screenId,
|
||||
menuObjid: parseInt(menuObjid),
|
||||
});
|
||||
console.log("[handleMenuClick] → screen 탭 (assignments):", assignedScreens[0].screenId);
|
||||
openTab({ type: "screen", title: menuName, screenId: assignedScreens[0].screenId, menuObjid });
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.warn("할당된 화면 조회 실패");
|
||||
}
|
||||
|
||||
if (menu.url && menu.url !== "#") {
|
||||
openTab({
|
||||
type: "admin",
|
||||
title: menuName,
|
||||
adminUrl: menu.url,
|
||||
});
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
} else {
|
||||
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
|
||||
} catch (err) {
|
||||
console.error("[handleMenuClick] 할당된 화면 조회 실패:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 대시보드 할당 (/dashboard/xxx) → admin 탭으로 렌더링 (AdminPageRenderer가 처리)
|
||||
if (menu.url && menu.url.startsWith("/dashboard/")) {
|
||||
console.log("[handleMenuClick] → 대시보드 탭:", menu.url);
|
||||
openTab({ type: "admin", title: menuName, adminUrl: menu.url });
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn("[handleMenuClick] 어떤 조건에도 매칭 안 됨:", { menuName, menuType: menu.menuType, url: menu.url, screenId: menu.screenId });
|
||||
toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요.");
|
||||
};
|
||||
|
||||
const handleModeSwitch = () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -226,6 +238,14 @@ function TabPageRenderer({
|
|||
tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string };
|
||||
refreshKey: number;
|
||||
}) {
|
||||
console.log("[TabPageRenderer] 탭 렌더링:", {
|
||||
tabId: tab.id,
|
||||
type: tab.type,
|
||||
screenId: tab.screenId,
|
||||
adminUrl: tab.adminUrl,
|
||||
menuObjid: tab.menuObjid,
|
||||
});
|
||||
|
||||
if (tab.type === "screen" && tab.screenId != null) {
|
||||
return (
|
||||
<ScreenViewPageWrapper
|
||||
|
|
@ -244,5 +264,6 @@ function TabPageRenderer({
|
|||
);
|
||||
}
|
||||
|
||||
console.warn("[TabPageRenderer] 렌더링 불가 - 매칭 조건 없음:", tab);
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -605,6 +605,23 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
}
|
||||
}, [relatedButtonFilter]);
|
||||
|
||||
// TableOptionsContext 필터 변경 시 데이터 재조회 (TableSearchWidget 연동)
|
||||
const filtersAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
// 초기 렌더 시 빈 배열은 무시 (불필요한 재조회 방지)
|
||||
if (!filtersAppliedRef.current && filters.length === 0) return;
|
||||
filtersAppliedRef.current = true;
|
||||
|
||||
const filterSearchParams: Record<string, any> = {};
|
||||
filters.forEach((f) => {
|
||||
if (f.value !== "" && f.value !== undefined && f.value !== null) {
|
||||
filterSearchParams[f.columnName] = f.value;
|
||||
}
|
||||
});
|
||||
loadData(1, { ...searchValues, ...filterSearchParams });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters]);
|
||||
|
||||
// 카테고리 타입 컬럼의 값 매핑 로드
|
||||
useEffect(() => {
|
||||
const loadCategoryMappings = async () => {
|
||||
|
|
|
|||
|
|
@ -541,8 +541,31 @@ export function DataFilterConfigPanel({
|
|||
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
|
||||
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
|
||||
<Select
|
||||
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
|
||||
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
|
||||
value={
|
||||
filter.operator === "in" || filter.operator === "not_in"
|
||||
? Array.isArray(filter.value) && filter.value.length > 0
|
||||
? filter.value[0]
|
||||
: ""
|
||||
: Array.isArray(filter.value)
|
||||
? filter.value[0]
|
||||
: filter.value
|
||||
}
|
||||
onValueChange={(selectedValue) => {
|
||||
if (filter.operator === "in" || filter.operator === "not_in") {
|
||||
const currentValues = Array.isArray(filter.value) ? filter.value : [];
|
||||
if (currentValues.includes(selectedValue)) {
|
||||
handleFilterChange(
|
||||
filter.id,
|
||||
"value",
|
||||
currentValues.filter((v) => v !== selectedValue),
|
||||
);
|
||||
} else {
|
||||
handleFilterChange(filter.id, "value", [...currentValues, selectedValue]);
|
||||
}
|
||||
} else {
|
||||
handleFilterChange(filter.id, "value", selectedValue);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue
|
||||
|
|
|
|||
|
|
@ -109,9 +109,8 @@ export const TableOptionsToolbar: React.FC = () => {
|
|||
onOpenChange={setColumnPanelOpen}
|
||||
/>
|
||||
<FilterPanel
|
||||
tableId={selectedTableId}
|
||||
open={filterPanelOpen}
|
||||
onOpenChange={setFilterPanelOpen}
|
||||
isOpen={filterPanelOpen}
|
||||
onClose={() => setFilterPanelOpen(false)}
|
||||
/>
|
||||
<GroupingPanel
|
||||
tableId={selectedTableId}
|
||||
|
|
|
|||
|
|
@ -288,6 +288,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [parentValue, setParentValue] = useState<CategoryValue | null>(null);
|
||||
const [continuousAdd, setContinuousAdd] = useState(false);
|
||||
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
|
||||
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
|
||||
|
||||
|
|
@ -512,21 +513,24 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
const response = await createCategoryValue(input);
|
||||
if (response.success) {
|
||||
toast.success("카테고리가 추가되었습니다");
|
||||
// 폼 초기화 (모달은 닫지 않고 연속 입력)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
valueCode: "",
|
||||
valueLabel: "",
|
||||
description: "",
|
||||
color: "",
|
||||
}));
|
||||
setTimeout(() => addNameRef.current?.focus(), 50);
|
||||
// 기존 펼침 상태 유지하면서 데이터 새로고침
|
||||
await loadTree(true);
|
||||
// 부모 노드만 펼치기 (하위 추가 시)
|
||||
if (parentValue) {
|
||||
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
|
||||
}
|
||||
|
||||
if (continuousAdd) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
valueCode: "",
|
||||
valueLabel: "",
|
||||
description: "",
|
||||
color: "",
|
||||
}));
|
||||
setTimeout(() => addNameRef.current?.focus(), 50);
|
||||
} else {
|
||||
setFormData({ valueCode: "", valueLabel: "", description: "", color: "", isActive: true });
|
||||
setIsAddModalOpen(false);
|
||||
}
|
||||
} else {
|
||||
toast.error(response.error || "추가 실패");
|
||||
}
|
||||
|
|
@ -818,6 +822,19 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
추가
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<div className="border-t px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="tree-continuous-add"
|
||||
checked={continuousAdd}
|
||||
onCheckedChange={(checked) => setContinuousAdd(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="tree-continuous-add" className="cursor-pointer text-sm font-normal select-none">
|
||||
저장 후 계속 입력 (연속 등록 모드)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
|
|
|||
|
|
@ -629,7 +629,7 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>((pro
|
|||
): SelectOption[] => {
|
||||
const result: SelectOption[] = [];
|
||||
for (const item of items) {
|
||||
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
|
||||
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
|
||||
result.push({
|
||||
value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치)
|
||||
label: prefix + item.valueLabel,
|
||||
|
|
|
|||
|
|
@ -909,10 +909,10 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center rounded-md border">
|
||||
<div className="numbering-segment border-input flex h-full items-center rounded-md border outline-none transition-[color,box-shadow]">
|
||||
{/* 고정 접두어 */}
|
||||
{templatePrefix && (
|
||||
<span className="text-muted-foreground bg-muted flex h-full items-center px-2 text-sm">
|
||||
<span className="text-muted-foreground bg-muted flex h-full items-center rounded-l-[5px] px-2 text-sm">
|
||||
{templatePrefix}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -945,13 +945,13 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
}
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none"
|
||||
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm ring-0"
|
||||
disabled={disabled || isGeneratingNumbering}
|
||||
style={inputTextStyle}
|
||||
style={{ ...inputTextStyle, outline: 'none' }}
|
||||
/>
|
||||
{/* 고정 접미어 */}
|
||||
{templateSuffix && (
|
||||
<span className="text-muted-foreground bg-muted flex h-full items-center px-2 text-sm">
|
||||
<span className="text-muted-foreground bg-muted flex h-full items-center rounded-r-[5px] px-2 text-sm">
|
||||
{templateSuffix}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -901,7 +901,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
|
|||
): SelectOption[] => {
|
||||
const result: SelectOption[] = [];
|
||||
for (const item of items) {
|
||||
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
|
||||
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
|
||||
result.push({
|
||||
value: item.valueCode, // 🔧 valueCode를 value로 사용
|
||||
label: prefix + item.valueLabel,
|
||||
|
|
|
|||
|
|
@ -480,6 +480,72 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 데이터 바인딩 설정 */}
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="dataBindingEnabled"
|
||||
checked={!!config.dataBinding?.sourceComponentId}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
updateConfig("dataBinding", {
|
||||
sourceComponentId: config.dataBinding?.sourceComponentId || "",
|
||||
sourceColumn: config.dataBinding?.sourceColumn || "",
|
||||
});
|
||||
} else {
|
||||
updateConfig("dataBinding", undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="dataBindingEnabled" className="text-xs font-semibold">
|
||||
테이블 선택 데이터 바인딩
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{config.dataBinding && (
|
||||
<div className="space-y-2 rounded border p-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
v2-table-list에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">소스 컴포넌트 ID</Label>
|
||||
<Input
|
||||
value={config.dataBinding?.sourceComponentId || ""}
|
||||
onChange={(e) => {
|
||||
updateConfig("dataBinding", {
|
||||
...config.dataBinding,
|
||||
sourceComponentId: e.target.value,
|
||||
});
|
||||
}}
|
||||
placeholder="예: tbl_items"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
같은 화면 내 v2-table-list 컴포넌트의 ID
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">소스 컬럼명</Label>
|
||||
<Input
|
||||
value={config.dataBinding?.sourceColumn || ""}
|
||||
onChange={(e) => {
|
||||
updateConfig("dataBinding", {
|
||||
...config.dataBinding,
|
||||
sourceColumn: e.target.value,
|
||||
});
|
||||
}}
|
||||
placeholder="예: item_number"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
선택된 행에서 가져올 컬럼명
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -375,12 +375,15 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
|
||||
// Entity 조인 컬럼 토글 (추가/제거)
|
||||
const toggleEntityJoinColumn = useCallback(
|
||||
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => {
|
||||
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string, columnType?: string) => {
|
||||
const currentJoins = config.entityJoins || [];
|
||||
const existingJoinIdx = currentJoins.findIndex(
|
||||
(j) => j.sourceColumn === sourceColumn && j.referenceTable === joinTableName,
|
||||
);
|
||||
|
||||
let newEntityJoins = [...currentJoins];
|
||||
let newColumns = [...config.columns];
|
||||
|
||||
if (existingJoinIdx >= 0) {
|
||||
const existingJoin = currentJoins[existingJoinIdx];
|
||||
const existingColIdx = existingJoin.columns.findIndex((c) => c.referenceField === refColumnName);
|
||||
|
|
@ -388,34 +391,49 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
if (existingColIdx >= 0) {
|
||||
const updatedColumns = existingJoin.columns.filter((_, i) => i !== existingColIdx);
|
||||
if (updatedColumns.length === 0) {
|
||||
updateConfig({ entityJoins: currentJoins.filter((_, i) => i !== existingJoinIdx) });
|
||||
newEntityJoins = newEntityJoins.filter((_, i) => i !== existingJoinIdx);
|
||||
} else {
|
||||
const updated = [...currentJoins];
|
||||
updated[existingJoinIdx] = { ...existingJoin, columns: updatedColumns };
|
||||
updateConfig({ entityJoins: updated });
|
||||
newEntityJoins[existingJoinIdx] = { ...existingJoin, columns: updatedColumns };
|
||||
}
|
||||
// config.columns에서도 제거
|
||||
newColumns = newColumns.filter(c => !(c.key === displayField && c.isJoinColumn));
|
||||
} else {
|
||||
const updated = [...currentJoins];
|
||||
updated[existingJoinIdx] = {
|
||||
newEntityJoins[existingJoinIdx] = {
|
||||
...existingJoin,
|
||||
columns: [...existingJoin.columns, { referenceField: refColumnName, displayField }],
|
||||
};
|
||||
updateConfig({ entityJoins: updated });
|
||||
// config.columns에 추가
|
||||
newColumns.push({
|
||||
key: displayField,
|
||||
title: refColumnLabel,
|
||||
width: "auto",
|
||||
visible: true,
|
||||
editable: false,
|
||||
isJoinColumn: true,
|
||||
inputType: columnType || "text",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updateConfig({
|
||||
entityJoins: [
|
||||
...currentJoins,
|
||||
{
|
||||
sourceColumn,
|
||||
referenceTable: joinTableName,
|
||||
columns: [{ referenceField: refColumnName, displayField }],
|
||||
},
|
||||
],
|
||||
newEntityJoins.push({
|
||||
sourceColumn,
|
||||
referenceTable: joinTableName,
|
||||
columns: [{ referenceField: refColumnName, displayField }],
|
||||
});
|
||||
// config.columns에 추가
|
||||
newColumns.push({
|
||||
key: displayField,
|
||||
title: refColumnLabel,
|
||||
width: "auto",
|
||||
visible: true,
|
||||
editable: false,
|
||||
isJoinColumn: true,
|
||||
inputType: columnType || "text",
|
||||
});
|
||||
}
|
||||
|
||||
updateConfig({ entityJoins: newEntityJoins, columns: newColumns });
|
||||
},
|
||||
[config.entityJoins, updateConfig],
|
||||
[config.entityJoins, config.columns, updateConfig],
|
||||
);
|
||||
|
||||
// Entity 조인에 특정 컬럼이 설정되어 있는지 확인
|
||||
|
|
@ -604,9 +622,9 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
|
||||
// 컬럼 토글 (현재 테이블 컬럼 - 입력용)
|
||||
const toggleInputColumn = (column: ColumnOption) => {
|
||||
const existingIndex = config.columns.findIndex((c) => c.key === column.columnName);
|
||||
const existingIndex = config.columns.findIndex((c) => c.key === column.columnName && !c.isJoinColumn && !c.isSourceDisplay);
|
||||
if (existingIndex >= 0) {
|
||||
const newColumns = config.columns.filter((c) => c.key !== column.columnName);
|
||||
const newColumns = config.columns.filter((_, i) => i !== existingIndex);
|
||||
updateConfig({ columns: newColumns });
|
||||
} else {
|
||||
// 컬럼의 inputType과 detailSettings 정보 포함
|
||||
|
|
@ -651,7 +669,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
};
|
||||
|
||||
const isColumnAdded = (columnName: string) => {
|
||||
return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay);
|
||||
return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay && !c.isJoinColumn);
|
||||
};
|
||||
|
||||
const isSourceColumnSelected = (columnName: string) => {
|
||||
|
|
@ -761,10 +779,9 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="basic" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="basic" className="text-xs">기본</TabsTrigger>
|
||||
<TabsTrigger value="columns" className="text-xs">컬럼</TabsTrigger>
|
||||
<TabsTrigger value="entityJoin" className="text-xs">Entity 조인</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 기본 설정 탭 */}
|
||||
|
|
@ -1365,6 +1382,84 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 🆕 Entity 조인 컬럼 (표시용) ===== */}
|
||||
<div className="space-y-2 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4 text-primary" />
|
||||
<Label className="text-xs font-medium text-primary">Entity 조인 컬럼 (읽기전용)</Label>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
FK 컬럼을 기반으로 참조 테이블의 데이터를 자동으로 조회하여 표시합니다.
|
||||
</p>
|
||||
|
||||
{loadingEntityJoins ? (
|
||||
<p className="text-muted-foreground py-2 text-xs">로딩 중...</p>
|
||||
) : entityJoinData.joinTables.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-xs">
|
||||
{entityJoinTargetTable
|
||||
? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다`
|
||||
: "저장 테이블을 먼저 설정해주세요"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entityJoinData.joinTables.map((joinTable, tableIndex) => {
|
||||
const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || "";
|
||||
|
||||
return (
|
||||
<div key={tableIndex} className="space-y-1">
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-primary">
|
||||
<Link2 className="h-3 w-3" />
|
||||
<span>{joinTable.tableName}</span>
|
||||
<span className="text-muted-foreground">({sourceColumn})</span>
|
||||
</div>
|
||||
<div className="max-h-40 space-y-0.5 overflow-y-auto rounded-md border border-primary/20 bg-primary/10/30 p-2">
|
||||
{joinTable.availableColumns.map((column, colIndex) => {
|
||||
const isActive = isEntityJoinColumnActive(
|
||||
joinTable.tableName,
|
||||
sourceColumn,
|
||||
column.columnName,
|
||||
);
|
||||
const matchingCol = config.columns.find((c) => c.key === column.columnName && c.isJoinColumn);
|
||||
const displayField = matchingCol?.key || column.columnName;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={colIndex}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-primary/10/50",
|
||||
isActive && "bg-primary/10",
|
||||
)}
|
||||
onClick={() =>
|
||||
toggleEntityJoinColumn(
|
||||
joinTable.tableName,
|
||||
sourceColumn,
|
||||
column.columnName,
|
||||
column.columnLabel,
|
||||
displayField,
|
||||
column.inputType || column.dataType
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
className="pointer-events-none h-3.5 w-3.5"
|
||||
/>
|
||||
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
|
||||
<span className="truncate text-xs">{column.columnLabel}</span>
|
||||
<span className="ml-auto text-[10px] text-primary/80">
|
||||
{column.inputType || column.dataType}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 선택된 컬럼 상세 설정 - 🆕 모든 컬럼 통합, 순서 변경 가능 */}
|
||||
{config.columns.length > 0 && (
|
||||
<>
|
||||
|
|
@ -1381,7 +1476,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border p-2",
|
||||
col.isSourceDisplay ? "border-primary/20 bg-primary/10/50" : "border-border bg-muted/30",
|
||||
(col.isSourceDisplay || col.isJoinColumn) ? "border-primary/20 bg-primary/10/50" : "border-border bg-muted/30",
|
||||
col.hidden && "opacity-50",
|
||||
)}
|
||||
draggable
|
||||
|
|
@ -1403,7 +1498,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
<GripVertical className="text-muted-foreground h-3 w-3 cursor-grab flex-shrink-0" />
|
||||
|
||||
{/* 확장/축소 버튼 (입력 컬럼만) */}
|
||||
{!col.isSourceDisplay && (
|
||||
{(!col.isSourceDisplay && !col.isJoinColumn) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedColumn(expandedColumn === col.key ? null : col.key)}
|
||||
|
|
@ -1419,8 +1514,10 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
|
||||
{col.isSourceDisplay ? (
|
||||
<Link2 className="text-primary h-3 w-3 flex-shrink-0" title="소스 표시 (읽기 전용)" />
|
||||
) : col.isJoinColumn ? (
|
||||
<Link2 className="text-amber-500 h-3 w-3 flex-shrink-0" title="Entity 조인 (읽기 전용)" />
|
||||
) : (
|
||||
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<Input
|
||||
|
|
@ -1431,7 +1528,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
/>
|
||||
|
||||
{/* 히든 토글 (입력 컬럼만) */}
|
||||
{!col.isSourceDisplay && (
|
||||
{(!col.isSourceDisplay && !col.isJoinColumn) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateColumnProp(col.key, "hidden", !col.hidden)}
|
||||
|
|
@ -1446,12 +1543,12 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 자동입력 표시 아이콘 */}
|
||||
{!col.isSourceDisplay && col.autoFill?.type && col.autoFill.type !== "none" && (
|
||||
{(!col.isSourceDisplay && !col.isJoinColumn) && col.autoFill?.type && col.autoFill.type !== "none" && (
|
||||
<Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" />
|
||||
)}
|
||||
|
||||
{/* 편집 가능 토글 */}
|
||||
{!col.isSourceDisplay && (
|
||||
{(!col.isSourceDisplay && !col.isJoinColumn) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateColumnProp(col.key, "editable", !(col.editable ?? true))}
|
||||
|
|
@ -1474,6 +1571,13 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
onClick={() => {
|
||||
if (col.isSourceDisplay) {
|
||||
toggleSourceDisplayColumn({ columnName: col.key, displayName: col.title });
|
||||
} else if (col.isJoinColumn) {
|
||||
const newColumns = config.columns.filter(c => c.key !== col.key);
|
||||
const newEntityJoins = config.entityJoins?.map(join => ({
|
||||
...join,
|
||||
columns: join.columns.filter(c => c.displayField !== col.key)
|
||||
})).filter(join => join.columns.length > 0);
|
||||
updateConfig({ columns: newColumns, entityJoins: newEntityJoins });
|
||||
} else {
|
||||
toggleInputColumn({ columnName: col.key, displayName: col.title });
|
||||
}
|
||||
|
|
@ -1485,7 +1589,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 확장된 상세 설정 (입력 컬럼만) */}
|
||||
{!col.isSourceDisplay && expandedColumn === col.key && (
|
||||
{(!col.isSourceDisplay && !col.isJoinColumn) && expandedColumn === col.key && (
|
||||
<div className="ml-6 space-y-2 rounded-md border border-dashed border-input bg-muted p-2">
|
||||
{/* 자동 입력 설정 */}
|
||||
<div className="space-y-1">
|
||||
|
|
@ -1812,120 +1916,6 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Entity 조인 설정 탭 */}
|
||||
<TabsContent value="entityJoin" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Entity 조인 연결</h3>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
FK 컬럼을 기반으로 참조 테이블의 데이터를 자동으로 조회하여 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
{loadingEntityJoins ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">로딩 중...</p>
|
||||
) : entityJoinData.joinTables.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{entityJoinTargetTable
|
||||
? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다`
|
||||
: "저장 테이블을 먼저 설정해주세요"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entityJoinData.joinTables.map((joinTable, tableIndex) => {
|
||||
const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || "";
|
||||
|
||||
return (
|
||||
<div key={tableIndex} className="space-y-1">
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-primary">
|
||||
<Link2 className="h-3 w-3" />
|
||||
<span>{joinTable.tableName}</span>
|
||||
<span className="text-muted-foreground">({sourceColumn})</span>
|
||||
</div>
|
||||
<div className="max-h-40 space-y-0.5 overflow-y-auto rounded-md border border-primary/20 bg-primary/10/30 p-2">
|
||||
{joinTable.availableColumns.map((column, colIndex) => {
|
||||
const isActive = isEntityJoinColumnActive(
|
||||
joinTable.tableName,
|
||||
sourceColumn,
|
||||
column.columnName,
|
||||
);
|
||||
const matchingCol = config.columns.find((c) => c.key === column.columnName);
|
||||
const displayField = matchingCol?.key || column.columnName;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={colIndex}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-primary/10/50",
|
||||
isActive && "bg-primary/10",
|
||||
)}
|
||||
onClick={() =>
|
||||
toggleEntityJoinColumn(
|
||||
joinTable.tableName,
|
||||
sourceColumn,
|
||||
column.columnName,
|
||||
column.columnLabel,
|
||||
displayField,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
className="pointer-events-none h-3.5 w-3.5"
|
||||
/>
|
||||
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
|
||||
<span className="truncate text-xs">{column.columnLabel}</span>
|
||||
<span className="ml-auto text-[10px] text-primary/80">
|
||||
{column.inputType || column.dataType}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 현재 설정된 Entity 조인 목록 */}
|
||||
{config.entityJoins && config.entityJoins.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium">설정된 조인</h4>
|
||||
<div className="space-y-1">
|
||||
{config.entityJoins.map((join, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1 rounded border bg-muted/30 px-2 py-1 text-[10px]">
|
||||
<Database className="h-3 w-3 text-primary" />
|
||||
<span className="font-medium">{join.sourceColumn}</span>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{join.referenceTable}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({join.columns.map((c) => c.referenceField).join(", ")})
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateConfig({
|
||||
entityJoins: config.entityJoins!.filter((_, i) => i !== idx),
|
||||
});
|
||||
}}
|
||||
className="ml-auto h-4 w-4 p-0 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ export interface MenuItem {
|
|||
TRANSLATED_DESC?: string;
|
||||
menu_icon?: string;
|
||||
MENU_ICON?: string;
|
||||
screen_code?: string;
|
||||
SCREEN_CODE?: string;
|
||||
}
|
||||
|
||||
export interface MenuFormData {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,16 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) {
|
|||
return el instanceof HTMLSelectElement && el.hasAttribute("aria-hidden");
|
||||
}
|
||||
|
||||
// 복합 입력 필드(채번 세그먼트 등)의 시각적 테두리 컨테이너 탐지
|
||||
// input 자체에 border가 없고 부모가 border를 가진 경우 부모를 반환
|
||||
function findBorderContainer(input: TargetEl): HTMLElement {
|
||||
const parent = input.parentElement;
|
||||
if (parent && parent.classList.contains("border")) {
|
||||
return parent;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function isEmpty(input: TargetEl): boolean {
|
||||
if (input instanceof HTMLButtonElement) {
|
||||
// Radix Select: data-placeholder 속성이 자식 span에 있으면 미선택 상태
|
||||
|
|
@ -120,20 +130,24 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) {
|
|||
}
|
||||
|
||||
function markError(input: TargetEl) {
|
||||
input.setAttribute(ERROR_ATTR, "true");
|
||||
const container = findBorderContainer(input);
|
||||
container.setAttribute(ERROR_ATTR, "true");
|
||||
errorFields.add(input);
|
||||
showErrorMsg(input);
|
||||
}
|
||||
|
||||
function clearError(input: TargetEl) {
|
||||
input.removeAttribute(ERROR_ATTR);
|
||||
const container = findBorderContainer(input);
|
||||
container.removeAttribute(ERROR_ATTR);
|
||||
errorFields.delete(input);
|
||||
removeErrorMsg(input);
|
||||
}
|
||||
|
||||
// 빈 필수 필드 아래에 경고 문구 삽입 (레이아웃 영향 없는 zero-height wrapper)
|
||||
// 복합 입력(채번 세그먼트 등)은 border 컨테이너 바깥에 삽입
|
||||
function showErrorMsg(input: TargetEl) {
|
||||
if (input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return;
|
||||
const container = findBorderContainer(input);
|
||||
if (container.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = MSG_WRAPPER_CLASS;
|
||||
|
|
@ -142,17 +156,19 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) {
|
|||
msg.textContent = "필수 입력 항목입니다";
|
||||
wrapper.appendChild(msg);
|
||||
|
||||
input.insertAdjacentElement("afterend", wrapper);
|
||||
container.insertAdjacentElement("afterend", wrapper);
|
||||
}
|
||||
|
||||
function removeErrorMsg(input: TargetEl) {
|
||||
const wrapper = input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`);
|
||||
const container = findBorderContainer(input);
|
||||
const wrapper = container.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`);
|
||||
if (wrapper) wrapper.remove();
|
||||
}
|
||||
|
||||
function highlightField(input: TargetEl) {
|
||||
input.setAttribute(HIGHLIGHT_ATTR, "true");
|
||||
input.addEventListener("animationend", () => input.removeAttribute(HIGHLIGHT_ATTR), { once: true });
|
||||
const container = findBorderContainer(input);
|
||||
container.setAttribute(HIGHLIGHT_ATTR, "true");
|
||||
container.addEventListener("animationend", () => container.removeAttribute(HIGHLIGHT_ATTR), { once: true });
|
||||
|
||||
if (input instanceof HTMLButtonElement) {
|
||||
input.click();
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||
import { toast } from "sonner";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
import { getCategoryValues, getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps {
|
||||
// 추가 props
|
||||
|
|
@ -1354,6 +1354,40 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
loadCategoryLabels();
|
||||
}, [isDesignMode, config.leftPanel?.tableName, config.rightPanel?.tableName, config.leftPanel?.displayColumns, config.rightPanel?.displayColumns, config.rightPanel?.tabConfig?.tabSourceColumn, config.leftPanel?.tabConfig?.tabSourceColumn]);
|
||||
|
||||
// 데이터 로드 후 미해결 카테고리 코드를 batch API로 변환
|
||||
useEffect(() => {
|
||||
if (isDesignMode) return;
|
||||
const allData = [...leftData, ...rightData];
|
||||
if (allData.length === 0) return;
|
||||
|
||||
const unresolvedCodes = new Set<string>();
|
||||
const checkValue = (v: unknown) => {
|
||||
if (typeof v === "string" && (v.startsWith("CAT_") || v.startsWith("CATEGORY_"))) {
|
||||
if (!categoryLabelMap[v]) unresolvedCodes.add(v);
|
||||
}
|
||||
};
|
||||
for (const item of allData) {
|
||||
for (const val of Object.values(item)) {
|
||||
if (Array.isArray(val)) {
|
||||
val.forEach(checkValue);
|
||||
} else {
|
||||
checkValue(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolvedCodes.size === 0) return;
|
||||
|
||||
const resolveMissingLabels = async () => {
|
||||
const result = await getCategoryLabelsByCodes(Array.from(unresolvedCodes));
|
||||
if (result.success && result.data && Object.keys(result.data).length > 0) {
|
||||
setCategoryLabelMap((prev) => ({ ...prev, ...result.data }));
|
||||
}
|
||||
};
|
||||
|
||||
resolveMissingLabels();
|
||||
}, [isDesignMode, leftData, rightData, categoryLabelMap]);
|
||||
|
||||
// 컴포넌트 언마운트 시 DataProvider 해제
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,78 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2InputDefinition } from "./index";
|
||||
import { V2Input } from "@/components/v2/V2Input";
|
||||
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
|
||||
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
||||
|
||||
/**
|
||||
* dataBinding이 설정된 v2-input을 위한 wrapper
|
||||
* v2-table-list의 TABLE_DATA_CHANGE 이벤트를 구독하여
|
||||
* 선택된 행의 특정 컬럼 값을 자동으로 formData에 반영
|
||||
*/
|
||||
function DataBindingWrapper({
|
||||
dataBinding,
|
||||
columnName,
|
||||
onFormDataChange,
|
||||
isInteractive,
|
||||
children,
|
||||
}: {
|
||||
dataBinding: { sourceComponentId: string; sourceColumn: string };
|
||||
columnName: string;
|
||||
onFormDataChange?: (field: string, value: any) => void;
|
||||
isInteractive?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const lastBoundValueRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return;
|
||||
|
||||
console.log("[DataBinding] 구독 시작:", {
|
||||
sourceComponentId: dataBinding.sourceComponentId,
|
||||
sourceColumn: dataBinding.sourceColumn,
|
||||
targetColumn: columnName,
|
||||
isInteractive,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
});
|
||||
|
||||
const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_DATA_CHANGE, (payload: any) => {
|
||||
console.log("[DataBinding] TABLE_DATA_CHANGE 수신:", {
|
||||
payloadSource: payload.source,
|
||||
expectedSource: dataBinding.sourceComponentId,
|
||||
dataLength: payload.data?.length,
|
||||
match: payload.source === dataBinding.sourceComponentId,
|
||||
});
|
||||
|
||||
if (payload.source !== dataBinding.sourceComponentId) return;
|
||||
|
||||
const selectedData = payload.data;
|
||||
if (selectedData && selectedData.length > 0) {
|
||||
const value = selectedData[0][dataBinding.sourceColumn];
|
||||
console.log("[DataBinding] 바인딩 값:", { column: dataBinding.sourceColumn, value, columnName });
|
||||
if (value !== lastBoundValueRef.current) {
|
||||
lastBoundValueRef.current = value;
|
||||
if (onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, value ?? "");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (lastBoundValueRef.current !== null) {
|
||||
lastBoundValueRef.current = null;
|
||||
if (onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName, onFormDataChange, isInteractive]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2Input 렌더러
|
||||
|
|
@ -16,41 +84,37 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
|
|||
render(): React.ReactElement {
|
||||
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props;
|
||||
|
||||
// 컴포넌트 설정 추출
|
||||
const config = component.componentConfig || component.config || {};
|
||||
const columnName = component.columnName;
|
||||
const tableName = component.tableName || this.props.tableName;
|
||||
|
||||
// formData에서 현재 값 가져오기
|
||||
const currentValue = formData?.[columnName] ?? component.value ?? "";
|
||||
|
||||
// 값 변경 핸들러
|
||||
const handleChange = (value: any) => {
|
||||
console.log("🔄 [V2InputRenderer] handleChange 호출:", {
|
||||
columnName,
|
||||
value,
|
||||
isInteractive,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
});
|
||||
if (isInteractive && onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, value);
|
||||
} else {
|
||||
console.warn("⚠️ [V2InputRenderer] onFormDataChange 호출 스킵:", {
|
||||
isInteractive,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
columnName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 라벨: style.labelText 우선, 없으면 component.label 사용
|
||||
// 🔧 style.labelDisplay를 먼저 체크 (속성 패널에서 style 객체로 업데이트하므로)
|
||||
const style = component.style || {};
|
||||
const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay;
|
||||
// labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김)
|
||||
const effectiveLabel = labelDisplay === true ? style.labelText || component.label : undefined;
|
||||
|
||||
return (
|
||||
const dataBinding = config.dataBinding || (component as any).dataBinding || config.componentConfig?.dataBinding;
|
||||
|
||||
if (dataBinding || (config as any).dataBinding || (component as any).dataBinding) {
|
||||
console.log("[V2InputRenderer] dataBinding 탐색:", {
|
||||
componentId: component.id,
|
||||
columnName,
|
||||
configKeys: Object.keys(config),
|
||||
configDataBinding: config.dataBinding,
|
||||
componentDataBinding: (component as any).dataBinding,
|
||||
nestedDataBinding: config.componentConfig?.dataBinding,
|
||||
finalDataBinding: dataBinding,
|
||||
});
|
||||
}
|
||||
|
||||
const inputElement = (
|
||||
<V2Input
|
||||
id={component.id}
|
||||
value={currentValue}
|
||||
|
|
@ -77,10 +141,26 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
|
|||
{...restProps}
|
||||
label={effectiveLabel}
|
||||
required={component.required || isColumnRequiredByMeta(tableName, columnName)}
|
||||
readonly={config.readonly || component.readonly}
|
||||
readonly={config.readonly || component.readonly || !!dataBinding?.sourceComponentId}
|
||||
disabled={config.disabled || component.disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
// dataBinding이 있으면 wrapper로 감싸서 이벤트 구독
|
||||
if (dataBinding?.sourceComponentId && dataBinding?.sourceColumn) {
|
||||
return (
|
||||
<DataBindingWrapper
|
||||
dataBinding={dataBinding}
|
||||
columnName={columnName}
|
||||
onFormDataChange={onFormDataChange}
|
||||
isInteractive={isInteractive}
|
||||
>
|
||||
{inputElement}
|
||||
</DataBindingWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return inputElement;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { FormatSegment } from "./types";
|
||||
import { SEGMENT_TYPE_LABELS, buildFormattedString, SAMPLE_VALUES } from "./config";
|
||||
|
||||
// 개별 세그먼트 행
|
||||
interface SortableSegmentRowProps {
|
||||
segment: FormatSegment;
|
||||
index: number;
|
||||
onChange: (index: number, updates: Partial<FormatSegment>) => void;
|
||||
}
|
||||
|
||||
function SortableSegmentRow({ segment, index, onChange }: SortableSegmentRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: `${segment.type}-${index}` });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"grid grid-cols-[16px_56px_18px_1fr_1fr_1fr] items-center gap-1 rounded border bg-white px-2 py-1.5",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
|
||||
<span className="truncate text-xs font-medium">
|
||||
{SEGMENT_TYPE_LABELS[segment.type]}
|
||||
</span>
|
||||
|
||||
<Checkbox
|
||||
checked={segment.showLabel}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange(index, { showLabel: checked === true })
|
||||
}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={segment.label}
|
||||
onChange={(e) => onChange(index, { label: e.target.value })}
|
||||
placeholder=""
|
||||
className={cn(
|
||||
"h-6 px-1 text-xs",
|
||||
!segment.showLabel && "text-gray-400 line-through",
|
||||
)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={segment.separatorAfter}
|
||||
onChange={(e) => onChange(index, { separatorAfter: e.target.value })}
|
||||
placeholder=""
|
||||
className="h-6 px-1 text-center text-xs"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={5}
|
||||
value={segment.pad}
|
||||
onChange={(e) =>
|
||||
onChange(index, { pad: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
disabled={segment.type !== "row" && segment.type !== "level"}
|
||||
className={cn(
|
||||
"h-6 px-1 text-center text-xs",
|
||||
segment.type !== "row" && segment.type !== "level" && "bg-gray-100 opacity-50",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// FormatSegmentEditor 메인 컴포넌트
|
||||
interface FormatSegmentEditorProps {
|
||||
label: string;
|
||||
segments: FormatSegment[];
|
||||
onChange: (segments: FormatSegment[]) => void;
|
||||
sampleValues?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function FormatSegmentEditor({
|
||||
label,
|
||||
segments,
|
||||
onChange,
|
||||
sampleValues = SAMPLE_VALUES,
|
||||
}: FormatSegmentEditorProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const preview = useMemo(
|
||||
() => buildFormattedString(segments, sampleValues),
|
||||
[segments, sampleValues],
|
||||
);
|
||||
|
||||
const sortableIds = useMemo(
|
||||
() => segments.map((seg, i) => `${seg.type}-${i}`),
|
||||
[segments],
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = sortableIds.indexOf(active.id as string);
|
||||
const newIndex = sortableIds.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
onChange(arrayMove([...segments], oldIndex, newIndex));
|
||||
};
|
||||
|
||||
const handleSegmentChange = (index: number, updates: Partial<FormatSegment>) => {
|
||||
const updated = segments.map((seg, i) =>
|
||||
i === index ? { ...seg, ...updates } : seg,
|
||||
);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600">{label}</div>
|
||||
|
||||
<div className="grid grid-cols-[16px_56px_18px_1fr_1fr_1fr] items-center gap-1 px-2 text-[10px] text-gray-500">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span>라벨</span>
|
||||
<span className="text-center">구분</span>
|
||||
<span className="text-center">자릿수</span>
|
||||
</div>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1">
|
||||
{segments.map((segment, index) => (
|
||||
<SortableSegmentRow
|
||||
key={sortableIds[index]}
|
||||
segment={segment}
|
||||
index={index}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<div className="rounded bg-gray-50 px-2 py-1.5">
|
||||
<span className="text-[10px] text-gray-500">미리보기: </span>
|
||||
<span className="text-xs font-medium text-gray-800">
|
||||
{preview || "(빈 값)"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ import {
|
|||
GeneratedLocation,
|
||||
RackStructureContext,
|
||||
} from "./types";
|
||||
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils";
|
||||
import { defaultFormatConfig, buildFormattedString } from "./config";
|
||||
|
||||
// 기존 위치 데이터 타입
|
||||
interface ExistingLocation {
|
||||
|
|
@ -289,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]);
|
||||
|
|
@ -378,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;
|
||||
|
|
@ -388,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 조건으로 처리
|
||||
|
|
@ -494,27 +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 vars = {
|
||||
warehouse: context?.warehouseCode || "WH001",
|
||||
warehouseName: context?.warehouseName || "",
|
||||
floor: context?.floor || "1",
|
||||
const values: Record<string, string> = {
|
||||
warehouseCode: context?.warehouseCode || "WH001",
|
||||
floor: context?.floor || "",
|
||||
zone: context?.zone || "A",
|
||||
row,
|
||||
level,
|
||||
row: row.toString(),
|
||||
level: level.toString(),
|
||||
};
|
||||
|
||||
const codePattern = config.codePattern || DEFAULT_CODE_PATTERN;
|
||||
const namePattern = config.namePattern || DEFAULT_NAME_PATTERN;
|
||||
const code = buildFormattedString(formatConfig.codeSegments, values);
|
||||
const name = buildFormattedString(formatConfig.nameSegments, values);
|
||||
|
||||
return {
|
||||
code: applyLocationPattern(codePattern, vars),
|
||||
name: applyLocationPattern(namePattern, vars),
|
||||
};
|
||||
return { code, name };
|
||||
},
|
||||
[context, config.codePattern, config.namePattern],
|
||||
[context, formatConfig],
|
||||
);
|
||||
|
||||
// 미리보기 생성
|
||||
|
|
@ -875,7 +871,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="font-mono">{loc.location_code}</TableCell>
|
||||
<TableCell>{loc.location_name}</TableCell>
|
||||
<TableCell className="text-center">{loc.floor || context?.floor || "1"}</TableCell>
|
||||
<TableCell className="text-center">{loc.floor || context?.floor || "-"}</TableCell>
|
||||
<TableCell className="text-center">{loc.zone || context?.zone || "A"}</TableCell>
|
||||
<TableCell className="text-center">{loc.row_num.padStart(2, "0")}</TableCell>
|
||||
<TableCell className="text-center">{loc.level_num}</TableCell>
|
||||
|
|
|
|||
|
|
@ -11,48 +11,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RackStructureComponentConfig, FieldMapping } from "./types";
|
||||
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN, PATTERN_VARIABLES } from "./patternUtils";
|
||||
|
||||
// 패턴 미리보기 서브 컴포넌트
|
||||
const PatternPreview: React.FC<{
|
||||
codePattern?: string;
|
||||
namePattern?: string;
|
||||
}> = ({ codePattern, namePattern }) => {
|
||||
const sampleVars = {
|
||||
warehouse: "WH002",
|
||||
warehouseName: "2창고",
|
||||
floor: "2층",
|
||||
zone: "A구역",
|
||||
row: 1,
|
||||
level: 3,
|
||||
};
|
||||
|
||||
const previewCode = useMemo(
|
||||
() => applyLocationPattern(codePattern || DEFAULT_CODE_PATTERN, sampleVars),
|
||||
[codePattern],
|
||||
);
|
||||
const previewName = useMemo(
|
||||
() => applyLocationPattern(namePattern || DEFAULT_NAME_PATTERN, sampleVars),
|
||||
[namePattern],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-primary/20 bg-primary/5 p-2.5">
|
||||
<div className="mb-1.5 text-[10px] font-medium text-primary">미리보기 (2창고 / 2층 / A구역 / 1열 / 3단)</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-14 shrink-0 text-muted-foreground">위치코드:</span>
|
||||
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewCode}</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-14 shrink-0 text-muted-foreground">위치명:</span>
|
||||
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewName}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { RackStructureComponentConfig, FieldMapping, FormatSegment } from "./types";
|
||||
import { defaultFormatConfig, SAMPLE_VALUES } from "./config";
|
||||
import { FormatSegmentEditor } from "./FormatSegmentEditor";
|
||||
|
||||
interface RackStructureConfigPanelProps {
|
||||
config: RackStructureComponentConfig;
|
||||
|
|
@ -110,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">
|
||||
{/* 필드 매핑 섹션 */}
|
||||
|
|
@ -378,6 +354,29 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 포맷 설정 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-gray-700">포맷 설정</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고,
|
||||
구분자/라벨을 편집할 수 있습니다
|
||||
</p>
|
||||
|
||||
<FormatSegmentEditor
|
||||
label="위치코드 포맷"
|
||||
segments={formatConfig.codeSegments}
|
||||
onChange={(segs) => handleFormatChange("codeSegments", segs)}
|
||||
sampleValues={SAMPLE_VALUES}
|
||||
/>
|
||||
|
||||
<FormatSegmentEditor
|
||||
label="위치명 포맷"
|
||||
segments={formatConfig.nameSegments}
|
||||
onChange={(segs) => handleFormatChange("nameSegments", segs)}
|
||||
sampleValues={SAMPLE_VALUES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,26 +2,107 @@
|
|||
* 렉 구조 컴포넌트 기본 설정
|
||||
*/
|
||||
|
||||
import { RackStructureComponentConfig } from "./types";
|
||||
import {
|
||||
RackStructureComponentConfig,
|
||||
FormatSegment,
|
||||
FormatSegmentType,
|
||||
LocationFormatConfig,
|
||||
} from "./types";
|
||||
|
||||
// 세그먼트 타입별 한글 표시명
|
||||
export const SEGMENT_TYPE_LABELS: Record<FormatSegmentType, string> = {
|
||||
warehouseCode: "창고코드",
|
||||
floor: "층",
|
||||
zone: "구역",
|
||||
row: "열",
|
||||
level: "단",
|
||||
};
|
||||
|
||||
// 위치코드 기본 세그먼트 (현재 하드코딩과 동일한 결과)
|
||||
export const defaultCodeSegments: FormatSegment[] = [
|
||||
{ type: "warehouseCode", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 0 },
|
||||
{ type: "floor", enabled: true, showLabel: true, label: "층", separatorAfter: "", pad: 0 },
|
||||
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
|
||||
{ type: "row", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 2 },
|
||||
{ type: "level", enabled: true, showLabel: false, label: "", separatorAfter: "", pad: 0 },
|
||||
];
|
||||
|
||||
// 위치명 기본 세그먼트 (현재 하드코딩과 동일한 결과)
|
||||
export const defaultNameSegments: FormatSegment[] = [
|
||||
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
|
||||
{ type: "row", enabled: true, showLabel: true, label: "열", separatorAfter: "-", pad: 2 },
|
||||
{ type: "level", enabled: true, showLabel: true, label: "단", separatorAfter: "", pad: 0 },
|
||||
];
|
||||
|
||||
export const defaultFormatConfig: LocationFormatConfig = {
|
||||
codeSegments: defaultCodeSegments,
|
||||
nameSegments: defaultNameSegments,
|
||||
};
|
||||
|
||||
// 세그먼트 타입별 기본 한글 접미사 (context 값에 포함되어 있는 한글)
|
||||
const KNOWN_SUFFIXES: Partial<Record<FormatSegmentType, string>> = {
|
||||
floor: "층",
|
||||
zone: "구역",
|
||||
};
|
||||
|
||||
// 값에서 알려진 한글 접미사를 제거하여 순수 값만 추출
|
||||
function stripKnownSuffix(type: FormatSegmentType, val: string): string {
|
||||
const suffix = KNOWN_SUFFIXES[type];
|
||||
if (suffix && val.endsWith(suffix)) {
|
||||
return val.slice(0, -suffix.length);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
// 세그먼트 배열로 포맷된 문자열 생성
|
||||
export function buildFormattedString(
|
||||
segments: FormatSegment[],
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
const activeSegments = segments.filter(
|
||||
(seg) => seg.enabled && values[seg.type],
|
||||
);
|
||||
|
||||
return activeSegments
|
||||
.map((seg, idx) => {
|
||||
// 1) 원본 값에서 한글 접미사를 먼저 벗겨냄 ("A구역" → "A", "1층" → "1")
|
||||
let val = stripKnownSuffix(seg.type, values[seg.type]);
|
||||
|
||||
// 2) showLabel이 켜져 있고 label이 있으면 붙임
|
||||
if (seg.showLabel && seg.label) {
|
||||
val += seg.label;
|
||||
}
|
||||
|
||||
if (seg.pad > 0 && !isNaN(Number(val))) {
|
||||
val = val.padStart(seg.pad, "0");
|
||||
}
|
||||
|
||||
if (idx < activeSegments.length - 1) {
|
||||
val += seg.separatorAfter;
|
||||
}
|
||||
return val;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// 미리보기용 샘플 값
|
||||
export const SAMPLE_VALUES: Record<string, string> = {
|
||||
warehouseCode: "WH001",
|
||||
floor: "1층",
|
||||
zone: "A구역",
|
||||
row: "1",
|
||||
level: "1",
|
||||
};
|
||||
|
||||
export const defaultConfig: RackStructureComponentConfig = {
|
||||
// 기본 제한
|
||||
maxConditions: 10,
|
||||
maxRows: 99,
|
||||
maxLevels: 20,
|
||||
|
||||
// 기본 코드 패턴
|
||||
codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}",
|
||||
namePattern: "{zone}구역-{row:02d}열-{level}단",
|
||||
|
||||
// UI 설정
|
||||
showTemplates: true,
|
||||
showPreview: true,
|
||||
showStatistics: true,
|
||||
readonly: false,
|
||||
|
||||
// 초기 조건 없음
|
||||
initialConditions: [],
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,24 @@ export interface FieldMapping {
|
|||
statusField?: string; // 사용 여부로 사용할 폼 필드명
|
||||
}
|
||||
|
||||
// 포맷 세그먼트 (위치코드/위치명의 각 구성요소)
|
||||
export type FormatSegmentType = 'warehouseCode' | 'floor' | 'zone' | 'row' | 'level';
|
||||
|
||||
export interface FormatSegment {
|
||||
type: FormatSegmentType;
|
||||
enabled: boolean; // 이 세그먼트를 포함할지 여부
|
||||
showLabel: boolean; // 한글 라벨 표시 여부 (false면 값에서 라벨 제거)
|
||||
label: string; // 한글 라벨 (예: "층", "구역", "열", "단")
|
||||
separatorAfter: string; // 이 세그먼트 뒤의 구분자 (예: "-", "/", "")
|
||||
pad: number; // 최소 자릿수 (0 = 그대로, 2 = "01"처럼 2자리 맞춤)
|
||||
}
|
||||
|
||||
// 위치코드 + 위치명 포맷 설정
|
||||
export interface LocationFormatConfig {
|
||||
codeSegments: FormatSegment[];
|
||||
nameSegments: FormatSegment[];
|
||||
}
|
||||
|
||||
// 컴포넌트 설정
|
||||
export interface RackStructureComponentConfig {
|
||||
// 기본 설정
|
||||
|
|
@ -54,8 +72,9 @@ export interface RackStructureComponentConfig {
|
|||
fieldMapping?: FieldMapping;
|
||||
|
||||
// 위치 코드 생성 규칙
|
||||
codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}")
|
||||
namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단")
|
||||
codePattern?: string; // 코드 패턴 (하위 호환용 유지)
|
||||
namePattern?: string; // 이름 패턴 (하위 호환용 유지)
|
||||
formatConfig?: LocationFormatConfig; // 구조화된 포맷 설정
|
||||
|
||||
// UI 설정
|
||||
showTemplates?: boolean; // 템플릿 기능 표시
|
||||
|
|
@ -93,5 +112,3 @@ export interface RackStructureComponentProps {
|
|||
isPreview?: boolean;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1001,23 +1001,24 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
return formatNumberValue(value, format);
|
||||
}
|
||||
|
||||
// 🆕 카테고리 매핑 찾기 (여러 키 형태 시도)
|
||||
// 카테고리 매핑 찾기 (여러 키 형태 시도)
|
||||
// 1. 전체 컬럼명 (예: "item_info.material")
|
||||
// 2. 컬럼명만 (예: "material")
|
||||
// 3. 전역 폴백: 모든 매핑에서 value 검색
|
||||
let mapping = categoryMappings[columnName];
|
||||
|
||||
if (!mapping && columnName.includes(".")) {
|
||||
// 조인된 컬럼의 경우 컬럼명만으로 다시 시도
|
||||
const simpleColumnName = columnName.split(".").pop() || columnName;
|
||||
mapping = categoryMappings[simpleColumnName];
|
||||
}
|
||||
|
||||
if (mapping && mapping[String(value)]) {
|
||||
const categoryData = mapping[String(value)];
|
||||
const displayLabel = categoryData.label || String(value);
|
||||
const strValue = String(value);
|
||||
|
||||
if (mapping && mapping[strValue]) {
|
||||
const categoryData = mapping[strValue];
|
||||
const displayLabel = categoryData.label || strValue;
|
||||
const displayColor = categoryData.color || "#64748b";
|
||||
|
||||
// 배지로 표시
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
|
|
@ -1031,6 +1032,29 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
);
|
||||
}
|
||||
|
||||
// 전역 폴백: 컬럼명으로 매핑을 못 찾았을 때, 전체 매핑에서 값 검색
|
||||
if (!mapping && (strValue.startsWith("CAT_") || strValue.startsWith("CATEGORY_"))) {
|
||||
for (const key of Object.keys(categoryMappings)) {
|
||||
const m = categoryMappings[key];
|
||||
if (m && m[strValue]) {
|
||||
const categoryData = m[strValue];
|
||||
const displayLabel = categoryData.label || strValue;
|
||||
const displayColor = categoryData.color || "#64748b";
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: displayColor,
|
||||
borderColor: displayColor,
|
||||
}}
|
||||
className="text-white"
|
||||
>
|
||||
{displayLabel}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체)
|
||||
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) {
|
||||
return formatDateValue(value, "YYYY-MM-DD");
|
||||
|
|
@ -1150,10 +1174,44 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]);
|
||||
}
|
||||
|
||||
// 좌측 패널 dataFilter 클라이언트 사이드 적용
|
||||
let filteredLeftData = result.data || [];
|
||||
const leftDataFilter = componentConfig.leftPanel?.dataFilter;
|
||||
if (leftDataFilter?.enabled && leftDataFilter.filters?.length > 0) {
|
||||
const matchFn = leftDataFilter.matchType === "any" ? "some" : "every";
|
||||
filteredLeftData = filteredLeftData.filter((item: any) => {
|
||||
return leftDataFilter.filters[matchFn]((cond: any) => {
|
||||
const val = item[cond.columnName];
|
||||
switch (cond.operator) {
|
||||
case "equals":
|
||||
return val === cond.value;
|
||||
case "not_equals":
|
||||
return val !== cond.value;
|
||||
case "in": {
|
||||
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
|
||||
return arr.includes(val);
|
||||
}
|
||||
case "not_in": {
|
||||
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
|
||||
return !arr.includes(val);
|
||||
}
|
||||
case "contains":
|
||||
return String(val || "").includes(String(cond.value));
|
||||
case "is_null":
|
||||
return val === null || val === undefined || val === "";
|
||||
case "is_not_null":
|
||||
return val !== null && val !== undefined && val !== "";
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
|
||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||
if (leftColumn && result.data.length > 0) {
|
||||
result.data.sort((a, b) => {
|
||||
if (leftColumn && filteredLeftData.length > 0) {
|
||||
filteredLeftData.sort((a, b) => {
|
||||
const aValue = String(a[leftColumn] || "");
|
||||
const bValue = String(b[leftColumn] || "");
|
||||
return aValue.localeCompare(bValue, "ko-KR");
|
||||
|
|
@ -1161,7 +1219,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}
|
||||
|
||||
// 계층 구조 빌드
|
||||
const hierarchicalData = buildHierarchy(result.data);
|
||||
const hierarchicalData = buildHierarchy(filteredLeftData);
|
||||
setLeftData(hierarchicalData);
|
||||
} catch (error) {
|
||||
console.error("좌측 데이터 로드 실패:", error);
|
||||
|
|
@ -1220,7 +1278,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
case "equals":
|
||||
return value === cond.value;
|
||||
case "notEquals":
|
||||
case "not_equals":
|
||||
return value !== cond.value;
|
||||
case "in": {
|
||||
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
|
||||
return arr.includes(value);
|
||||
}
|
||||
case "not_in": {
|
||||
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
|
||||
return !arr.includes(value);
|
||||
}
|
||||
case "contains":
|
||||
return String(value || "").includes(String(cond.value));
|
||||
case "is_null":
|
||||
|
|
@ -1537,7 +1604,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
case "equals":
|
||||
return value === cond.value;
|
||||
case "notEquals":
|
||||
case "not_equals":
|
||||
return value !== cond.value;
|
||||
case "in": {
|
||||
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
|
||||
return arr.includes(value);
|
||||
}
|
||||
case "not_in": {
|
||||
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
|
||||
return !arr.includes(value);
|
||||
}
|
||||
case "contains":
|
||||
return String(value || "").includes(String(cond.value));
|
||||
case "is_null":
|
||||
|
|
@ -1929,43 +2005,59 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
loadRightTableColumns();
|
||||
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
|
||||
|
||||
// 좌측 테이블 카테고리 매핑 로드
|
||||
// 좌측 테이블 카테고리 매핑 로드 (조인된 테이블 포함)
|
||||
useEffect(() => {
|
||||
const loadLeftCategoryMappings = async () => {
|
||||
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||
if (!leftTableName || isDesignMode) return;
|
||||
|
||||
try {
|
||||
// 1. 컬럼 메타 정보 조회
|
||||
const columnsResponse = await tableTypeApi.getColumns(leftTableName);
|
||||
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
|
||||
|
||||
if (categoryColumns.length === 0) {
|
||||
setLeftCategoryMappings({});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 각 카테고리 컬럼에 대한 값 조회
|
||||
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
||||
const tablesToLoad = new Set<string>([leftTableName]);
|
||||
|
||||
for (const col of categoryColumns) {
|
||||
const columnName = col.columnName || col.column_name;
|
||||
// 좌측 패널 컬럼 설정에서 조인된 테이블 추출
|
||||
const leftColumns = componentConfig.leftPanel?.columns || [];
|
||||
leftColumns.forEach((col: any) => {
|
||||
const colName = col.name || col.columnName;
|
||||
if (colName && colName.includes(".")) {
|
||||
const joinTableName = colName.split(".")[0];
|
||||
tablesToLoad.add(joinTableName);
|
||||
}
|
||||
});
|
||||
|
||||
// 각 테이블에 대해 카테고리 매핑 로드
|
||||
for (const tableName of tablesToLoad) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values?includeInactive=true`);
|
||||
const columnsResponse = await tableTypeApi.getColumns(tableName);
|
||||
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const valueMap: Record<string, { label: string; color?: string }> = {};
|
||||
response.data.data.forEach((item: any) => {
|
||||
valueMap[item.value_code || item.valueCode] = {
|
||||
label: item.value_label || item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
});
|
||||
mappings[columnName] = valueMap;
|
||||
console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
|
||||
for (const col of categoryColumns) {
|
||||
const columnName = col.columnName || col.column_name;
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const valueMap: Record<string, { label: string; color?: string }> = {};
|
||||
response.data.data.forEach((item: any) => {
|
||||
valueMap[item.value_code || item.valueCode] = {
|
||||
label: item.value_label || item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
});
|
||||
|
||||
// 조인된 테이블은 "테이블명.컬럼명" 형태로도 저장
|
||||
const mappingKey = tableName === leftTableName ? columnName : `${tableName}.${columnName}`;
|
||||
mappings[mappingKey] = valueMap;
|
||||
|
||||
// 컬럼명만으로도 접근 가능하도록 추가 저장
|
||||
mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`좌측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`좌측 카테고리 값 조회 실패 [${columnName}]:`, error);
|
||||
console.error(`좌측 카테고리 테이블 컬럼 조회 실패 [${tableName}]:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1976,7 +2068,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
};
|
||||
|
||||
loadLeftCategoryMappings();
|
||||
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
|
||||
}, [componentConfig.leftPanel?.tableName, componentConfig.leftPanel?.columns, isDesignMode]);
|
||||
|
||||
// 우측 테이블 카테고리 매핑 로드 (조인된 테이블 포함)
|
||||
useEffect(() => {
|
||||
|
|
@ -3668,9 +3760,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
displayFields = configuredColumns.slice(0, 2).map((col: any) => {
|
||||
const colName = typeof col === "string" ? col : col.name || col.columnName;
|
||||
const colLabel = typeof col === "object" ? col.label : leftColumnLabels[colName] || colName;
|
||||
const rawValue = getEntityJoinValue(item, colName);
|
||||
// 카테고리 매핑이 있으면 라벨로 변환
|
||||
let displayValue = rawValue;
|
||||
if (rawValue != null && rawValue !== "") {
|
||||
const strVal = String(rawValue);
|
||||
let mapping = leftCategoryMappings[colName];
|
||||
if (!mapping && colName.includes(".")) {
|
||||
mapping = leftCategoryMappings[colName.split(".").pop() || colName];
|
||||
}
|
||||
if (mapping && mapping[strVal]) {
|
||||
displayValue = mapping[strVal].label;
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: colLabel,
|
||||
value: item[colName],
|
||||
value: displayValue,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -3682,10 +3787,21 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const keys = Object.keys(item).filter(
|
||||
(k) => k !== "id" && k !== "ID" && k !== "children" && k !== "level" && shouldShowField(k),
|
||||
);
|
||||
displayFields = keys.slice(0, 2).map((key) => ({
|
||||
label: leftColumnLabels[key] || key,
|
||||
value: item[key],
|
||||
}));
|
||||
displayFields = keys.slice(0, 2).map((key) => {
|
||||
const rawValue = item[key];
|
||||
let displayValue = rawValue;
|
||||
if (rawValue != null && rawValue !== "") {
|
||||
const strVal = String(rawValue);
|
||||
const mapping = leftCategoryMappings[key];
|
||||
if (mapping && mapping[strVal]) {
|
||||
displayValue = mapping[strVal].label;
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: leftColumnLabels[key] || key,
|
||||
value: displayValue,
|
||||
};
|
||||
});
|
||||
|
||||
if (index === 0) {
|
||||
console.log(" ⚠️ 설정된 컬럼 없음, 자동 선택:", displayFields);
|
||||
|
|
@ -5103,6 +5219,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1932,7 +1932,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
|
||||
{/* ===== 기본 설정 모달 ===== */}
|
||||
<Dialog open={activeModal === "basic"} onOpenChange={(open) => !open && setActiveModal(null)}>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogContent container={null} className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">기본 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs">패널 관계 타입 및 레이아웃을 설정합니다</DialogDescription>
|
||||
|
|
@ -2010,7 +2010,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
|
||||
{/* ===== 좌측 패널 모달 ===== */}
|
||||
<Dialog open={activeModal === "left"} onOpenChange={(open) => !open && setActiveModal(null)}>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogContent container={null} className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">좌측 패널 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs">마스터 데이터 표시 및 필터링을 설정합니다</DialogDescription>
|
||||
|
|
@ -2680,7 +2680,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
|
||||
{/* ===== 우측 패널 모달 ===== */}
|
||||
<Dialog open={activeModal === "right"} onOpenChange={(open) => !open && setActiveModal(null)}>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogContent container={null} className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">우측 패널 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
|
|
@ -3604,7 +3604,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
|
||||
{/* ===== 추가 탭 모달 ===== */}
|
||||
<Dialog open={activeModal === "tabs"} onOpenChange={(open) => !open && setActiveModal(null)}>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogContent container={null} className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">추가 탭 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
|
|
|
|||
|
|
@ -118,9 +118,9 @@ export interface AdditionalTabConfig {
|
|||
// 추가 버튼 설정 (모달 화면 연결 지원)
|
||||
addButton?: {
|
||||
enabled: boolean;
|
||||
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
|
||||
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
|
||||
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
|
||||
mode: "auto" | "modal";
|
||||
modalScreenId?: number;
|
||||
buttonLabel?: string;
|
||||
};
|
||||
|
||||
deleteButton?: {
|
||||
|
|
@ -161,9 +161,9 @@ export interface SplitPanelLayoutConfig {
|
|||
// 추가 버튼 설정 (모달 화면 연결 지원)
|
||||
addButton?: {
|
||||
enabled: boolean;
|
||||
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
|
||||
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
|
||||
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
|
||||
mode: "auto" | "modal";
|
||||
modalScreenId?: number;
|
||||
buttonLabel?: string;
|
||||
};
|
||||
|
||||
columns?: Array<{
|
||||
|
|
@ -334,10 +334,10 @@ export interface SplitPanelLayoutConfig {
|
|||
|
||||
// 🆕 추가 버튼 설정 (모달 화면 연결 지원)
|
||||
addButton?: {
|
||||
enabled: boolean; // 추가 버튼 표시 여부 (기본: true)
|
||||
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
|
||||
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
|
||||
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
|
||||
enabled: boolean;
|
||||
mode: "auto" | "modal";
|
||||
modalScreenId?: number;
|
||||
buttonLabel?: string;
|
||||
};
|
||||
|
||||
// 🆕 삭제 버튼 설정
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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 사용
|
||||
|
|
@ -405,6 +406,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 디버그 로그 제거 (성능 최적화)
|
||||
|
||||
const currentTabId = useTabId();
|
||||
|
||||
const buttonColor = getAdaptiveLabelColor(component.style?.labelColor);
|
||||
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
|
||||
|
||||
|
|
@ -433,13 +436,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: isDesignMode ? "300px" : "100%",
|
||||
...style,
|
||||
// 런타임에서는 DB의 고정 px 크기를 무시하고 부모에 맞춤
|
||||
...(!isDesignMode && {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minWidth: 0,
|
||||
}),
|
||||
...style, // style prop이 위의 기본값들을 덮어씀
|
||||
};
|
||||
|
||||
// ========================================
|
||||
|
|
@ -695,13 +692,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [pageInputValue, setPageInputValue] = useState<string>("1");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
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 tableConfig.pagination?.pageSize || 20;
|
||||
});
|
||||
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
|
||||
const [columnMeta, setColumnMeta] = useState<
|
||||
Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }>
|
||||
|
|
@ -811,9 +827,10 @@ 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]);
|
||||
|
||||
|
|
@ -1623,7 +1640,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const page = tableConfig.pagination?.currentPage || currentPage;
|
||||
const page = currentPage;
|
||||
const pageSize = localPageSize;
|
||||
// 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용
|
||||
const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined;
|
||||
|
|
@ -1886,7 +1903,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
}, [
|
||||
tableConfig.selectedTable,
|
||||
tableConfig.pagination?.currentPage,
|
||||
tableConfig.columns,
|
||||
currentPage,
|
||||
localPageSize,
|
||||
|
|
@ -1929,6 +1945,23 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPageInputValue(String(currentPage));
|
||||
}, [currentPage]);
|
||||
|
||||
const commitPageInput = () => {
|
||||
const parsed = parseInt(pageInputValue, 10);
|
||||
if (isNaN(parsed) || pageInputValue.trim() === "") {
|
||||
setPageInputValue(String(currentPage));
|
||||
return;
|
||||
}
|
||||
const clamped = Math.max(1, Math.min(parsed, totalPages || 1));
|
||||
if (clamped !== currentPage) {
|
||||
handlePageChange(clamped);
|
||||
}
|
||||
setPageInputValue(String(clamped));
|
||||
};
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
let newSortColumn = column;
|
||||
let newSortDirection: "asc" | "desc" = "asc";
|
||||
|
|
@ -2080,11 +2113,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
};
|
||||
|
||||
const handleRowSelection = (rowKey: string, checked: boolean) => {
|
||||
const newSelectedRows = new Set(selectedRows);
|
||||
if (checked) {
|
||||
newSelectedRows.add(rowKey);
|
||||
const isMultiSelect = tableConfig.checkbox?.multiple !== false;
|
||||
let newSelectedRows: Set<string>;
|
||||
|
||||
if (isMultiSelect) {
|
||||
newSelectedRows = new Set(selectedRows);
|
||||
if (checked) {
|
||||
newSelectedRows.add(rowKey);
|
||||
} else {
|
||||
newSelectedRows.delete(rowKey);
|
||||
}
|
||||
} else {
|
||||
newSelectedRows.delete(rowKey);
|
||||
// 단일 선택: 기존 선택 해제 후 새 항목만 선택
|
||||
newSelectedRows = checked ? new Set([rowKey]) : new Set();
|
||||
}
|
||||
setSelectedRows(newSelectedRows);
|
||||
|
||||
|
|
@ -2968,7 +3009,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(tableStateKey, JSON.stringify(state));
|
||||
sessionStorage.setItem(tableStateKey, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 상태 저장 실패:", error);
|
||||
}
|
||||
|
|
@ -2991,7 +3032,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);
|
||||
|
|
@ -3029,7 +3070,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (!tableStateKey) return;
|
||||
|
||||
try {
|
||||
localStorage.removeItem(tableStateKey);
|
||||
sessionStorage.removeItem(tableStateKey);
|
||||
setColumnWidths({});
|
||||
setColumnOrder([]);
|
||||
setSortColumn(null);
|
||||
|
|
@ -4154,6 +4195,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
const renderCheckboxHeader = () => {
|
||||
if (!tableConfig.checkbox?.selectAll) return null;
|
||||
if (tableConfig.checkbox?.multiple === false) return null;
|
||||
|
||||
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />;
|
||||
};
|
||||
|
|
@ -4263,8 +4305,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
return (
|
||||
<div className="flex max-w-full items-center gap-1.5 text-sm">
|
||||
<Paperclip className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-primary" title={fileNames}>
|
||||
<Paperclip className="h-4 w-4 flex-shrink-0 text-gray-500" />
|
||||
<span className="truncate text-blue-600" title={fileNames}>
|
||||
{fileNames}
|
||||
</span>
|
||||
{files.length > 1 && <span className="text-muted-foreground flex-shrink-0 text-xs">({files.length})</span>}
|
||||
|
|
@ -4453,28 +4495,32 @@ 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));
|
||||
|
|
@ -4493,7 +4539,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("검색 필터 설정이 저장되었습니다");
|
||||
|
||||
|
|
@ -4548,7 +4594,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);
|
||||
}
|
||||
|
|
@ -4628,7 +4674,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setGroupByColumns([]);
|
||||
setCollapsedGroups(new Set());
|
||||
if (groupSettingKey) {
|
||||
localStorage.removeItem(groupSettingKey);
|
||||
sessionStorage.removeItem(groupSettingKey);
|
||||
}
|
||||
toast.success("그룹이 해제되었습니다");
|
||||
}, [groupSettingKey]);
|
||||
|
|
@ -4812,7 +4858,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);
|
||||
|
|
@ -5112,7 +5158,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 페이지 크기 변경 핸들러
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
setLocalPageSize(newSize);
|
||||
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로 이동
|
||||
setCurrentPage(1);
|
||||
if (pageSizeKey) {
|
||||
sessionStorage.setItem(pageSizeKey, String(newSize));
|
||||
}
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...tableConfig,
|
||||
|
|
@ -5121,8 +5170,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const pageSizeOptions = tableConfig.pagination?.pageSizeOptions || [5, 10, 20, 50, 100];
|
||||
|
||||
return (
|
||||
<div className="border-border bg-background relative flex h-14 w-full flex-shrink-0 items-center justify-center border-t-2 px-4 sm:h-[60px] sm:px-6">
|
||||
{/* 좌측: 페이지 크기 입력 */}
|
||||
|
|
@ -5168,9 +5215,28 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<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>
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={pageInputValue}
|
||||
onChange={(e) => setPageInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
commitPageInput();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
onBlur={commitPageInput}
|
||||
onFocus={(e) => e.target.select()}
|
||||
disabled={loading}
|
||||
className="border-input bg-background focus:ring-ring h-7 w-10 rounded-md border px-1 text-center text-xs font-medium focus:ring-1 focus:outline-none sm:h-8 sm:w-12 sm:text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs sm:text-sm">/</span>
|
||||
<span className="text-foreground text-xs font-medium sm:text-sm">
|
||||
{totalPages || 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -5205,7 +5271,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<div className="flex flex-col gap-1">
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">Excel</div>
|
||||
<Button variant="ghost" size="sm" className="justify-start text-xs" onClick={() => exportToExcel(true)}>
|
||||
<FileSpreadsheet className="mr-2 h-3 w-3 text-emerald-600" />
|
||||
<FileSpreadsheet className="mr-2 h-3 w-3 text-green-600" />
|
||||
전체 Excel 내보내기
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -5215,13 +5281,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onClick={() => exportToExcel(false)}
|
||||
disabled={selectedRows.size === 0}
|
||||
>
|
||||
<FileSpreadsheet className="mr-2 h-3 w-3 text-emerald-600" />
|
||||
<FileSpreadsheet className="mr-2 h-3 w-3 text-green-600" />
|
||||
선택 항목만 ({selectedRows.size}개)
|
||||
</Button>
|
||||
<div className="border-border my-1 border-t" />
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">PDF/인쇄</div>
|
||||
<Button variant="ghost" size="sm" className="justify-start text-xs" onClick={() => exportToPdf(true)}>
|
||||
<FileText className="mr-2 h-3 w-3 text-destructive" />
|
||||
<FileText className="mr-2 h-3 w-3 text-red-600" />
|
||||
전체 PDF 내보내기
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -5231,7 +5297,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onClick={() => exportToPdf(false)}
|
||||
disabled={selectedRows.size === 0}
|
||||
>
|
||||
<FileText className="mr-2 h-3 w-3 text-destructive" />
|
||||
<FileText className="mr-2 h-3 w-3 text-red-600" />
|
||||
선택 항목만 ({selectedRows.size}개)
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -5267,6 +5333,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
localPageSize,
|
||||
onConfigChange,
|
||||
tableConfig,
|
||||
pageInputValue,
|
||||
]);
|
||||
|
||||
// ========================================
|
||||
|
|
@ -5278,7 +5345,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onDragStart: isDesignMode ? onDragStart : undefined,
|
||||
onDragEnd: isDesignMode ? onDragEnd : undefined,
|
||||
draggable: isDesignMode,
|
||||
className: cn("w-full h-full overflow-hidden", className, isDesignMode && "cursor-move"),
|
||||
className: cn("w-full h-full", className, isDesignMode && "cursor-move"), // customer-item-mapping과 동일
|
||||
style: componentStyle,
|
||||
};
|
||||
|
||||
|
|
@ -5348,7 +5415,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", WebkitOverflowScrolling: "touch" }}>
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||
<SingleTableWithSticky
|
||||
data={data}
|
||||
columns={visibleColumns}
|
||||
|
|
@ -5414,7 +5481,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
className="h-7 text-xs"
|
||||
title="Excel 내보내기"
|
||||
>
|
||||
<FileSpreadsheet className="mr-1 h-3 w-3 text-emerald-600" />
|
||||
<FileSpreadsheet className="mr-1 h-3 w-3 text-green-600" />
|
||||
Excel
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -5426,7 +5493,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
className="h-7 text-xs"
|
||||
title="PDF 내보내기"
|
||||
>
|
||||
<FileText className="mr-1 h-3 w-3 text-destructive" />
|
||||
<FileText className="mr-1 h-3 w-3 text-red-600" />
|
||||
PDF
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -5658,7 +5725,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
}}
|
||||
onScroll={handleVirtualScroll}
|
||||
>
|
||||
|
|
@ -5669,7 +5735,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
borderCollapse: "collapse",
|
||||
width: "100%",
|
||||
tableLayout: "fixed",
|
||||
minWidth: "400px",
|
||||
}}
|
||||
>
|
||||
{/* 헤더 (sticky) */}
|
||||
|
|
@ -5887,7 +5952,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
{/* 리사이즈 핸들 (체크박스 제외) */}
|
||||
{columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
|
||||
<div
|
||||
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-primary"
|
||||
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
|
||||
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
|
||||
onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지
|
||||
onMouseDown={(e) => {
|
||||
|
|
@ -6240,11 +6305,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 🆕 배치 편집: 수정된 셀 스타일 (노란 배경)
|
||||
isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40",
|
||||
// 🆕 유효성 에러: 빨간 테두리 및 배경
|
||||
cellValidationError && "bg-destructive/10 ring-2 ring-destructive ring-inset dark:bg-destructive/15",
|
||||
cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40",
|
||||
// 🆕 검색 하이라이트 스타일 (노란 배경)
|
||||
isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50",
|
||||
// 🆕 편집 불가 컬럼 스타일 (연한 회색 배경)
|
||||
column.editable === false && "bg-muted dark:bg-foreground/30",
|
||||
column.editable === false && "bg-gray-50 dark:bg-gray-900/30",
|
||||
)}
|
||||
// 🆕 유효성 에러 툴팁
|
||||
title={cellValidationError || undefined}
|
||||
|
|
@ -6637,7 +6702,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
{/* 행 삭제 */}
|
||||
<button
|
||||
className="hover:bg-destructive hover:text-destructive-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-destructive"
|
||||
className="hover:bg-destructive hover:text-destructive-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-red-600"
|
||||
onClick={async () => {
|
||||
if (confirm("이 행을 삭제하시겠습니까?")) {
|
||||
try {
|
||||
|
|
@ -6704,7 +6769,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFilterGroup(group.id)}
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -6764,7 +6829,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFilterCondition(group.id, condition.id)}
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||||
disabled={group.conditions.length === 1}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
|
|
|
|||
|
|
@ -78,13 +78,30 @@ export function loadTabCache(tabId: string): TabCacheData | null {
|
|||
}
|
||||
|
||||
/**
|
||||
* 특정 탭의 캐시 삭제
|
||||
* 특정 탭의 캐시 삭제.
|
||||
* tab-cache-{tabId} 외에도 테이블 관련 키(tableState_, pageSize_, filterSettings_, groupSettings_)를 일괄 제거한다.
|
||||
*/
|
||||
export function clearTabCache(tabId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(CACHE_PREFIX + tabId);
|
||||
|
||||
const suffix = `_${tabId}_`;
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (
|
||||
key &&
|
||||
(key.startsWith("tableState" + suffix) ||
|
||||
key.startsWith("pageSize" + suffix) ||
|
||||
key.startsWith("filterSettings" + suffix) ||
|
||||
key.startsWith("groupSettings" + suffix))
|
||||
) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach((k) => sessionStorage.removeItem(k));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
|
|
|||
|
|
@ -689,11 +689,10 @@ export class ButtonActionExecutor {
|
|||
return false;
|
||||
}
|
||||
|
||||
// 🆕 렉 구조 등록 화면 감지 (warehouse_location 테이블 + floor/zone 필드 있음 + 렉 구조 데이터 없음)
|
||||
// 이 경우 일반 저장을 차단하고 미리보기 생성을 요구
|
||||
// 렉 구조 등록 화면 감지 (warehouse_location 테이블 + zone 필드 있음 + 렉 구조 데이터 없음)
|
||||
// floor는 선택 입력이므로 감지 조건에서 제외
|
||||
const isRackStructureScreen =
|
||||
context.tableName === "warehouse_location" &&
|
||||
context.formData?.floor &&
|
||||
context.formData?.zone &&
|
||||
!rackStructureLocations;
|
||||
|
||||
|
|
@ -2085,15 +2084,18 @@ export class ButtonActionExecutor {
|
|||
const floor = firstLocation.floor;
|
||||
const zone = firstLocation.zone;
|
||||
|
||||
if (warehouseCode && floor && zone) {
|
||||
if (warehouseCode && zone) {
|
||||
try {
|
||||
// search 파라미터를 사용하여 백엔드에서 필터링 (filters는 백엔드에서 처리 안됨)
|
||||
const searchParams: Record<string, any> = {
|
||||
warehouse_code: { value: warehouseCode, operator: "equals" },
|
||||
zone: { value: zone, operator: "equals" },
|
||||
};
|
||||
if (floor) {
|
||||
searchParams.floor = { value: floor, operator: "equals" };
|
||||
}
|
||||
|
||||
const existingResponse = await DynamicFormApi.getTableData(tableName, {
|
||||
search: {
|
||||
warehouse_code: { value: warehouseCode, operator: "equals" },
|
||||
floor: { value: floor, operator: "equals" },
|
||||
zone: { value: zone, operator: "equals" },
|
||||
},
|
||||
search: searchParams,
|
||||
page: 1,
|
||||
pageSize: 1000,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ export const useTabStore = create<TabState>()(
|
|||
},
|
||||
|
||||
refreshTab: (tabId) => {
|
||||
clearTabCache(tabId);
|
||||
set((state) => ({
|
||||
refreshKeys: { ...state.refreshKeys, [tabId]: (state.refreshKeys[tabId] || 0) + 1 },
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in New Issue