Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into refactor/config-panel-redesign
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
commit
b8f5d4be4c
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
description: 컴포넌트 추가/수정 또는 DB 구조 변경 시 관련 문서를 항상 최신화하도록 강제하는 규칙
|
||||||
|
globs:
|
||||||
|
- "frontend/lib/registry/components/**/*.tsx"
|
||||||
|
- "frontend/components/v2/**/*.tsx"
|
||||||
|
- "db/migrations/**/*.sql"
|
||||||
|
- "backend-node/src/types/ddl.ts"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 컴포넌트 및 DB 구조 변경 시 문서 동기화 규칙
|
||||||
|
|
||||||
|
## 🚨 핵심 원칙 (절대 준수)
|
||||||
|
|
||||||
|
새로운 V2 컴포넌트를 생성하거나 기존 컴포넌트의 설정(overrides)을 변경할 때, 또는 DB 테이블 구조나 화면 생성 파이프라인이 변경될 때는 **반드시** 아래 두 문서를 함께 업데이트해야 합니다.
|
||||||
|
|
||||||
|
1. `docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md` (전체 레퍼런스)
|
||||||
|
2. `docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md` (실행 가이드)
|
||||||
|
|
||||||
|
## 📌 업데이트 대상 및 방법
|
||||||
|
|
||||||
|
### 1. V2 컴포넌트 신규 추가 또는 속성(Props/Overrides) 변경 시
|
||||||
|
- **`full-screen-analysis.md`**: `3. 컴포넌트 전체 설정 레퍼런스` 섹션에 해당 컴포넌트의 모든 설정값(타입, 기본값, 설명)을 표 형태로 추가/수정하세요.
|
||||||
|
- **`v2-component-usage-guide.md`**:
|
||||||
|
- `7. Step 6: screen_layouts_v2 INSERT`의 컴포넌트 url 매핑표에 추가하세요.
|
||||||
|
- `16. 컴포넌트 빠른 참조표`에 추가하세요.
|
||||||
|
- 필요한 경우 `8. 패턴별 layout_data 완전 예시`에 새로운 패턴을 추가하세요.
|
||||||
|
|
||||||
|
### 2. DB 테이블 구조 또는 화면 생성 로직 변경 시
|
||||||
|
- **`full-screen-analysis.md`**: `2. DB 테이블 스키마` 섹션의 테이블 구조(컬럼, 타입, 설명)를 최신화하세요.
|
||||||
|
- **`v2-component-usage-guide.md`**:
|
||||||
|
- `Step 1` ~ `Step 7`의 SQL 템플릿이 변경된 구조와 일치하는지 확인하고 수정하세요.
|
||||||
|
- 특히 `INSERT` 문의 컬럼 목록과 `VALUES` 형식이 정확한지 검증하세요.
|
||||||
|
|
||||||
|
## ⚠️ AI 에이전트 행동 지침
|
||||||
|
|
||||||
|
1. 사용자가 컴포넌트 코드를 수정해달라고 요청하면, 수정 완료 후 **"관련 가이드 문서도 업데이트할까요?"** 라고 반드시 물어보세요.
|
||||||
|
2. 사용자가 DB 마이그레이션 스크립트를 작성해달라고 하거나 핵심 시스템 테이블을 건드리면, 가이드 문서의 SQL 템플릿도 수정해야 하는지 확인하세요.
|
||||||
|
3. 가이드 문서 업데이트 시 JSON 예제 안에 `//` 같은 주석을 넣지 않도록 주의하세요 (DB 파싱 에러 방지).
|
||||||
|
|
@ -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. **항상 한글로 답변**
|
||||||
66
.cursorrules
66
.cursorrules
|
|
@ -1510,3 +1510,69 @@ const query = `
|
||||||
|
|
||||||
**company_code = "*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!**
|
**company_code = "*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DB 테이블 생성 필수 규칙
|
||||||
|
|
||||||
|
**상세 가이드**: [table-type-sql-guide.mdc](.cursor/rules/table-type-sql-guide.mdc)
|
||||||
|
|
||||||
|
### 핵심 원칙 (절대 위반 금지)
|
||||||
|
|
||||||
|
1. **모든 비즈니스 컬럼은 `VARCHAR(500)`**: NUMERIC, INTEGER, SERIAL, TEXT 등 DB 타입 직접 지정 금지
|
||||||
|
2. **기본 5개 컬럼 자동 포함** (모든 테이블 필수):
|
||||||
|
```sql
|
||||||
|
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
"created_date" timestamp DEFAULT now(),
|
||||||
|
"updated_date" timestamp DEFAULT now(),
|
||||||
|
"writer" varchar(500) DEFAULT NULL,
|
||||||
|
"company_code" varchar(500)
|
||||||
|
```
|
||||||
|
3. **3개 메타데이터 테이블 등록 필수**:
|
||||||
|
- `table_labels`: 테이블 라벨/설명
|
||||||
|
- `table_type_columns`: 컬럼 input_type, detail_settings (company_code = '*')
|
||||||
|
- `column_labels`: 컬럼 한글 라벨 (레거시 호환)
|
||||||
|
4. **input_type으로 타입 구분**: text, number, date, code, entity, select, checkbox, radio, textarea
|
||||||
|
5. **ON CONFLICT 절 필수**: 중복 시 UPDATE 처리
|
||||||
|
|
||||||
|
### 금지 사항
|
||||||
|
|
||||||
|
- `SERIAL`, `INTEGER`, `NUMERIC`, `BOOLEAN`, `TEXT`, `DATE` 등 DB 타입 직접 사용 금지
|
||||||
|
- `VARCHAR` 길이 변경 금지 (반드시 500)
|
||||||
|
- 기본 5개 컬럼 누락 금지
|
||||||
|
- 메타데이터 테이블 미등록 금지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 화면 개발 방식 필수 규칙 (사용자 메뉴 vs 관리자 메뉴)
|
||||||
|
|
||||||
|
**상세 가이드**: [pipeline-common-rules.md](.cursor/agents/pipeline-common-rules.md)
|
||||||
|
|
||||||
|
### 핵심 원칙 (절대 위반 금지)
|
||||||
|
|
||||||
|
1. **사용자 업무 화면은 React 코드(.tsx)로 직접 만들지 않는다!**
|
||||||
|
- 포장관리, 금형관리, BOM, 입출고, 품질 등 일반 업무 화면
|
||||||
|
- DB에 `screen_definitions` + `screen_layouts_v2` + `menu_info` 등록으로 구현
|
||||||
|
- 이미 `/screen/[screenCode]` → `/screens/[screenId]` 렌더링 시스템이 존재
|
||||||
|
- V2 컴포넌트(v2-split-panel-layout, v2-table-list, v2-repeater 등)로 레이아웃 구성
|
||||||
|
|
||||||
|
2. **관리자 메뉴만 React 코드로 작성 가능**
|
||||||
|
- 사용자 관리, 권한 관리, 시스템 설정 등
|
||||||
|
- `frontend/app/(main)/admin/{기능}/page.tsx`에 작성
|
||||||
|
- `menu_info` 테이블에 메뉴 등록 필수
|
||||||
|
|
||||||
|
### 사용자 메뉴 구현 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
1. DB 테이블 생성 (비즈니스 데이터용)
|
||||||
|
2. screen_definitions INSERT (screen_code, table_name)
|
||||||
|
3. screen_layouts_v2 INSERT (V2 레이아웃 JSON)
|
||||||
|
4. menu_info INSERT (menu_url = '/screen/{screen_code}')
|
||||||
|
5. 필요하면 백엔드 전용 API 추가 (범용 API로 안 되는 경우만)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 금지 사항
|
||||||
|
|
||||||
|
- `frontend/app/(main)/production/*/page.tsx` 같은 사용자 화면 하드코딩 금지
|
||||||
|
- `frontend/app/(main)/warehouse/*/page.tsx` 같은 사용자 화면 하드코딩 금지
|
||||||
|
- 사용자 메뉴의 UI를 React 컴포넌트로 직접 구현하는 것 금지
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ dist/
|
||||||
build/
|
build/
|
||||||
build/Release
|
build/Release
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
**/backend/.gradle/
|
||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
.npm
|
.npm
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
|
||||||
|
|
@ -947,6 +947,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
|
|
@ -2184,6 +2185,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.12.0",
|
"pg-connection-string": "^2.12.0",
|
||||||
"pg-pool": "^3.13.0",
|
"pg-pool": "^3.13.0",
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관
|
||||||
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||||
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
|
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
|
||||||
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
||||||
|
import packagingRoutes from "./routes/packagingRoutes"; // 포장/적재정보 관리
|
||||||
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||||
|
|
@ -321,6 +322,7 @@ app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||||
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||||
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
|
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
|
||||||
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
||||||
|
app.use("/api/packaging", packagingRoutes); // 포장/적재정보 관리
|
||||||
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,46 @@ export async function getUserMenus(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 메뉴 목록 조회
|
||||||
|
* [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환
|
||||||
|
*/
|
||||||
|
export async function getPopMenus(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
||||||
|
const userType = req.user?.userType;
|
||||||
|
|
||||||
|
const result = await AdminService.getPopMenuList({
|
||||||
|
userCompanyCode,
|
||||||
|
userType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: ApiResponse<any> = {
|
||||||
|
success: true,
|
||||||
|
message: "POP 메뉴 목록 조회 성공",
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("POP 메뉴 목록 조회 중 오류 발생:", error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "POP 메뉴 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "POP_MENU_LIST_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(500).json(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴 정보 조회
|
* 메뉴 정보 조회
|
||||||
*/
|
*/
|
||||||
|
|
@ -1814,7 +1854,7 @@ export async function toggleMenuStatus(
|
||||||
|
|
||||||
// 현재 상태 및 회사 코드 조회
|
// 현재 상태 및 회사 코드 조회
|
||||||
const currentMenu = await queryOne<any>(
|
const currentMenu = await queryOne<any>(
|
||||||
`SELECT objid, status, company_code FROM menu_info WHERE objid = $1`,
|
`SELECT objid, status, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
|
||||||
[Number(menuId)]
|
[Number(menuId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -3574,7 +3614,7 @@ export async function getTableSchema(
|
||||||
ic.character_maximum_length,
|
ic.character_maximum_length,
|
||||||
ic.numeric_precision,
|
ic.numeric_precision,
|
||||||
ic.numeric_scale,
|
ic.numeric_scale,
|
||||||
COALESCE(ttc_company.column_label, ttc_common.column_label) AS column_label,
|
COALESCE(NULLIF(ttc_company.column_label, ic.column_name), ttc_common.column_label) AS column_label,
|
||||||
COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order,
|
COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order,
|
||||||
COALESCE(ttc_company.is_nullable, ttc_common.is_nullable) AS ttc_is_nullable,
|
COALESCE(ttc_company.is_nullable, ttc_common.is_nullable) AS ttc_is_nullable,
|
||||||
COALESCE(ttc_company.is_unique, ttc_common.is_unique) AS ttc_is_unique
|
COALESCE(ttc_company.is_unique, ttc_common.is_unique) AS ttc_is_unique
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||||
import { auditLogService } from "../services/auditLogService";
|
import { auditLogService, getClientIp, AuditAction, AuditResourceType } from "../services/auditLogService";
|
||||||
import { query } from "../database/db";
|
import { query } from "../database/db";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
|
@ -137,3 +137,40 @@ export const getAuditLogUsers = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프론트엔드에서 직접 감사 로그 기록 (그룹 복제 등 프론트 오케스트레이션 작업용)
|
||||||
|
*/
|
||||||
|
export const createAuditLog = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { action, resourceType, resourceId, resourceName, tableName, summary, changes } = req.body;
|
||||||
|
|
||||||
|
if (!action || !resourceType) {
|
||||||
|
res.status(400).json({ success: false, message: "action, resourceType은 필수입니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: action as AuditAction,
|
||||||
|
resourceType: resourceType as AuditResourceType,
|
||||||
|
resourceId: resourceId || undefined,
|
||||||
|
resourceName: resourceName || undefined,
|
||||||
|
tableName: tableName || undefined,
|
||||||
|
summary: summary || undefined,
|
||||||
|
changes: changes || undefined,
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("감사 로그 기록 실패", { error: error.message });
|
||||||
|
res.status(500).json({ success: false, message: "감사 로그 기록 실패" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { AuthService } from "../services/authService";
|
||||||
import { JwtUtils } from "../utils/jwtUtils";
|
import { JwtUtils } from "../utils/jwtUtils";
|
||||||
import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth";
|
import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import { sendSmartFactoryLog } from "../utils/smartFactoryLog";
|
||||||
|
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
/**
|
/**
|
||||||
|
|
@ -50,29 +51,24 @@ export class AuthController {
|
||||||
|
|
||||||
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
|
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
|
||||||
|
|
||||||
|
// 메뉴 조회를 위한 공통 파라미터
|
||||||
|
const { AdminService } = await import("../services/adminService");
|
||||||
|
const paramMap = {
|
||||||
|
userId: loginResult.userInfo.userId,
|
||||||
|
userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN",
|
||||||
|
userType: loginResult.userInfo.userType,
|
||||||
|
userLang: "ko",
|
||||||
|
};
|
||||||
|
|
||||||
// 사용자의 첫 번째 접근 가능한 메뉴 조회
|
// 사용자의 첫 번째 접근 가능한 메뉴 조회
|
||||||
let firstMenuPath: string | null = null;
|
let firstMenuPath: string | null = null;
|
||||||
try {
|
try {
|
||||||
const { AdminService } = await import("../services/adminService");
|
|
||||||
const paramMap = {
|
|
||||||
userId: loginResult.userInfo.userId,
|
|
||||||
userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN",
|
|
||||||
userType: loginResult.userInfo.userType,
|
|
||||||
userLang: "ko",
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||||
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
||||||
|
|
||||||
// 접근 가능한 첫 번째 메뉴 찾기
|
|
||||||
// 조건:
|
|
||||||
// 1. LEV (레벨)이 2 이상 (최상위 폴더 제외)
|
|
||||||
// 2. MENU_URL이 있고 비어있지 않음
|
|
||||||
// 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴
|
|
||||||
const firstMenu = menuList.find((menu: any) => {
|
const firstMenu = menuList.find((menu: any) => {
|
||||||
const level = menu.lev || menu.level;
|
const level = menu.lev || menu.level;
|
||||||
const url = menu.menu_url || menu.url;
|
const url = menu.menu_url || menu.url;
|
||||||
|
|
||||||
return level >= 2 && url && url.trim() !== "" && url !== "#";
|
return level >= 2 && url && url.trim() !== "" && url !== "#";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -86,13 +82,37 @@ export class AuthController {
|
||||||
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함)
|
||||||
|
sendSmartFactoryLog({
|
||||||
|
userId: userInfo.userId,
|
||||||
|
remoteAddr,
|
||||||
|
useType: "접속",
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// POP 랜딩 경로 조회
|
||||||
|
let popLandingPath: string | null = null;
|
||||||
|
try {
|
||||||
|
const popResult = await AdminService.getPopMenuList(paramMap);
|
||||||
|
if (popResult.landingMenu?.menu_url) {
|
||||||
|
popLandingPath = popResult.landingMenu.menu_url;
|
||||||
|
} else if (popResult.childMenus.length === 1) {
|
||||||
|
popLandingPath = popResult.childMenus[0].menu_url;
|
||||||
|
} else if (popResult.childMenus.length > 1) {
|
||||||
|
popLandingPath = "/pop";
|
||||||
|
}
|
||||||
|
logger.debug(`POP 랜딩 경로: ${popLandingPath}`);
|
||||||
|
} catch (popError) {
|
||||||
|
logger.warn("POP 메뉴 조회 중 오류 (무시):", popError);
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "로그인 성공",
|
message: "로그인 성공",
|
||||||
data: {
|
data: {
|
||||||
userInfo,
|
userInfo,
|
||||||
token: loginResult.token,
|
token: loginResult.token,
|
||||||
firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가
|
firstMenuPath,
|
||||||
|
popLandingPath,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Router, Request, Response } from "express";
|
||||||
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
|
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ router.use(authenticateToken);
|
||||||
interface AuthenticatedRequest extends Request {
|
interface AuthenticatedRequest extends Request {
|
||||||
user?: {
|
user?: {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
userName: string;
|
||||||
companyCode: string;
|
companyCode: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -157,6 +159,21 @@ router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|
||||||
const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy);
|
const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName,
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "CODE_CATEGORY",
|
||||||
|
resourceId: String(value.valueId),
|
||||||
|
resourceName: input.valueLabel,
|
||||||
|
tableName: "category_values",
|
||||||
|
summary: `카테고리 값 "${input.valueLabel}" 생성 (${input.tableName}.${input.columnName})`,
|
||||||
|
changes: { after: { tableName: input.tableName, columnName: input.columnName, valueCode: input.valueCode, valueLabel: input.valueLabel } },
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: value,
|
data: value,
|
||||||
|
|
@ -182,6 +199,7 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
const updatedBy = req.user?.userId;
|
const updatedBy = req.user?.userId;
|
||||||
|
|
||||||
|
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||||
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
|
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|
@ -191,6 +209,24 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName,
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "CODE_CATEGORY",
|
||||||
|
resourceId: valueId,
|
||||||
|
resourceName: value.valueLabel,
|
||||||
|
tableName: "category_values",
|
||||||
|
summary: `카테고리 값 "${value.valueLabel}" 수정 (${value.tableName}.${value.columnName})`,
|
||||||
|
changes: {
|
||||||
|
before: beforeValue ? { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode } : undefined,
|
||||||
|
after: input,
|
||||||
|
},
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: value,
|
data: value,
|
||||||
|
|
@ -239,6 +275,7 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
||||||
const { valueId } = req.params;
|
const { valueId } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||||
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
|
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
|
|
@ -248,6 +285,21 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName,
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "CODE_CATEGORY",
|
||||||
|
resourceId: valueId,
|
||||||
|
resourceName: beforeValue?.valueLabel || valueId,
|
||||||
|
tableName: "category_values",
|
||||||
|
summary: `카테고리 값 "${beforeValue?.valueLabel || valueId}" 삭제 (${beforeValue?.tableName || ""}.${beforeValue?.columnName || ""})`,
|
||||||
|
changes: beforeValue ? { before: { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode, tableName: beforeValue.tableName, columnName: beforeValue.columnName } } : undefined,
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "삭제되었습니다",
|
message: "삭제되었습니다",
|
||||||
|
|
|
||||||
|
|
@ -396,6 +396,20 @@ export class CommonCodeController {
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: companyCode || "",
|
||||||
|
userId: userId || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "CODE",
|
||||||
|
resourceId: codeValue,
|
||||||
|
resourceName: codeData.codeName || codeValue,
|
||||||
|
tableName: "code_info",
|
||||||
|
summary: `코드 "${categoryCode}.${codeValue}" 수정`,
|
||||||
|
changes: { after: codeData },
|
||||||
|
ipAddress: getClientIp(req as any),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: code,
|
data: code,
|
||||||
|
|
@ -440,6 +454,19 @@ export class CommonCodeController {
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "CODE",
|
||||||
|
resourceId: codeValue,
|
||||||
|
tableName: "code_info",
|
||||||
|
summary: `코드 "${categoryCode}.${codeValue}" 삭제`,
|
||||||
|
changes: { before: { categoryCode, codeValue } },
|
||||||
|
ipAddress: getClientIp(req as any),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "코드 삭제 성공",
|
message: "코드 삭제 성공",
|
||||||
|
|
|
||||||
|
|
@ -438,6 +438,19 @@ export class DDLController {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: userCompanyCode || "",
|
||||||
|
userId,
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "TABLE",
|
||||||
|
resourceId: tableName,
|
||||||
|
resourceName: tableName,
|
||||||
|
tableName,
|
||||||
|
summary: `테이블 "${tableName}" 삭제`,
|
||||||
|
ipAddress: getClientIp(req as any),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: result.message,
|
message: result.message,
|
||||||
|
|
|
||||||
|
|
@ -266,7 +266,6 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
||||||
logger.info("컬럼 DISTINCT 값 조회 성공", {
|
logger.info("컬럼 DISTINCT 값 조회 성공", {
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
columnInputType: columnInputType || "none",
|
|
||||||
labelColumn: effectiveLabelColumn,
|
labelColumn: effectiveLabelColumn,
|
||||||
companyCode,
|
companyCode,
|
||||||
hasFilters: !!filtersParam,
|
hasFilters: !!filtersParam,
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,7 @@ router.post(
|
||||||
auditLogService.log({
|
auditLogService.log({
|
||||||
companyCode,
|
companyCode,
|
||||||
userId,
|
userId,
|
||||||
|
userName: req.user?.userName,
|
||||||
action: "CREATE",
|
action: "CREATE",
|
||||||
resourceType: "NUMBERING_RULE",
|
resourceType: "NUMBERING_RULE",
|
||||||
resourceId: String(newRule.ruleId),
|
resourceId: String(newRule.ruleId),
|
||||||
|
|
@ -243,6 +244,7 @@ router.put(
|
||||||
auditLogService.log({
|
auditLogService.log({
|
||||||
companyCode,
|
companyCode,
|
||||||
userId: req.user?.userId || "",
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName,
|
||||||
action: "UPDATE",
|
action: "UPDATE",
|
||||||
resourceType: "NUMBERING_RULE",
|
resourceType: "NUMBERING_RULE",
|
||||||
resourceId: ruleId,
|
resourceId: ruleId,
|
||||||
|
|
@ -285,6 +287,7 @@ router.delete(
|
||||||
auditLogService.log({
|
auditLogService.log({
|
||||||
companyCode,
|
companyCode,
|
||||||
userId: req.user?.userId || "",
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName,
|
||||||
action: "DELETE",
|
action: "DELETE",
|
||||||
resourceType: "NUMBERING_RULE",
|
resourceType: "NUMBERING_RULE",
|
||||||
resourceId: ruleId,
|
resourceId: ruleId,
|
||||||
|
|
@ -521,6 +524,56 @@ router.post(
|
||||||
companyCode,
|
companyCode,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isUpdate = !!ruleConfig.ruleId;
|
||||||
|
|
||||||
|
const resetPeriodLabel: Record<string, string> = {
|
||||||
|
none: "초기화 안함", daily: "일별", monthly: "월별", yearly: "연별",
|
||||||
|
};
|
||||||
|
const partTypeLabel: Record<string, string> = {
|
||||||
|
sequence: "순번", number: "숫자", date: "날짜", text: "문자", category: "카테고리", reference: "참조",
|
||||||
|
};
|
||||||
|
const partsDescription = (ruleConfig.parts || [])
|
||||||
|
.sort((a: any, b: any) => (a.order || 0) - (b.order || 0))
|
||||||
|
.map((p: any) => {
|
||||||
|
const type = partTypeLabel[p.partType] || p.partType;
|
||||||
|
if (p.partType === "text" && p.autoConfig?.textValue) return `${type}("${p.autoConfig.textValue}")`;
|
||||||
|
if (p.partType === "sequence" && p.autoConfig?.sequenceLength) return `${type}(${p.autoConfig.sequenceLength}자리)`;
|
||||||
|
if (p.partType === "date" && p.autoConfig?.dateFormat) return `${type}(${p.autoConfig.dateFormat})`;
|
||||||
|
if (p.partType === "category") return `${type}(${p.autoConfig?.categoryKey || ""})`;
|
||||||
|
if (p.partType === "reference") return `${type}(${p.autoConfig?.referenceColumnName || ""})`;
|
||||||
|
return type;
|
||||||
|
})
|
||||||
|
.join(` ${ruleConfig.separator || "-"} `);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
userName: req.user?.userName,
|
||||||
|
action: isUpdate ? "UPDATE" : "CREATE",
|
||||||
|
resourceType: "NUMBERING_RULE",
|
||||||
|
resourceId: String(savedRule.ruleId),
|
||||||
|
resourceName: ruleConfig.ruleName,
|
||||||
|
tableName: "numbering_rules",
|
||||||
|
summary: isUpdate
|
||||||
|
? `채번 규칙 "${ruleConfig.ruleName}" 수정`
|
||||||
|
: `채번 규칙 "${ruleConfig.ruleName}" 생성`,
|
||||||
|
changes: {
|
||||||
|
after: {
|
||||||
|
규칙명: ruleConfig.ruleName,
|
||||||
|
적용테이블: ruleConfig.tableName || "(미지정)",
|
||||||
|
적용컬럼: ruleConfig.columnName || "(미지정)",
|
||||||
|
구분자: ruleConfig.separator || "-",
|
||||||
|
리셋주기: resetPeriodLabel[ruleConfig.resetPeriod] || ruleConfig.resetPeriod || "초기화 안함",
|
||||||
|
적용범위: ruleConfig.scopeType === "menu" ? "메뉴별" : "전역",
|
||||||
|
코드구성: partsDescription || "(파트 없음)",
|
||||||
|
파트수: (ruleConfig.parts || []).length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({ success: true, data: savedRule });
|
return res.json({ success: true, data: savedRule });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
||||||
|
|
@ -535,10 +588,25 @@ router.delete(
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
|
const userId = req.user!.userId;
|
||||||
const { ruleId } = req.params;
|
const { ruleId } = req.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
|
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
userName: req.user?.userName,
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "NUMBERING_RULE",
|
||||||
|
resourceId: ruleId,
|
||||||
|
tableName: "numbering_rules",
|
||||||
|
summary: `채번 규칙(ID:${ruleId}) 삭제`,
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "테스트 채번 규칙이 삭제되었습니다",
|
message: "테스트 채번 규칙이 삭제되었습니다",
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,26 +30,68 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon
|
||||||
routingTable = "item_routing_version",
|
routingTable = "item_routing_version",
|
||||||
routingFkColumn = "item_code",
|
routingFkColumn = "item_code",
|
||||||
search = "",
|
search = "",
|
||||||
|
extraColumns = "",
|
||||||
|
filterConditions = "",
|
||||||
} = req.query as Record<string, string>;
|
} = req.query as Record<string, string>;
|
||||||
|
|
||||||
const searchCondition = search
|
|
||||||
? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)`
|
|
||||||
: "";
|
|
||||||
const params: any[] = [companyCode];
|
const params: any[] = [companyCode];
|
||||||
if (search) params.push(`%${search}%`);
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
// 검색 조건
|
||||||
|
let searchCondition = "";
|
||||||
|
if (search) {
|
||||||
|
searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추가 컬럼 SELECT
|
||||||
|
const extraColumnNames: string[] = extraColumns
|
||||||
|
? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||||
|
const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||||
|
|
||||||
|
// 사전 필터 조건
|
||||||
|
let filterWhere = "";
|
||||||
|
if (filterConditions) {
|
||||||
|
try {
|
||||||
|
const filters = JSON.parse(filterConditions) as Array<{
|
||||||
|
column: string;
|
||||||
|
operator: string;
|
||||||
|
value: string;
|
||||||
|
}>;
|
||||||
|
for (const f of filters) {
|
||||||
|
if (!f.column || !f.value) continue;
|
||||||
|
if (f.operator === "equals") {
|
||||||
|
filterWhere += ` AND i.${f.column} = $${paramIndex}`;
|
||||||
|
params.push(f.value);
|
||||||
|
} else if (f.operator === "contains") {
|
||||||
|
filterWhere += ` AND i.${f.column} ILIKE $${paramIndex}`;
|
||||||
|
params.push(`%${f.value}%`);
|
||||||
|
} else if (f.operator === "not_equals") {
|
||||||
|
filterWhere += ` AND i.${f.column} != $${paramIndex}`;
|
||||||
|
params.push(f.value);
|
||||||
|
}
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
} catch { /* 파싱 실패 시 무시 */ }
|
||||||
|
}
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
i.id,
|
i.id,
|
||||||
i.${nameColumn} AS item_name,
|
i.${nameColumn} AS item_name,
|
||||||
i.${codeColumn} AS item_code,
|
i.${codeColumn} AS item_code
|
||||||
|
${extraSelect ? ", " + extraSelect : ""},
|
||||||
COUNT(rv.id) AS routing_count
|
COUNT(rv.id) AS routing_count
|
||||||
FROM ${tableName} i
|
FROM ${tableName} i
|
||||||
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||||
AND rv.company_code = i.company_code
|
AND rv.company_code = i.company_code
|
||||||
WHERE i.company_code = $1
|
WHERE i.company_code = $1
|
||||||
${searchCondition}
|
${searchCondition}
|
||||||
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date
|
${filterWhere}
|
||||||
|
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}, i.created_date
|
||||||
ORDER BY i.created_date DESC NULLS LAST
|
ORDER BY i.created_date DESC NULLS LAST
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -711,3 +753,184 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
||||||
client.release();
|
client.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 등록 품목 관리 (item_routing_registered)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면별 등록된 품목 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getRegisteredItems(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { screenCode } = req.params;
|
||||||
|
const {
|
||||||
|
tableName = "item_info",
|
||||||
|
nameColumn = "item_name",
|
||||||
|
codeColumn = "item_number",
|
||||||
|
routingTable = "item_routing_version",
|
||||||
|
routingFkColumn = "item_code",
|
||||||
|
search = "",
|
||||||
|
extraColumns = "",
|
||||||
|
} = req.query as Record<string, string>;
|
||||||
|
|
||||||
|
const params: any[] = [companyCode, screenCode];
|
||||||
|
let paramIndex = 3;
|
||||||
|
|
||||||
|
let searchCondition = "";
|
||||||
|
if (search) {
|
||||||
|
searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraColumnNames: string[] = extraColumns
|
||||||
|
? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||||
|
const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
irr.id AS registered_id,
|
||||||
|
irr.sort_order,
|
||||||
|
i.id,
|
||||||
|
i.${nameColumn} AS item_name,
|
||||||
|
i.${codeColumn} AS item_code
|
||||||
|
${extraSelect ? ", " + extraSelect : ""},
|
||||||
|
COUNT(rv.id) AS routing_count
|
||||||
|
FROM item_routing_registered irr
|
||||||
|
JOIN ${tableName} i ON irr.item_id = i.id
|
||||||
|
AND i.company_code = irr.company_code
|
||||||
|
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||||
|
AND rv.company_code = i.company_code
|
||||||
|
WHERE irr.company_code = $1
|
||||||
|
AND irr.screen_code = $2
|
||||||
|
${searchCondition}
|
||||||
|
GROUP BY irr.id, irr.sort_order, i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}
|
||||||
|
ORDER BY CAST(irr.sort_order AS int) ASC, irr.created_date ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, params);
|
||||||
|
return res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("등록 품목 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 품목 등록 (화면에 품목 추가)
|
||||||
|
*/
|
||||||
|
export async function registerItem(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { screenCode, itemId, itemCode } = req.body;
|
||||||
|
if (!screenCode || !itemId) {
|
||||||
|
return res.status(400).json({ success: false, message: "screenCode, itemId 필수" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
const result = await getPool().query(query, [
|
||||||
|
screenCode, itemId, itemCode || null, companyCode, req.user?.userId || null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.json({ success: true, message: "이미 등록된 품목입니다", data: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("품목 등록", { companyCode, screenCode, itemId });
|
||||||
|
return res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("품목 등록 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 품목 일괄 등록
|
||||||
|
*/
|
||||||
|
export async function registerItemsBatch(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { screenCode, items } = req.body;
|
||||||
|
if (!screenCode || !Array.isArray(items) || items.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, message: "screenCode, items[] 필수" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getPool().connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const inserted: any[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const result = await client.query(
|
||||||
|
`INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING
|
||||||
|
RETURNING *`,
|
||||||
|
[screenCode, item.itemId, item.itemCode || null, companyCode, req.user?.userId || null]
|
||||||
|
);
|
||||||
|
if (result.rows[0]) inserted.push(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("품목 일괄 등록", { companyCode, screenCode, count: inserted.length });
|
||||||
|
return res.json({ success: true, data: inserted });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("품목 일괄 등록 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등록 품목 제거
|
||||||
|
*/
|
||||||
|
export async function unregisterItem(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await getPool().query(
|
||||||
|
`DELETE FROM item_routing_registered WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("등록 품목 제거", { companyCode, id });
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("등록 품목 제거 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -614,20 +614,6 @@ export const copyScreenWithModals = async (
|
||||||
modalScreens: modalScreens || [],
|
modalScreens: modalScreens || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
auditLogService.log({
|
|
||||||
companyCode: targetCompanyCode || companyCode,
|
|
||||||
userId: userId || "",
|
|
||||||
userName: (req.user as any)?.userName || "",
|
|
||||||
action: "COPY",
|
|
||||||
resourceType: "SCREEN",
|
|
||||||
resourceId: id,
|
|
||||||
resourceName: mainScreen?.screenName,
|
|
||||||
summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`,
|
|
||||||
changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } },
|
|
||||||
ipAddress: getClientIp(req),
|
|
||||||
requestPath: req.originalUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result,
|
data: result,
|
||||||
|
|
@ -663,20 +649,6 @@ export const copyScreen = async (
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
auditLogService.log({
|
|
||||||
companyCode,
|
|
||||||
userId: userId || "",
|
|
||||||
userName: (req.user as any)?.userName || "",
|
|
||||||
action: "COPY",
|
|
||||||
resourceType: "SCREEN",
|
|
||||||
resourceId: String(copiedScreen?.screenId || ""),
|
|
||||||
resourceName: screenName,
|
|
||||||
summary: `화면 "${screenName}" 복사 (원본 ID:${id})`,
|
|
||||||
changes: { after: { sourceScreenId: id, screenName, screenCode } },
|
|
||||||
ipAddress: getClientIp(req),
|
|
||||||
requestPath: req.originalUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: copiedScreen,
|
data: copiedScreen,
|
||||||
|
|
|
||||||
|
|
@ -62,24 +62,31 @@ export const getAllCategoryColumns = async (req: AuthenticatedRequest, res: Resp
|
||||||
*/
|
*/
|
||||||
export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => {
|
export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user!.companyCode;
|
const userCompanyCode = req.user!.companyCode;
|
||||||
const { tableName, columnName } = req.params;
|
const { tableName, columnName } = req.params;
|
||||||
const includeInactive = req.query.includeInactive === "true";
|
const includeInactive = req.query.includeInactive === "true";
|
||||||
const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined;
|
const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined;
|
||||||
|
const filterCompanyCode = req.query.filterCompanyCode as string | undefined;
|
||||||
|
|
||||||
|
// 최고관리자가 특정 회사 기준 필터링을 요청한 경우 해당 회사 코드 사용
|
||||||
|
const effectiveCompanyCode = (userCompanyCode === "*" && filterCompanyCode)
|
||||||
|
? filterCompanyCode
|
||||||
|
: userCompanyCode;
|
||||||
|
|
||||||
logger.info("카테고리 값 조회 요청", {
|
logger.info("카테고리 값 조회 요청", {
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
companyCode,
|
companyCode: effectiveCompanyCode,
|
||||||
|
filterCompanyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const values = await tableCategoryValueService.getCategoryValues(
|
const values = await tableCategoryValueService.getCategoryValues(
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
companyCode,
|
effectiveCompanyCode,
|
||||||
includeInactive,
|
includeInactive,
|
||||||
menuObjid // ← menuObjid 전달
|
menuObjid
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
|
||||||
|
|
@ -963,6 +963,15 @@ export async function addTableData(
|
||||||
|
|
||||||
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
|
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
|
||||||
|
|
||||||
|
const systemFields = new Set([
|
||||||
|
"id", "created_date", "updated_date", "writer", "company_code",
|
||||||
|
"createdDate", "updatedDate", "companyCode",
|
||||||
|
]);
|
||||||
|
const auditData: Record<string, any> = {};
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
if (!systemFields.has(k)) auditData[k] = v;
|
||||||
|
}
|
||||||
|
|
||||||
auditLogService.log({
|
auditLogService.log({
|
||||||
companyCode: req.user?.companyCode || "",
|
companyCode: req.user?.companyCode || "",
|
||||||
userId: req.user?.userId || "",
|
userId: req.user?.userId || "",
|
||||||
|
|
@ -973,7 +982,7 @@ export async function addTableData(
|
||||||
resourceName: tableName,
|
resourceName: tableName,
|
||||||
tableName,
|
tableName,
|
||||||
summary: `${tableName} 데이터 추가`,
|
summary: `${tableName} 데이터 추가`,
|
||||||
changes: { after: data },
|
changes: { after: auditData },
|
||||||
ipAddress: getClientIp(req),
|
ipAddress: getClientIp(req),
|
||||||
requestPath: req.originalUrl,
|
requestPath: req.originalUrl,
|
||||||
});
|
});
|
||||||
|
|
@ -1096,10 +1105,14 @@ export async function editTableData(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 변경된 필드만 추출
|
const systemFieldsForEdit = new Set([
|
||||||
|
"id", "created_date", "updated_date", "writer", "company_code",
|
||||||
|
"createdDate", "updatedDate", "companyCode",
|
||||||
|
]);
|
||||||
const changedBefore: Record<string, any> = {};
|
const changedBefore: Record<string, any> = {};
|
||||||
const changedAfter: Record<string, any> = {};
|
const changedAfter: Record<string, any> = {};
|
||||||
for (const key of Object.keys(updatedData)) {
|
for (const key of Object.keys(updatedData)) {
|
||||||
|
if (systemFieldsForEdit.has(key)) continue;
|
||||||
if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) {
|
if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) {
|
||||||
changedBefore[key] = originalData[key];
|
changedBefore[key] = originalData[key];
|
||||||
changedAfter[key] = updatedData[key];
|
changedAfter[key] = updatedData[key];
|
||||||
|
|
@ -3105,3 +3118,153 @@ export async function getNumberingColumnsByCompany(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 업로드 전 데이터 검증
|
||||||
|
* POST /api/table-management/validate-excel
|
||||||
|
* Body: { tableName, data: Record<string,any>[] }
|
||||||
|
*/
|
||||||
|
export async function validateExcelData(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName, data } = req.body as {
|
||||||
|
tableName: string;
|
||||||
|
data: Record<string, any>[];
|
||||||
|
};
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!tableName || !Array.isArray(data) || data.length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: "tableName과 data 배열이 필요합니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveCompanyCode =
|
||||||
|
companyCode === "*" && data[0]?.company_code && data[0].company_code !== "*"
|
||||||
|
? data[0].company_code
|
||||||
|
: companyCode;
|
||||||
|
|
||||||
|
let constraintCols = await query<{
|
||||||
|
column_name: string;
|
||||||
|
column_label: string;
|
||||||
|
is_nullable: string;
|
||||||
|
is_unique: string;
|
||||||
|
}>(
|
||||||
|
`SELECT column_name,
|
||||||
|
COALESCE(column_label, column_name) as column_label,
|
||||||
|
COALESCE(is_nullable, 'Y') as is_nullable,
|
||||||
|
COALESCE(is_unique, 'N') as is_unique
|
||||||
|
FROM table_type_columns
|
||||||
|
WHERE table_name = $1 AND company_code = $2`,
|
||||||
|
[tableName, effectiveCompanyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (constraintCols.length === 0 && effectiveCompanyCode !== "*") {
|
||||||
|
constraintCols = await query(
|
||||||
|
`SELECT column_name,
|
||||||
|
COALESCE(column_label, column_name) as column_label,
|
||||||
|
COALESCE(is_nullable, 'Y') as is_nullable,
|
||||||
|
COALESCE(is_unique, 'N') as is_unique
|
||||||
|
FROM table_type_columns
|
||||||
|
WHERE table_name = $1 AND company_code = '*'`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoGenCols = ["id", "created_date", "updated_date", "writer", "company_code"];
|
||||||
|
const notNullCols = constraintCols.filter((c) => c.is_nullable === "N" && !autoGenCols.includes(c.column_name));
|
||||||
|
const uniqueCols = constraintCols.filter((c) => c.is_unique === "Y" && !autoGenCols.includes(c.column_name));
|
||||||
|
|
||||||
|
const notNullErrors: { row: number; column: string; label: string }[] = [];
|
||||||
|
const uniqueInExcelErrors: { rows: number[]; column: string; label: string; value: string }[] = [];
|
||||||
|
const uniqueInDbErrors: { row: number; column: string; label: string; value: string }[] = [];
|
||||||
|
|
||||||
|
// NOT NULL 검증
|
||||||
|
for (const col of notNullCols) {
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const val = data[i][col.column_name];
|
||||||
|
if (val === null || val === undefined || String(val).trim() === "") {
|
||||||
|
notNullErrors.push({ row: i + 1, column: col.column_name, label: col.column_label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UNIQUE: 엑셀 내부 중복
|
||||||
|
for (const col of uniqueCols) {
|
||||||
|
const seen = new Map<string, number[]>();
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const val = data[i][col.column_name];
|
||||||
|
if (val === null || val === undefined || String(val).trim() === "") continue;
|
||||||
|
const key = String(val).trim();
|
||||||
|
if (!seen.has(key)) seen.set(key, []);
|
||||||
|
seen.get(key)!.push(i + 1);
|
||||||
|
}
|
||||||
|
for (const [value, rows] of seen) {
|
||||||
|
if (rows.length > 1) {
|
||||||
|
uniqueInExcelErrors.push({ rows, column: col.column_name, label: col.column_label, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UNIQUE: DB 기존 데이터와 중복
|
||||||
|
const hasCompanyCode = await query(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const col of uniqueCols) {
|
||||||
|
const values = [...new Set(
|
||||||
|
data
|
||||||
|
.map((row) => row[col.column_name])
|
||||||
|
.filter((v) => v !== null && v !== undefined && String(v).trim() !== "")
|
||||||
|
.map((v) => String(v).trim())
|
||||||
|
)];
|
||||||
|
if (values.length === 0) continue;
|
||||||
|
|
||||||
|
let dupQuery: string;
|
||||||
|
let dupParams: any[];
|
||||||
|
const targetCompany = data[0]?.company_code || (effectiveCompanyCode !== "*" ? effectiveCompanyCode : null);
|
||||||
|
|
||||||
|
if (hasCompanyCode.length > 0 && targetCompany) {
|
||||||
|
dupQuery = `SELECT "${col.column_name}" FROM "${tableName}" WHERE "${col.column_name}" = ANY($1) AND company_code = $2`;
|
||||||
|
dupParams = [values, targetCompany];
|
||||||
|
} else {
|
||||||
|
dupQuery = `SELECT "${col.column_name}" FROM "${tableName}" WHERE "${col.column_name}" = ANY($1)`;
|
||||||
|
dupParams = [values];
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRows = await query<Record<string, any>>(dupQuery, dupParams);
|
||||||
|
const existingSet = new Set(existingRows.map((r) => String(r[col.column_name]).trim()));
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const val = data[i][col.column_name];
|
||||||
|
if (val === null || val === undefined || String(val).trim() === "") continue;
|
||||||
|
if (existingSet.has(String(val).trim())) {
|
||||||
|
uniqueInDbErrors.push({ row: i + 1, column: col.column_name, label: col.column_label, value: String(val) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = notNullErrors.length === 0 && uniqueInExcelErrors.length === 0 && uniqueInDbErrors.length === 0;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
isValid,
|
||||||
|
notNullErrors,
|
||||||
|
uniqueInExcelErrors,
|
||||||
|
uniqueInDbErrors,
|
||||||
|
summary: {
|
||||||
|
notNull: notNullErrors.length,
|
||||||
|
uniqueInExcel: uniqueInExcelErrors.length,
|
||||||
|
uniqueInDb: uniqueInDbErrors.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("엑셀 데이터 검증 오류:", error);
|
||||||
|
res.status(500).json({ success: false, message: "데이터 검증 중 오류가 발생했습니다." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Router } from "express";
|
||||||
import {
|
import {
|
||||||
getAdminMenus,
|
getAdminMenus,
|
||||||
getUserMenus,
|
getUserMenus,
|
||||||
|
getPopMenus,
|
||||||
getMenuInfo,
|
getMenuInfo,
|
||||||
saveMenu, // 메뉴 추가
|
saveMenu, // 메뉴 추가
|
||||||
updateMenu, // 메뉴 수정
|
updateMenu, // 메뉴 수정
|
||||||
|
|
@ -40,6 +41,7 @@ router.use(authenticateToken);
|
||||||
// 메뉴 관련 API
|
// 메뉴 관련 API
|
||||||
router.get("/menus", getAdminMenus);
|
router.get("/menus", getAdminMenus);
|
||||||
router.get("/user-menus", getUserMenus);
|
router.get("/user-menus", getUserMenus);
|
||||||
|
router.get("/pop-menus", getPopMenus);
|
||||||
router.get("/menus/:menuId", getMenuInfo);
|
router.get("/menus/:menuId", getMenuInfo);
|
||||||
router.post("/menus", saveMenu); // 메뉴 추가
|
router.post("/menus", saveMenu); // 메뉴 추가
|
||||||
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)
|
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { getAuditLogs, getAuditLogStats, getAuditLogUsers } from "../controllers/auditLogController";
|
import { getAuditLogs, getAuditLogStats, getAuditLogUsers, createAuditLog } from "../controllers/auditLogController";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get("/", authenticateToken, getAuditLogs);
|
router.get("/", authenticateToken, getAuditLogs);
|
||||||
router.get("/stats", authenticateToken, getAuditLogStats);
|
router.get("/stats", authenticateToken, getAuditLogStats);
|
||||||
router.get("/users", authenticateToken, getAuditLogUsers);
|
router.get("/users", authenticateToken, getAuditLogUsers);
|
||||||
|
router.post("/", authenticateToken, createAuditLog);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { logger } from "../../utils/logger";
|
||||||
import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService";
|
import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService";
|
||||||
import { AuthenticatedRequest } from "../../types/auth";
|
import { AuthenticatedRequest } from "../../types/auth";
|
||||||
import { authenticateToken } from "../../middleware/authMiddleware";
|
import { authenticateToken } from "../../middleware/authMiddleware";
|
||||||
|
import { auditLogService, getClientIp } from "../../services/auditLogService";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -124,6 +125,21 @@ router.post("/", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
`플로우 저장 성공: ${result.flowId} (회사: ${userCompanyCode})`
|
`플로우 저장 성공: ${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({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "플로우가 저장되었습니다.",
|
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 {
|
try {
|
||||||
const { flowId, flowName, flowDescription, flowData } = req.body;
|
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(
|
await query(
|
||||||
`
|
`
|
||||||
UPDATE node_flows
|
UPDATE node_flows
|
||||||
|
|
@ -168,6 +189,25 @@ router.put("/", async (req: Request, res: Response) => {
|
||||||
|
|
||||||
logger.info(`플로우 수정 성공: ${flowId}`);
|
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({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "플로우가 수정되었습니다.",
|
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 {
|
try {
|
||||||
const { flowId } = req.params;
|
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(
|
await query(
|
||||||
`
|
`
|
||||||
DELETE FROM node_flows
|
DELETE FROM node_flows
|
||||||
|
|
@ -201,6 +246,25 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
|
||||||
|
|
||||||
logger.info(`플로우 삭제 성공: ${flowId}`);
|
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({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "플로우가 삭제되었습니다.",
|
message: "플로우가 삭제되었습니다.",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +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);
|
||||||
|
|
||||||
|
// 포장단위
|
||||||
|
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;
|
||||||
|
|
@ -17,6 +17,7 @@ interface AutoGenMappingInfo {
|
||||||
numberingRuleId: string;
|
numberingRuleId: string;
|
||||||
targetColumn: string;
|
targetColumn: string;
|
||||||
showResultModal?: boolean;
|
showResultModal?: boolean;
|
||||||
|
shareAcrossItems?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HiddenMappingInfo {
|
interface HiddenMappingInfo {
|
||||||
|
|
@ -182,6 +183,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
|
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allAutoGen = [
|
||||||
|
...(fieldMapping?.autoGenMappings ?? []),
|
||||||
|
...(cardMapping?.autoGenMappings ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번
|
||||||
|
const sharedCodes: Record<string, string> = {};
|
||||||
|
for (const ag of allAutoGen) {
|
||||||
|
if (!ag.shareAcrossItems) continue;
|
||||||
|
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||||
|
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||||
|
try {
|
||||||
|
const code = await numberingRuleService.allocateCode(
|
||||||
|
ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) },
|
||||||
|
);
|
||||||
|
sharedCodes[ag.targetColumn] = code;
|
||||||
|
generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false });
|
||||||
|
logger.info("[pop/execute-action] 일괄 채번 완료", {
|
||||||
|
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const columns: string[] = ["company_code"];
|
const columns: string[] = ["company_code"];
|
||||||
const values: unknown[] = [companyCode];
|
const values: unknown[] = [companyCode];
|
||||||
|
|
@ -225,26 +251,41 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
values.push(value);
|
values.push(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allAutoGen = [
|
|
||||||
...(fieldMapping?.autoGenMappings ?? []),
|
|
||||||
...(cardMapping?.autoGenMappings ?? []),
|
|
||||||
];
|
|
||||||
for (const ag of allAutoGen) {
|
for (const ag of allAutoGen) {
|
||||||
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||||
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||||
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
||||||
try {
|
|
||||||
const generatedCode = await numberingRuleService.allocateCode(
|
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
|
||||||
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
|
|
||||||
);
|
|
||||||
columns.push(`"${ag.targetColumn}"`);
|
columns.push(`"${ag.targetColumn}"`);
|
||||||
values.push(generatedCode);
|
values.push(sharedCodes[ag.targetColumn]);
|
||||||
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
} else if (!ag.shareAcrossItems) {
|
||||||
} catch (err: any) {
|
try {
|
||||||
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
const generatedCode = await numberingRuleService.allocateCode(
|
||||||
|
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
|
||||||
|
);
|
||||||
|
columns.push(`"${ag.targetColumn}"`);
|
||||||
|
values.push(generatedCode);
|
||||||
|
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!columns.includes('"created_date"')) {
|
||||||
|
columns.push('"created_date"');
|
||||||
|
values.push(new Date().toISOString());
|
||||||
|
}
|
||||||
|
if (!columns.includes('"updated_date"')) {
|
||||||
|
columns.push('"updated_date"');
|
||||||
|
values.push(new Date().toISOString());
|
||||||
|
}
|
||||||
|
if (!columns.includes('"writer"') && userId) {
|
||||||
|
columns.push('"writer"');
|
||||||
|
values.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (columns.length > 1) {
|
if (columns.length > 1) {
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|
@ -292,8 +333,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
for (let i = 0; i < lookupValues.length; i++) {
|
for (let i = 0; i < lookupValues.length; i++) {
|
||||||
const item = items[i] ?? {};
|
const item = items[i] ?? {};
|
||||||
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
||||||
|
const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||||
[resolved, companyCode, lookupValues[i]],
|
[resolved, companyCode, lookupValues[i]],
|
||||||
);
|
);
|
||||||
processedCount++;
|
processedCount++;
|
||||||
|
|
@ -311,9 +353,10 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
|
|
||||||
const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`;
|
const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`;
|
||||||
|
|
||||||
|
const autoUpdatedDb = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", ");
|
const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", ");
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`,
|
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql}${autoUpdatedDb} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`,
|
||||||
[thenVal, elseVal, companyCode, ...lookupValues],
|
[thenVal, elseVal, companyCode, ...lookupValues],
|
||||||
);
|
);
|
||||||
processedCount += lookupValues.length;
|
processedCount += lookupValues.length;
|
||||||
|
|
@ -325,7 +368,14 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
if (valSource === "linked") {
|
if (valSource === "linked") {
|
||||||
value = item[task.sourceField ?? ""] ?? null;
|
value = item[task.sourceField ?? ""] ?? null;
|
||||||
} else {
|
} else {
|
||||||
value = task.fixedValue ?? "";
|
const raw = task.fixedValue ?? "";
|
||||||
|
if (raw === "__CURRENT_USER__") {
|
||||||
|
value = userId;
|
||||||
|
} else if (raw === "__CURRENT_TIME__") {
|
||||||
|
value = new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
value = raw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let setSql: string;
|
let setSql: string;
|
||||||
|
|
@ -341,8 +391,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
setSql = `"${task.targetColumn}" = $1`;
|
setSql = `"${task.targetColumn}" = $1`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||||
[value, companyCode, lookupValues[i]],
|
[value, companyCode, lookupValues[i]],
|
||||||
);
|
);
|
||||||
processedCount++;
|
processedCount++;
|
||||||
|
|
@ -448,6 +499,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
|
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allAutoGen = [
|
||||||
|
...(fieldMapping?.autoGenMappings ?? []),
|
||||||
|
...(cardMapping?.autoGenMappings ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번
|
||||||
|
const sharedCodes: Record<string, string> = {};
|
||||||
|
for (const ag of allAutoGen) {
|
||||||
|
if (!ag.shareAcrossItems) continue;
|
||||||
|
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||||
|
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||||
|
try {
|
||||||
|
const code = await numberingRuleService.allocateCode(
|
||||||
|
ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) },
|
||||||
|
);
|
||||||
|
sharedCodes[ag.targetColumn] = code;
|
||||||
|
generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false });
|
||||||
|
logger.info("[pop/execute-action] 일괄 채번 완료", {
|
||||||
|
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const columns: string[] = ["company_code"];
|
const columns: string[] = ["company_code"];
|
||||||
const values: unknown[] = [companyCode];
|
const values: unknown[] = [companyCode];
|
||||||
|
|
@ -467,7 +543,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
|
|
||||||
const allHidden = [
|
const allHidden = [
|
||||||
...(fieldMapping?.hiddenMappings ?? []),
|
...(fieldMapping?.hiddenMappings ?? []),
|
||||||
...(cardMapping?.hiddenMappings ?? []),
|
...(cardMapping?.hiddenMappings ?? []),
|
||||||
|
|
@ -494,37 +569,44 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
values.push(value);
|
values.push(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
|
|
||||||
const allAutoGen = [
|
|
||||||
...(fieldMapping?.autoGenMappings ?? []),
|
|
||||||
...(cardMapping?.autoGenMappings ?? []),
|
|
||||||
];
|
|
||||||
for (const ag of allAutoGen) {
|
for (const ag of allAutoGen) {
|
||||||
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||||
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||||
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
||||||
try {
|
|
||||||
const generatedCode = await numberingRuleService.allocateCode(
|
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
|
||||||
ag.numberingRuleId,
|
|
||||||
companyCode,
|
|
||||||
{ ...fieldValues, ...item },
|
|
||||||
);
|
|
||||||
columns.push(`"${ag.targetColumn}"`);
|
columns.push(`"${ag.targetColumn}"`);
|
||||||
values.push(generatedCode);
|
values.push(sharedCodes[ag.targetColumn]);
|
||||||
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
} else if (!ag.shareAcrossItems) {
|
||||||
logger.info("[pop/execute-action] 채번 완료", {
|
try {
|
||||||
ruleId: ag.numberingRuleId,
|
const generatedCode = await numberingRuleService.allocateCode(
|
||||||
targetColumn: ag.targetColumn,
|
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
|
||||||
generatedCode,
|
);
|
||||||
});
|
columns.push(`"${ag.targetColumn}"`);
|
||||||
} catch (err: any) {
|
values.push(generatedCode);
|
||||||
logger.error("[pop/execute-action] 채번 실패", {
|
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
||||||
ruleId: ag.numberingRuleId,
|
logger.info("[pop/execute-action] 채번 완료", {
|
||||||
error: err.message,
|
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode,
|
||||||
});
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!columns.includes('"created_date"')) {
|
||||||
|
columns.push('"created_date"');
|
||||||
|
values.push(new Date().toISOString());
|
||||||
|
}
|
||||||
|
if (!columns.includes('"updated_date"')) {
|
||||||
|
columns.push('"updated_date"');
|
||||||
|
values.push(new Date().toISOString());
|
||||||
|
}
|
||||||
|
if (!columns.includes('"writer"') && userId) {
|
||||||
|
columns.push('"writer"');
|
||||||
|
values.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (columns.length > 1) {
|
if (columns.length > 1) {
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||||
|
|
@ -558,6 +640,19 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
values.push(fieldValues[sourceField] ?? null);
|
values.push(fieldValues[sourceField] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!columns.includes('"created_date"')) {
|
||||||
|
columns.push('"created_date"');
|
||||||
|
values.push(new Date().toISOString());
|
||||||
|
}
|
||||||
|
if (!columns.includes('"updated_date"')) {
|
||||||
|
columns.push('"updated_date"');
|
||||||
|
values.push(new Date().toISOString());
|
||||||
|
}
|
||||||
|
if (!columns.includes('"writer"') && userId) {
|
||||||
|
columns.push('"writer"');
|
||||||
|
values.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (columns.length > 1) {
|
if (columns.length > 1) {
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||||
|
|
@ -609,16 +704,18 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
}
|
}
|
||||||
|
|
||||||
if (valueType === "fixed") {
|
if (valueType === "fixed") {
|
||||||
|
const autoUpd = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", ");
|
const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", ");
|
||||||
const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`;
|
const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd} WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`;
|
||||||
await client.query(sql, [fixedValue, companyCode, ...lookupValues]);
|
await client.query(sql, [fixedValue, companyCode, ...lookupValues]);
|
||||||
processedCount += lookupValues.length;
|
processedCount += lookupValues.length;
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < lookupValues.length; i++) {
|
for (let i = 0; i < lookupValues.length; i++) {
|
||||||
const item = items[i] ?? {};
|
const item = items[i] ?? {};
|
||||||
const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item);
|
const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item);
|
||||||
|
const autoUpd2 = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd2} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||||
[resolvedValue, companyCode, lookupValues[i]]
|
[resolvedValue, companyCode, lookupValues[i]]
|
||||||
);
|
);
|
||||||
processedCount++;
|
processedCount++;
|
||||||
|
|
|
||||||
|
|
@ -33,4 +33,10 @@ router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail);
|
||||||
// 전체 저장 (일괄)
|
// 전체 저장 (일괄)
|
||||||
router.put("/save-all", ctrl.saveAll);
|
router.put("/save-all", ctrl.saveAll);
|
||||||
|
|
||||||
|
// 등록 품목 관리 (화면별 품목 목록)
|
||||||
|
router.get("/registered-items/:screenCode", ctrl.getRegisteredItems);
|
||||||
|
router.post("/registered-items", ctrl.registerItem);
|
||||||
|
router.post("/registered-items/batch", ctrl.registerItemsBatch);
|
||||||
|
router.delete("/registered-items/:id", ctrl.unregisterItem);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회
|
getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회
|
||||||
getNumberingColumnsByCompany, // 채번 타입 컬럼 조회
|
getNumberingColumnsByCompany, // 채번 타입 컬럼 조회
|
||||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||||
|
validateExcelData, // 엑셀 업로드 전 데이터 검증
|
||||||
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||||
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
|
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
|
||||||
getTableConstraints, // 🆕 PK/인덱스 상태 조회
|
getTableConstraints, // 🆕 PK/인덱스 상태 조회
|
||||||
|
|
@ -280,4 +281,9 @@ router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
|
||||||
*/
|
*/
|
||||||
router.post("/multi-table-save", multiTableSave);
|
router.post("/multi-table-save", multiTableSave);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 업로드 전 데이터 검증
|
||||||
|
*/
|
||||||
|
router.post("/validate-excel", validateExcelData);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -621,6 +621,74 @@ export class AdminService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 메뉴 목록 조회
|
||||||
|
* menu_name_kor에 'POP'이 포함되거나 menu_desc에 [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환
|
||||||
|
* [POP_LANDING] 태그가 있는 하위 메뉴를 landingMenu로 별도 반환
|
||||||
|
*/
|
||||||
|
static async getPopMenuList(paramMap: any): Promise<{ parentMenu: any | null; childMenus: any[]; landingMenu: any | null }> {
|
||||||
|
try {
|
||||||
|
const { userCompanyCode, userType } = paramMap;
|
||||||
|
logger.info("AdminService.getPopMenuList 시작", { userCompanyCode, userType });
|
||||||
|
|
||||||
|
let queryParams: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
let companyFilter = "";
|
||||||
|
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
||||||
|
companyFilter = `AND COMPANY_CODE = '*'`;
|
||||||
|
} else {
|
||||||
|
companyFilter = `AND COMPANY_CODE = $${paramIndex}`;
|
||||||
|
queryParams.push(userCompanyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POP L1 메뉴 조회
|
||||||
|
const parentMenus = await query<any>(
|
||||||
|
`SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS
|
||||||
|
FROM MENU_INFO
|
||||||
|
WHERE PARENT_OBJ_ID = 0
|
||||||
|
AND MENU_TYPE = 1
|
||||||
|
AND (
|
||||||
|
MENU_DESC LIKE '%[POP]%'
|
||||||
|
OR UPPER(MENU_NAME_KOR) LIKE '%POP%'
|
||||||
|
)
|
||||||
|
${companyFilter}
|
||||||
|
ORDER BY SEQ
|
||||||
|
LIMIT 1`,
|
||||||
|
queryParams
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parentMenus.length === 0) {
|
||||||
|
logger.info("POP 메뉴 없음 (L1 POP 메뉴 미발견)");
|
||||||
|
return { parentMenu: null, childMenus: [], landingMenu: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentMenu = parentMenus[0];
|
||||||
|
|
||||||
|
// 하위 active 메뉴 조회 (부모와 같은 company_code로 필터링)
|
||||||
|
const childMenus = await query<any>(
|
||||||
|
`SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS
|
||||||
|
FROM MENU_INFO
|
||||||
|
WHERE PARENT_OBJ_ID = $1
|
||||||
|
AND STATUS = 'active'
|
||||||
|
AND COMPANY_CODE = $2
|
||||||
|
ORDER BY SEQ`,
|
||||||
|
[parentMenu.objid, parentMenu.company_code]
|
||||||
|
);
|
||||||
|
|
||||||
|
// [POP_LANDING] 태그가 있는 메뉴를 랜딩 화면으로 지정
|
||||||
|
const landingMenu = childMenus.find((m: any) => m.menu_desc?.includes("[POP_LANDING]")) || null;
|
||||||
|
|
||||||
|
logger.info(`POP 메뉴 조회 완료: 부모=${parentMenu.menu_name_kor}, 하위=${childMenus.length}개, 랜딩=${landingMenu?.menu_name_kor || '없음'}`);
|
||||||
|
|
||||||
|
return { parentMenu, childMenus, landingMenu };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("AdminService.getPopMenuList 오류:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴 정보 조회
|
* 메뉴 정보 조회
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,8 @@ export type AuditResourceType =
|
||||||
| "DATA"
|
| "DATA"
|
||||||
| "TABLE"
|
| "TABLE"
|
||||||
| "NUMBERING_RULE"
|
| "NUMBERING_RULE"
|
||||||
| "BATCH";
|
| "BATCH"
|
||||||
|
| "NODE_FLOW";
|
||||||
|
|
||||||
export interface AuditLogParams {
|
export interface AuditLogParams {
|
||||||
companyCode: string;
|
companyCode: string;
|
||||||
|
|
@ -65,6 +66,7 @@ export interface AuditLogParams {
|
||||||
export interface AuditLogEntry {
|
export interface AuditLogEntry {
|
||||||
id: number;
|
id: number;
|
||||||
company_code: string;
|
company_code: string;
|
||||||
|
company_name: string | null;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
user_name: string | null;
|
user_name: string | null;
|
||||||
action: string;
|
action: string;
|
||||||
|
|
@ -106,6 +108,7 @@ class AuditLogService {
|
||||||
*/
|
*/
|
||||||
async log(params: AuditLogParams): Promise<void> {
|
async log(params: AuditLogParams): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`);
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO system_audit_log
|
`INSERT INTO system_audit_log
|
||||||
(company_code, user_id, user_name, action, resource_type,
|
(company_code, user_id, user_name, action, resource_type,
|
||||||
|
|
@ -127,8 +130,9 @@ class AuditLogService {
|
||||||
params.requestPath || null,
|
params.requestPath || null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
} catch (error) {
|
logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`);
|
||||||
logger.error("감사 로그 기록 실패 (무시됨)", { error, params });
|
} catch (error: any) {
|
||||||
|
logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,40 +189,40 @@ class AuditLogService {
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (!isSuperAdmin && filters.companyCode) {
|
if (!isSuperAdmin && filters.companyCode) {
|
||||||
conditions.push(`company_code = $${paramIndex++}`);
|
conditions.push(`sal.company_code = $${paramIndex++}`);
|
||||||
params.push(filters.companyCode);
|
params.push(filters.companyCode);
|
||||||
} else if (isSuperAdmin && filters.companyCode) {
|
} else if (isSuperAdmin && filters.companyCode) {
|
||||||
conditions.push(`company_code = $${paramIndex++}`);
|
conditions.push(`sal.company_code = $${paramIndex++}`);
|
||||||
params.push(filters.companyCode);
|
params.push(filters.companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.userId) {
|
if (filters.userId) {
|
||||||
conditions.push(`user_id = $${paramIndex++}`);
|
conditions.push(`sal.user_id = $${paramIndex++}`);
|
||||||
params.push(filters.userId);
|
params.push(filters.userId);
|
||||||
}
|
}
|
||||||
if (filters.resourceType) {
|
if (filters.resourceType) {
|
||||||
conditions.push(`resource_type = $${paramIndex++}`);
|
conditions.push(`sal.resource_type = $${paramIndex++}`);
|
||||||
params.push(filters.resourceType);
|
params.push(filters.resourceType);
|
||||||
}
|
}
|
||||||
if (filters.action) {
|
if (filters.action) {
|
||||||
conditions.push(`action = $${paramIndex++}`);
|
conditions.push(`sal.action = $${paramIndex++}`);
|
||||||
params.push(filters.action);
|
params.push(filters.action);
|
||||||
}
|
}
|
||||||
if (filters.tableName) {
|
if (filters.tableName) {
|
||||||
conditions.push(`table_name = $${paramIndex++}`);
|
conditions.push(`sal.table_name = $${paramIndex++}`);
|
||||||
params.push(filters.tableName);
|
params.push(filters.tableName);
|
||||||
}
|
}
|
||||||
if (filters.dateFrom) {
|
if (filters.dateFrom) {
|
||||||
conditions.push(`created_at >= $${paramIndex++}::timestamptz`);
|
conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`);
|
||||||
params.push(filters.dateFrom);
|
params.push(filters.dateFrom);
|
||||||
}
|
}
|
||||||
if (filters.dateTo) {
|
if (filters.dateTo) {
|
||||||
conditions.push(`created_at <= $${paramIndex++}::timestamptz`);
|
conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`);
|
||||||
params.push(filters.dateTo);
|
params.push(filters.dateTo);
|
||||||
}
|
}
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
`(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})`
|
`(sal.summary ILIKE $${paramIndex} OR sal.resource_name ILIKE $${paramIndex} OR sal.user_name ILIKE $${paramIndex})`
|
||||||
);
|
);
|
||||||
params.push(`%${filters.search}%`);
|
params.push(`%${filters.search}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
@ -232,14 +236,17 @@ class AuditLogService {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const countResult = await query<{ count: string }>(
|
const countResult = await query<{ count: string }>(
|
||||||
`SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`,
|
`SELECT COUNT(*) as count FROM system_audit_log sal ${whereClause}`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
const total = parseInt(countResult[0].count, 10);
|
const total = parseInt(countResult[0].count, 10);
|
||||||
|
|
||||||
const data = await query<AuditLogEntry>(
|
const data = await query<AuditLogEntry>(
|
||||||
`SELECT * FROM system_audit_log ${whereClause}
|
`SELECT sal.*, ci.company_name
|
||||||
ORDER BY created_at DESC
|
FROM system_audit_log sal
|
||||||
|
LEFT JOIN company_mng ci ON sal.company_code = ci.company_code
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY sal.created_at DESC
|
||||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||||
[...params, limit, offset]
|
[...params, limit, offset]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1715,8 +1715,8 @@ export class DynamicFormService {
|
||||||
`SELECT component_id, properties
|
`SELECT component_id, properties
|
||||||
FROM screen_layouts
|
FROM screen_layouts
|
||||||
WHERE screen_id = $1
|
WHERE screen_id = $1
|
||||||
AND component_type = $2`,
|
AND component_type IN ('component', 'v2-button-primary')`,
|
||||||
[screenId, "component"]
|
[screenId]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length);
|
console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length);
|
||||||
|
|
@ -1747,8 +1747,12 @@ export class DynamicFormService {
|
||||||
(triggerType === "delete" && buttonActionType === "delete") ||
|
(triggerType === "delete" && buttonActionType === "delete") ||
|
||||||
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
|
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
|
||||||
|
|
||||||
|
const isButtonComponent =
|
||||||
|
properties?.componentType === "button-primary" ||
|
||||||
|
properties?.componentType === "v2-button-primary";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
properties?.componentType === "button-primary" &&
|
isButtonComponent &&
|
||||||
isMatchingAction &&
|
isMatchingAction &&
|
||||||
properties?.webTypeConfig?.enableDataflowControl === true
|
properties?.webTypeConfig?.enableDataflowControl === true
|
||||||
) {
|
) {
|
||||||
|
|
@ -1877,7 +1881,7 @@ export class DynamicFormService {
|
||||||
{
|
{
|
||||||
sourceData: [savedData],
|
sourceData: [savedData],
|
||||||
dataSourceType: "formData",
|
dataSourceType: "formData",
|
||||||
buttonId: "save-button",
|
buttonId: `${triggerType}-button`,
|
||||||
screenId: screenId,
|
screenId: screenId,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
companyCode: companyCode,
|
companyCode: companyCode,
|
||||||
|
|
|
||||||
|
|
@ -972,7 +972,7 @@ class MultiTableExcelService {
|
||||||
c.column_name,
|
c.column_name,
|
||||||
c.is_nullable AS db_is_nullable,
|
c.is_nullable AS db_is_nullable,
|
||||||
c.column_default,
|
c.column_default,
|
||||||
COALESCE(ttc.column_label, cl.column_label) AS column_label,
|
COALESCE(NULLIF(ttc.column_label, c.column_name), cl.column_label) AS column_label,
|
||||||
COALESCE(ttc.reference_table, cl.reference_table) AS reference_table,
|
COALESCE(ttc.reference_table, cl.reference_table) AS reference_table,
|
||||||
COALESCE(ttc.is_nullable, cl.is_nullable) AS ttc_is_nullable
|
COALESCE(ttc.is_nullable, cl.is_nullable) AS ttc_is_nullable
|
||||||
FROM information_schema.columns c
|
FROM information_schema.columns c
|
||||||
|
|
|
||||||
|
|
@ -2346,19 +2346,24 @@ export class ScreenManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료)
|
* 메뉴별 화면 목록 조회
|
||||||
|
* company_code 매칭: 본인 회사 할당 + SUPER_ADMIN 글로벌 할당('*') 모두 조회
|
||||||
|
* 본인 회사 할당이 우선, 없으면 글로벌 할당 사용
|
||||||
*/
|
*/
|
||||||
async getScreensByMenu(
|
async getScreensByMenu(
|
||||||
menuObjid: number,
|
menuObjid: number,
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
): Promise<ScreenDefinition[]> {
|
): Promise<ScreenDefinition[]> {
|
||||||
const screens = await query<any>(
|
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
|
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
|
||||||
WHERE sma.menu_objid = $1
|
WHERE sma.menu_objid = $1
|
||||||
AND sma.company_code = $2
|
AND (sma.company_code = $2 OR sma.company_code = '*')
|
||||||
AND sma.is_active = 'Y'
|
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],
|
[menuObjid, companyCode],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -217,12 +217,12 @@ class TableCategoryValueService {
|
||||||
AND column_name = $2
|
AND column_name = $2
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// category_values 테이블 사용 (menu_objid 없음)
|
// company_code 기반 필터링
|
||||||
if (companyCode === "*") {
|
if (companyCode === "*") {
|
||||||
// 최고 관리자: 모든 값 조회
|
// 최고 관리자: 공통(*) 카테고리만 조회 (모든 회사 카테고리 혼합 방지)
|
||||||
query = baseSelect;
|
query = baseSelect + ` AND company_code = '*'`;
|
||||||
params = [tableName, columnName];
|
params = [tableName, columnName];
|
||||||
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values)");
|
logger.info("최고 관리자: 공통 카테고리만 조회 (category_values)");
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회
|
// 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회
|
||||||
query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`;
|
query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`;
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ export class TableManagementService {
|
||||||
? await query<any>(
|
? await query<any>(
|
||||||
`SELECT
|
`SELECT
|
||||||
c.column_name as "columnName",
|
c.column_name as "columnName",
|
||||||
COALESCE(ttc.column_label, cl.column_label, c.column_name) as "displayName",
|
COALESCE(NULLIF(ttc.column_label, c.column_name), cl.column_label, c.column_name) as "displayName",
|
||||||
c.data_type as "dataType",
|
c.data_type as "dataType",
|
||||||
c.data_type as "dbType",
|
c.data_type as "dbType",
|
||||||
COALESCE(ttc.input_type, cl.input_type, 'text') as "webType",
|
COALESCE(ttc.input_type, cl.input_type, 'text') as "webType",
|
||||||
|
|
@ -3367,22 +3367,26 @@ export class TableManagementService {
|
||||||
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
|
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "in":
|
case "in": {
|
||||||
if (Array.isArray(value) && value.length > 0) {
|
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
||||||
const values = value
|
if (inArr.length > 0) {
|
||||||
|
const values = inArr
|
||||||
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
|
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
filterConditions.push(`${safeColumn} IN (${values})`);
|
filterConditions.push(`${safeColumn} IN (${values})`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "not_in":
|
}
|
||||||
if (Array.isArray(value) && value.length > 0) {
|
case "not_in": {
|
||||||
const values = value
|
const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
||||||
|
if (notInArr.length > 0) {
|
||||||
|
const values = notInArr
|
||||||
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
|
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
filterConditions.push(`${safeColumn} NOT IN (${values})`);
|
filterConditions.push(`${safeColumn} NOT IN (${values})`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "contains":
|
case "contains":
|
||||||
filterConditions.push(
|
filterConditions.push(
|
||||||
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'`
|
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'`
|
||||||
|
|
@ -4500,26 +4504,30 @@ export class TableManagementService {
|
||||||
|
|
||||||
const rawColumns = await query<any>(
|
const rawColumns = await query<any>(
|
||||||
`SELECT
|
`SELECT
|
||||||
column_name as "columnName",
|
c.column_name as "columnName",
|
||||||
column_name as "displayName",
|
c.column_name as "displayName",
|
||||||
data_type as "dataType",
|
c.data_type as "dataType",
|
||||||
udt_name as "dbType",
|
c.udt_name as "dbType",
|
||||||
is_nullable as "isNullable",
|
c.is_nullable as "isNullable",
|
||||||
column_default as "defaultValue",
|
c.column_default as "defaultValue",
|
||||||
character_maximum_length as "maxLength",
|
c.character_maximum_length as "maxLength",
|
||||||
numeric_precision as "numericPrecision",
|
c.numeric_precision as "numericPrecision",
|
||||||
numeric_scale as "numericScale",
|
c.numeric_scale as "numericScale",
|
||||||
CASE
|
CASE
|
||||||
WHEN column_name IN (
|
WHEN c.column_name IN (
|
||||||
SELECT column_name FROM information_schema.key_column_usage
|
SELECT kcu.column_name FROM information_schema.key_column_usage kcu
|
||||||
WHERE table_name = $1 AND constraint_name LIKE '%_pkey'
|
WHERE kcu.table_name = $1 AND kcu.constraint_name LIKE '%_pkey'
|
||||||
) THEN true
|
) THEN true
|
||||||
ELSE false
|
ELSE false
|
||||||
END as "isPrimaryKey"
|
END as "isPrimaryKey",
|
||||||
FROM information_schema.columns
|
col_description(
|
||||||
WHERE table_name = $1
|
(SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')),
|
||||||
AND table_schema = 'public'
|
c.ordinal_position
|
||||||
ORDER BY ordinal_position`,
|
) as "columnComment"
|
||||||
|
FROM information_schema.columns c
|
||||||
|
WHERE c.table_name = $1
|
||||||
|
AND c.table_schema = 'public'
|
||||||
|
ORDER BY c.ordinal_position`,
|
||||||
[tableName]
|
[tableName]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -4529,10 +4537,10 @@ export class TableManagementService {
|
||||||
displayName: col.displayName,
|
displayName: col.displayName,
|
||||||
dataType: col.dataType,
|
dataType: col.dataType,
|
||||||
dbType: col.dbType,
|
dbType: col.dbType,
|
||||||
webType: "text", // 기본값
|
webType: "text",
|
||||||
inputType: "direct",
|
inputType: "direct",
|
||||||
detailSettings: "{}",
|
detailSettings: "{}",
|
||||||
description: "", // 필수 필드 추가
|
description: col.columnComment || "",
|
||||||
isNullable: col.isNullable,
|
isNullable: col.isNullable,
|
||||||
isPrimaryKey: col.isPrimaryKey,
|
isPrimaryKey: col.isPrimaryKey,
|
||||||
defaultValue: col.defaultValue,
|
defaultValue: col.defaultValue,
|
||||||
|
|
@ -4543,6 +4551,7 @@ export class TableManagementService {
|
||||||
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
|
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
|
||||||
displayOrder: 0,
|
displayOrder: 0,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
|
columnComment: col.columnComment || "",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -98,23 +98,27 @@ export function buildDataFilterWhereClause(
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "in":
|
case "in": {
|
||||||
if (Array.isArray(value) && value.length > 0) {
|
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
||||||
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
|
if (inArr.length > 0) {
|
||||||
|
const placeholders = inArr.map((_, idx) => `$${paramIndex + idx}`).join(", ");
|
||||||
conditions.push(`${columnRef} IN (${placeholders})`);
|
conditions.push(`${columnRef} IN (${placeholders})`);
|
||||||
params.push(...value);
|
params.push(...inArr);
|
||||||
paramIndex += value.length;
|
paramIndex += inArr.length;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "not_in":
|
case "not_in": {
|
||||||
if (Array.isArray(value) && value.length > 0) {
|
const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
||||||
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
|
if (notInArr.length > 0) {
|
||||||
|
const placeholders = notInArr.map((_, idx) => `$${paramIndex + idx}`).join(", ");
|
||||||
conditions.push(`${columnRef} NOT IN (${placeholders})`);
|
conditions.push(`${columnRef} NOT IN (${placeholders})`);
|
||||||
params.push(...value);
|
params.push(...notInArr);
|
||||||
paramIndex += value.length;
|
paramIndex += notInArr.length;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "contains":
|
case "contains":
|
||||||
conditions.push(`${columnRef} LIKE $${paramIndex}`);
|
conditions.push(`${columnRef} LIKE $${paramIndex}`);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
// 스마트공장 활용 로그 전송 유틸리티
|
||||||
|
// https://log.smart-factory.kr 에 사용자 접속 로그를 전송
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
|
const SMART_FACTORY_LOG_URL =
|
||||||
|
"https://log.smart-factory.kr/apisvc/sendLogDataJSON.do";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스마트공장 활용 로그 전송
|
||||||
|
* 로그인 성공 시 비동기로 호출하여 응답을 블로킹하지 않음
|
||||||
|
*/
|
||||||
|
export async function sendSmartFactoryLog(params: {
|
||||||
|
userId: string;
|
||||||
|
remoteAddr: string;
|
||||||
|
useType?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const apiKey = process.env.SMART_FACTORY_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
logger.warn(
|
||||||
|
"SMART_FACTORY_API_KEY 환경변수가 설정되지 않아 스마트공장 로그 전송을 건너뜁니다."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const logDt = formatDateTime(now);
|
||||||
|
|
||||||
|
const logData = {
|
||||||
|
crtfcKey: apiKey,
|
||||||
|
logDt,
|
||||||
|
useSe: params.useType || "접속",
|
||||||
|
sysUser: params.userId,
|
||||||
|
conectIp: params.remoteAddr,
|
||||||
|
dataUsgqty: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodedLogData = encodeURIComponent(JSON.stringify(logData));
|
||||||
|
|
||||||
|
const response = await axios.get(SMART_FACTORY_LOG_URL, {
|
||||||
|
params: { logData: encodedLogData },
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("스마트공장 로그 전송 완료", {
|
||||||
|
userId: params.userId,
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 스마트공장 로그 전송 실패해도 로그인에 영향 없도록 에러만 기록
|
||||||
|
logger.error("스마트공장 로그 전송 실패", {
|
||||||
|
userId: params.userId,
|
||||||
|
error: error instanceof Error ? error.message : error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** yyyy-MM-dd HH:mm:ss.SSS 형식 */
|
||||||
|
function formatDateTime(date: Date): string {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const M = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(date.getDate()).padStart(2, "0");
|
||||||
|
const H = String(date.getHours()).padStart(2, "0");
|
||||||
|
const m = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
const s = String(date.getSeconds()).padStart(2, "0");
|
||||||
|
const ms = String(date.getMilliseconds()).padStart(3, "0");
|
||||||
|
return `${y}-${M}-${d} ${H}:${m}:${s}.${ms}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,620 @@
|
||||||
|
# POP 작업진행 관리 설계서
|
||||||
|
|
||||||
|
> 작성일: 2026-03-13
|
||||||
|
> 목적: POP 시스템에서 작업지시 기반으로 라우팅/작업기준정보를 조회하고, 공정별 작업 진행 상태를 관리하는 구조 설계
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 핵심 설계 원칙
|
||||||
|
|
||||||
|
**작업지시에 라우팅ID, 작업기준정보ID 등을 별도 컬럼으로 넣지 않는다.**
|
||||||
|
|
||||||
|
- 작업지시(`work_instruction`)에는 `item_id`(품목 ID)만 있으면 충분
|
||||||
|
- 품목 → 라우팅 → 작업기준정보는 마스터 데이터 체인으로 조회
|
||||||
|
- 작업 진행 상태만 별도 테이블에서 관리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 기존 테이블 구조 (마스터 데이터)
|
||||||
|
|
||||||
|
### 2-1. ER 다이어그램
|
||||||
|
|
||||||
|
> GitHub / VSCode Mermaid 플러그인에서 렌더링됩니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
%% ========== 마스터 데이터 (변경 없음) ==========
|
||||||
|
|
||||||
|
item_info {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar item_number "품번"
|
||||||
|
varchar item_name "품명"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
item_routing_version {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar item_code "품번 (= item_info.item_number)"
|
||||||
|
varchar version_name "버전명"
|
||||||
|
boolean is_default "기본버전 여부"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
item_routing_detail {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar routing_version_id FK "→ item_routing_version.id"
|
||||||
|
varchar seq_no "공정순서 10,20,30..."
|
||||||
|
varchar process_code FK "→ process_mng.process_code"
|
||||||
|
varchar is_required "필수/선택"
|
||||||
|
varchar is_fixed_order "고정/선택"
|
||||||
|
varchar standard_time "표준시간(분)"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
process_mng {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar process_code "공정코드"
|
||||||
|
varchar process_name "공정명"
|
||||||
|
varchar process_type "공정유형"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
process_work_item {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar routing_detail_id FK "→ item_routing_detail.id"
|
||||||
|
varchar work_phase "PRE / IN / POST"
|
||||||
|
varchar title "작업항목명"
|
||||||
|
varchar is_required "Y/N"
|
||||||
|
int sort_order "정렬순서"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
process_work_item_detail {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar work_item_id FK "→ process_work_item.id"
|
||||||
|
varchar detail_type "check/inspect/input/procedure/info"
|
||||||
|
varchar content "내용"
|
||||||
|
varchar input_type "입력타입"
|
||||||
|
varchar inspection_code "검사코드"
|
||||||
|
varchar unit "단위"
|
||||||
|
varchar lower_limit "하한값"
|
||||||
|
varchar upper_limit "상한값"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
%% ========== 트랜잭션 데이터 ==========
|
||||||
|
|
||||||
|
work_instruction {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar work_instruction_no "작업지시번호"
|
||||||
|
varchar item_id FK "→ item_info.id ★핵심★"
|
||||||
|
varchar status "waiting/in_progress/completed/cancelled"
|
||||||
|
varchar qty "지시수량"
|
||||||
|
varchar completed_qty "완성수량"
|
||||||
|
varchar worker "작업자"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
work_order_process {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar wo_id FK "→ work_instruction.id"
|
||||||
|
varchar routing_detail_id FK "→ item_routing_detail.id ★추가★"
|
||||||
|
varchar seq_no "공정순서"
|
||||||
|
varchar process_code "공정코드"
|
||||||
|
varchar process_name "공정명"
|
||||||
|
varchar status "waiting/in_progress/completed/skipped"
|
||||||
|
varchar plan_qty "계획수량"
|
||||||
|
varchar good_qty "양품수량"
|
||||||
|
varchar defect_qty "불량수량"
|
||||||
|
timestamp started_at "시작시간"
|
||||||
|
timestamp completed_at "완료시간"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
work_order_work_item {
|
||||||
|
varchar id PK "UUID ★신규★"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
varchar work_order_process_id FK "→ work_order_process.id"
|
||||||
|
varchar work_item_id FK "→ process_work_item.id"
|
||||||
|
varchar work_phase "PRE/IN/POST"
|
||||||
|
varchar status "pending/completed/skipped/failed"
|
||||||
|
varchar completed_by "완료자"
|
||||||
|
timestamp completed_at "완료시간"
|
||||||
|
}
|
||||||
|
|
||||||
|
work_order_work_item_result {
|
||||||
|
varchar id PK "UUID ★신규★"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
varchar work_order_work_item_id FK "→ work_order_work_item.id"
|
||||||
|
varchar work_item_detail_id FK "→ process_work_item_detail.id"
|
||||||
|
varchar detail_type "check/inspect/input/procedure"
|
||||||
|
varchar result_value "결과값"
|
||||||
|
varchar is_passed "Y/N/null"
|
||||||
|
varchar recorded_by "기록자"
|
||||||
|
timestamp recorded_at "기록시간"
|
||||||
|
}
|
||||||
|
|
||||||
|
%% ========== 관계 ==========
|
||||||
|
|
||||||
|
%% 마스터 체인: 품목 → 라우팅 → 작업기준정보
|
||||||
|
item_info ||--o{ item_routing_version : "item_number = item_code"
|
||||||
|
item_routing_version ||--o{ item_routing_detail : "id = routing_version_id"
|
||||||
|
item_routing_detail }o--|| process_mng : "process_code"
|
||||||
|
item_routing_detail ||--o{ process_work_item : "id = routing_detail_id"
|
||||||
|
process_work_item ||--o{ process_work_item_detail : "id = work_item_id"
|
||||||
|
|
||||||
|
%% 트랜잭션: 작업지시 → 공정진행 → 작업기준정보 진행
|
||||||
|
work_instruction }o--|| item_info : "item_id = id"
|
||||||
|
work_instruction ||--o{ work_order_process : "id = wo_id"
|
||||||
|
work_order_process }o--|| item_routing_detail : "routing_detail_id = id"
|
||||||
|
work_order_process ||--o{ work_order_work_item : "id = work_order_process_id"
|
||||||
|
work_order_work_item }o--|| process_work_item : "work_item_id = id"
|
||||||
|
work_order_work_item ||--o{ work_order_work_item_result : "id = work_order_work_item_id"
|
||||||
|
work_order_work_item_result }o--|| process_work_item_detail : "work_item_detail_id = id"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-1-1. 관계 요약 (텍스트)
|
||||||
|
|
||||||
|
```
|
||||||
|
[마스터 데이터 체인 - 조회용, 변경 없음]
|
||||||
|
|
||||||
|
item_info ─── 1:N ───→ item_routing_version ─── 1:N ───→ item_routing_detail
|
||||||
|
(품목) item_number (라우팅 버전) routing_ (공정별 상세)
|
||||||
|
= item_code version_id
|
||||||
|
│
|
||||||
|
process_mng ◄───┘ process_code (공정 마스터)
|
||||||
|
│
|
||||||
|
├── 1:N ───→ process_work_item ─── 1:N ───→ process_work_item_detail
|
||||||
|
│ (작업기준정보) (작업기준정보 상세)
|
||||||
|
│ routing_detail_id work_item_id
|
||||||
|
│
|
||||||
|
[트랜잭션 데이터 - 상태 관리] │
|
||||||
|
│
|
||||||
|
work_instruction ─── 1:N ───→ work_order_process ─┘ routing_detail_id (★추가★)
|
||||||
|
(작업지시) wo_id (공정별 진행)
|
||||||
|
item_id → item_info │
|
||||||
|
├── 1:N ───→ work_order_work_item ─── 1:N ───→ work_order_work_item_result
|
||||||
|
│ (작업기준정보 진행) (상세 결과값)
|
||||||
|
│ work_order_process_id work_order_work_item_id
|
||||||
|
│ work_item_id → process_work_item work_item_detail_id → process_work_item_detail
|
||||||
|
│ ★신규 테이블★ ★신규 테이블★
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-2. 마스터 테이블 상세
|
||||||
|
|
||||||
|
#### item_info (품목 마스터)
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | |
|
||||||
|
| item_number | 품번 | item_routing_version.item_code와 매칭 |
|
||||||
|
| item_name | 품명 | |
|
||||||
|
| company_code | 회사코드 | 멀티테넌시 |
|
||||||
|
|
||||||
|
#### item_routing_version (라우팅 버전)
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | |
|
||||||
|
| item_code | 품번 | item_info.item_number와 매칭 |
|
||||||
|
| version_name | 버전명 | 예: "기본 라우팅", "버전2" |
|
||||||
|
| is_default | 기본 버전 여부 | true/false, 기본 버전을 사용 |
|
||||||
|
| company_code | 회사코드 | |
|
||||||
|
|
||||||
|
#### item_routing_detail (라우팅 상세 - 공정별)
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | |
|
||||||
|
| routing_version_id | FK → item_routing_version.id | |
|
||||||
|
| seq_no | 공정 순서 | 10, 20, 30... |
|
||||||
|
| process_code | 공정코드 | FK → process_mng.process_code |
|
||||||
|
| is_required | 필수/선택 | "필수" / "선택" |
|
||||||
|
| is_fixed_order | 순서고정 여부 | "고정" / "선택" |
|
||||||
|
| work_type | 작업유형 | |
|
||||||
|
| standard_time | 표준시간(분) | |
|
||||||
|
| outsource_supplier | 외주업체 | |
|
||||||
|
| company_code | 회사코드 | |
|
||||||
|
|
||||||
|
#### process_work_item (작업기준정보)
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | |
|
||||||
|
| routing_detail_id | FK → item_routing_detail.id | |
|
||||||
|
| work_phase | 작업단계 | PRE(작업전) / IN(작업중) / POST(작업후) |
|
||||||
|
| title | 작업항목명 | 예: "장비 체크", "소재 준비" |
|
||||||
|
| is_required | 필수여부 | Y/N |
|
||||||
|
| sort_order | 정렬순서 | |
|
||||||
|
| description | 설명 | |
|
||||||
|
| company_code | 회사코드 | |
|
||||||
|
|
||||||
|
#### process_work_item_detail (작업기준정보 상세)
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | |
|
||||||
|
| work_item_id | FK → process_work_item.id | |
|
||||||
|
| detail_type | 상세유형 | check(체크) / inspect(검사) / input(입력) / procedure(절차) / info(정보) |
|
||||||
|
| content | 내용 | 예: "소음검사", "치수검사" |
|
||||||
|
| input_type | 입력타입 | select, text 등 |
|
||||||
|
| inspection_code | 검사코드 | |
|
||||||
|
| inspection_method | 검사방법 | |
|
||||||
|
| unit | 단위 | |
|
||||||
|
| lower_limit | 하한값 | |
|
||||||
|
| upper_limit | 상한값 | |
|
||||||
|
| is_required | 필수여부 | Y/N |
|
||||||
|
| sort_order | 정렬순서 | |
|
||||||
|
| company_code | 회사코드 | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 작업 진행 테이블 (트랜잭션 데이터)
|
||||||
|
|
||||||
|
### 3-1. work_instruction (작업지시) - 기존 테이블
|
||||||
|
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | |
|
||||||
|
| work_instruction_no | 작업지시번호 | 예: WO-2026-001 |
|
||||||
|
| **item_id** | **FK → item_info.id** | **이것만으로 라우팅/작업기준정보 전부 조회 가능** |
|
||||||
|
| status | 작업지시 상태 | waiting / in_progress / completed / cancelled |
|
||||||
|
| qty | 지시수량 | |
|
||||||
|
| completed_qty | 완성수량 | |
|
||||||
|
| work_team | 작업팀 | |
|
||||||
|
| worker | 작업자 | |
|
||||||
|
| equipment_id | 설비 | |
|
||||||
|
| start_date | 시작일 | |
|
||||||
|
| end_date | 종료일 | |
|
||||||
|
| remark | 비고 | |
|
||||||
|
| company_code | 회사코드 | |
|
||||||
|
|
||||||
|
> **routing 컬럼**: 현재 존재하지만 사용하지 않음 (null). 라우팅 버전을 지정하고 싶으면 이 컬럼에 `item_routing_version.id`를 넣어 특정 버전을 지정할 수 있음. 없으면 `is_default=true` 버전 자동 사용.
|
||||||
|
|
||||||
|
### 3-2. work_order_process (공정별 진행) - 기존 테이블, 변경 필요
|
||||||
|
|
||||||
|
작업지시가 생성될 때, 해당 품목의 라우팅 공정을 복사해서 이 테이블에 INSERT.
|
||||||
|
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | |
|
||||||
|
| wo_id | FK → work_instruction.id | 작업지시 참조 |
|
||||||
|
| **routing_detail_id** | **FK → item_routing_detail.id** | **추가 필요 - 라우팅 상세 참조** |
|
||||||
|
| seq_no | 공정 순서 | 라우팅에서 복사 |
|
||||||
|
| process_code | 공정코드 | 라우팅에서 복사 |
|
||||||
|
| process_name | 공정명 | 라우팅에서 복사 (비정규화, 조회 편의) |
|
||||||
|
| is_required | 필수여부 | 라우팅에서 복사 |
|
||||||
|
| is_fixed_order | 순서고정 | 라우팅에서 복사 |
|
||||||
|
| standard_time | 표준시간 | 라우팅에서 복사 |
|
||||||
|
| **status** | **공정 상태** | **waiting / in_progress / completed / skipped** |
|
||||||
|
| plan_qty | 계획수량 | |
|
||||||
|
| input_qty | 투입수량 | |
|
||||||
|
| good_qty | 양품수량 | |
|
||||||
|
| defect_qty | 불량수량 | |
|
||||||
|
| equipment_code | 사용설비 | |
|
||||||
|
| accepted_by | 접수자 | |
|
||||||
|
| accepted_at | 접수시간 | |
|
||||||
|
| started_at | 시작시간 | |
|
||||||
|
| completed_at | 완료시간 | |
|
||||||
|
| remark | 비고 | |
|
||||||
|
| company_code | 회사코드 | |
|
||||||
|
|
||||||
|
### 3-3. work_order_work_item (작업기준정보별 진행) - 신규 테이블
|
||||||
|
|
||||||
|
POP에서 작업자가 각 작업기준정보 항목을 체크/입력할 때 사용.
|
||||||
|
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | gen_random_uuid() |
|
||||||
|
| company_code | 회사코드 | 멀티테넌시 |
|
||||||
|
| work_order_process_id | FK → work_order_process.id | 어떤 작업지시의 어떤 공정인지 |
|
||||||
|
| work_item_id | FK → process_work_item.id | 어떤 작업기준정보인지 |
|
||||||
|
| work_phase | 작업단계 | PRE / IN / POST (마스터에서 복사) |
|
||||||
|
| status | 완료상태 | pending / completed / skipped / failed |
|
||||||
|
| completed_by | 완료자 | 작업자 ID |
|
||||||
|
| completed_at | 완료시간 | |
|
||||||
|
| created_date | 생성일 | |
|
||||||
|
| updated_date | 수정일 | |
|
||||||
|
| writer | 작성자 | |
|
||||||
|
|
||||||
|
### 3-4. work_order_work_item_result (작업기준정보 상세 결과) - 신규 테이블
|
||||||
|
|
||||||
|
작업기준정보의 상세 항목(체크, 검사, 입력 등)에 대한 실제 결과값 저장.
|
||||||
|
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | gen_random_uuid() |
|
||||||
|
| company_code | 회사코드 | 멀티테넌시 |
|
||||||
|
| work_order_work_item_id | FK → work_order_work_item.id | |
|
||||||
|
| work_item_detail_id | FK → process_work_item_detail.id | 어떤 상세항목인지 |
|
||||||
|
| detail_type | 상세유형 | check / inspect / input / procedure (마스터에서 복사) |
|
||||||
|
| result_value | 결과값 | 체크: "Y"/"N", 검사: 측정값, 입력: 입력값 |
|
||||||
|
| is_passed | 합격여부 | Y / N / null(해당없음) |
|
||||||
|
| remark | 비고 | 불합격 사유 등 |
|
||||||
|
| recorded_by | 기록자 | |
|
||||||
|
| recorded_at | 기록시간 | |
|
||||||
|
| created_date | 생성일 | |
|
||||||
|
| updated_date | 수정일 | |
|
||||||
|
| writer | 작성자 | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. POP 데이터 플로우
|
||||||
|
|
||||||
|
### 4-1. 작업지시 등록 시 (ERP 측)
|
||||||
|
|
||||||
|
```
|
||||||
|
[작업지시 생성]
|
||||||
|
│
|
||||||
|
├── 1. work_instruction INSERT (item_id, qty, status='waiting' 등)
|
||||||
|
│
|
||||||
|
├── 2. item_id → item_info.item_number 조회
|
||||||
|
│
|
||||||
|
├── 3. item_number → item_routing_version 조회 (is_default=true 또는 지정 버전)
|
||||||
|
│
|
||||||
|
├── 4. routing_version_id → item_routing_detail 조회 (공정 목록)
|
||||||
|
│
|
||||||
|
└── 5. 각 공정별로 work_order_process INSERT
|
||||||
|
├── wo_id = work_instruction.id
|
||||||
|
├── routing_detail_id = item_routing_detail.id ← 핵심!
|
||||||
|
├── seq_no, process_code, process_name 복사
|
||||||
|
├── status = 'waiting'
|
||||||
|
└── plan_qty = work_instruction.qty
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4-2. POP 작업 조회 시
|
||||||
|
|
||||||
|
```
|
||||||
|
[POP 화면: 작업지시 선택]
|
||||||
|
│
|
||||||
|
├── 1. work_instruction 목록 조회 (status = 'waiting' or 'in_progress')
|
||||||
|
│
|
||||||
|
├── 2. 선택한 작업지시의 공정 목록 조회
|
||||||
|
│ SELECT wop.*, pm.process_name
|
||||||
|
│ FROM work_order_process wop
|
||||||
|
│ LEFT JOIN process_mng pm ON wop.process_code = pm.process_code
|
||||||
|
│ WHERE wop.wo_id = {작업지시ID}
|
||||||
|
│ ORDER BY CAST(wop.seq_no AS int)
|
||||||
|
│
|
||||||
|
└── 3. 선택한 공정의 작업기준정보 조회 (마스터 데이터 참조)
|
||||||
|
SELECT pwi.*, pwid.*
|
||||||
|
FROM process_work_item pwi
|
||||||
|
LEFT JOIN process_work_item_detail pwid ON pwi.id = pwid.work_item_id
|
||||||
|
WHERE pwi.routing_detail_id = {work_order_process.routing_detail_id}
|
||||||
|
ORDER BY pwi.work_phase, pwi.sort_order, pwid.sort_order
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4-3. POP 작업 실행 시
|
||||||
|
|
||||||
|
```
|
||||||
|
[작업자가 공정 시작]
|
||||||
|
│
|
||||||
|
├── 1. work_order_process UPDATE
|
||||||
|
│ SET status = 'in_progress', started_at = NOW(), accepted_by = {작업자}
|
||||||
|
│
|
||||||
|
├── 2. work_instruction UPDATE (첫 공정 시작 시)
|
||||||
|
│ SET status = 'in_progress'
|
||||||
|
│
|
||||||
|
├── 3. 작업기준정보 항목별 체크/입력 시
|
||||||
|
│ ├── work_order_work_item UPSERT (항목별 상태)
|
||||||
|
│ └── work_order_work_item_result UPSERT (상세 결과값)
|
||||||
|
│
|
||||||
|
└── 4. 공정 완료 시
|
||||||
|
├── work_order_process UPDATE
|
||||||
|
│ SET status = 'completed', completed_at = NOW(),
|
||||||
|
│ good_qty = {양품}, defect_qty = {불량}
|
||||||
|
│
|
||||||
|
└── (모든 공정 완료 시)
|
||||||
|
work_instruction UPDATE
|
||||||
|
SET status = 'completed', completed_qty = {최종양품}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 핵심 조회 쿼리
|
||||||
|
|
||||||
|
### 5-1. 작업지시 → 전체 공정 + 작업기준정보 한방 조회
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 작업지시의 공정별 진행 현황 + 작업기준정보
|
||||||
|
SELECT
|
||||||
|
wi.work_instruction_no,
|
||||||
|
wi.qty,
|
||||||
|
wi.status as wi_status,
|
||||||
|
ii.item_number,
|
||||||
|
ii.item_name,
|
||||||
|
wop.id as process_id,
|
||||||
|
wop.seq_no,
|
||||||
|
wop.process_code,
|
||||||
|
wop.process_name,
|
||||||
|
wop.status as process_status,
|
||||||
|
wop.plan_qty,
|
||||||
|
wop.good_qty,
|
||||||
|
wop.defect_qty,
|
||||||
|
wop.started_at,
|
||||||
|
wop.completed_at,
|
||||||
|
wop.routing_detail_id,
|
||||||
|
-- 작업기준정보는 routing_detail_id로 마스터 조회
|
||||||
|
pwi.id as work_item_id,
|
||||||
|
pwi.work_phase,
|
||||||
|
pwi.title as work_item_title,
|
||||||
|
pwi.is_required as work_item_required
|
||||||
|
FROM work_instruction wi
|
||||||
|
JOIN item_info ii ON wi.item_id = ii.id
|
||||||
|
JOIN work_order_process wop ON wi.id = wop.wo_id
|
||||||
|
LEFT JOIN process_work_item pwi ON wop.routing_detail_id = pwi.routing_detail_id
|
||||||
|
WHERE wi.id = $1
|
||||||
|
AND wi.company_code = $2
|
||||||
|
ORDER BY CAST(wop.seq_no AS int), pwi.work_phase, pwi.sort_order;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5-2. 특정 공정의 작업기준정보 + 진행 상태 조회
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- POP에서 특정 공정 선택 시: 마스터 + 진행 상태 조인
|
||||||
|
SELECT
|
||||||
|
pwi.id as work_item_id,
|
||||||
|
pwi.work_phase,
|
||||||
|
pwi.title,
|
||||||
|
pwi.is_required,
|
||||||
|
pwid.id as detail_id,
|
||||||
|
pwid.detail_type,
|
||||||
|
pwid.content,
|
||||||
|
pwid.input_type,
|
||||||
|
pwid.inspection_code,
|
||||||
|
pwid.inspection_method,
|
||||||
|
pwid.unit,
|
||||||
|
pwid.lower_limit,
|
||||||
|
pwid.upper_limit,
|
||||||
|
-- 진행 상태
|
||||||
|
wowi.status as item_status,
|
||||||
|
wowi.completed_by,
|
||||||
|
wowi.completed_at,
|
||||||
|
-- 결과값
|
||||||
|
wowir.result_value,
|
||||||
|
wowir.is_passed,
|
||||||
|
wowir.remark as result_remark
|
||||||
|
FROM process_work_item pwi
|
||||||
|
LEFT JOIN process_work_item_detail pwid
|
||||||
|
ON pwi.id = pwid.work_item_id
|
||||||
|
LEFT JOIN work_order_work_item wowi
|
||||||
|
ON wowi.work_item_id = pwi.id
|
||||||
|
AND wowi.work_order_process_id = $1 -- work_order_process.id
|
||||||
|
LEFT JOIN work_order_work_item_result wowir
|
||||||
|
ON wowir.work_order_work_item_id = wowi.id
|
||||||
|
AND wowir.work_item_detail_id = pwid.id
|
||||||
|
WHERE pwi.routing_detail_id = $2 -- work_order_process.routing_detail_id
|
||||||
|
ORDER BY
|
||||||
|
CASE pwi.work_phase WHEN 'PRE' THEN 1 WHEN 'IN' THEN 2 WHEN 'POST' THEN 3 END,
|
||||||
|
pwi.sort_order,
|
||||||
|
pwid.sort_order;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 변경사항 요약
|
||||||
|
|
||||||
|
### 6-1. 기존 테이블 변경
|
||||||
|
|
||||||
|
| 테이블 | 변경내용 |
|
||||||
|
|--------|---------|
|
||||||
|
| work_order_process | `routing_detail_id VARCHAR(500)` 컬럼 추가 |
|
||||||
|
|
||||||
|
### 6-2. 신규 테이블
|
||||||
|
|
||||||
|
| 테이블 | 용도 |
|
||||||
|
|--------|------|
|
||||||
|
| work_order_work_item | 작업지시 공정별 작업기준정보 진행 상태 |
|
||||||
|
| work_order_work_item_result | 작업기준정보 상세 항목의 실제 결과값 |
|
||||||
|
|
||||||
|
### 6-3. 건드리지 않는 것
|
||||||
|
|
||||||
|
| 테이블 | 이유 |
|
||||||
|
|--------|------|
|
||||||
|
| work_instruction | item_id만 있으면 충분. 라우팅/작업기준정보 ID 추가 불필요 |
|
||||||
|
| item_routing_version | 마스터 데이터, 변경 없음 |
|
||||||
|
| item_routing_detail | 마스터 데이터, 변경 없음 |
|
||||||
|
| process_work_item | 마스터 데이터, 변경 없음 |
|
||||||
|
| process_work_item_detail | 마스터 데이터, 변경 없음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. DDL (마이그레이션 SQL)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. work_order_process에 routing_detail_id 추가
|
||||||
|
ALTER TABLE work_order_process
|
||||||
|
ADD COLUMN IF NOT EXISTS routing_detail_id VARCHAR(500);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wop_routing_detail_id
|
||||||
|
ON work_order_process(routing_detail_id);
|
||||||
|
|
||||||
|
-- 2. 작업기준정보별 진행 상태 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS work_order_work_item (
|
||||||
|
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
company_code VARCHAR(500) NOT NULL,
|
||||||
|
work_order_process_id VARCHAR(500) NOT NULL,
|
||||||
|
work_item_id VARCHAR(500) NOT NULL,
|
||||||
|
work_phase VARCHAR(500),
|
||||||
|
status VARCHAR(500) DEFAULT 'pending',
|
||||||
|
completed_by VARCHAR(500),
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
created_date TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_date TIMESTAMP DEFAULT NOW(),
|
||||||
|
writer VARCHAR(500)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wowi_process_id ON work_order_work_item(work_order_process_id);
|
||||||
|
CREATE INDEX idx_wowi_work_item_id ON work_order_work_item(work_item_id);
|
||||||
|
CREATE INDEX idx_wowi_company_code ON work_order_work_item(company_code);
|
||||||
|
|
||||||
|
-- 3. 작업기준정보 상세 결과 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS work_order_work_item_result (
|
||||||
|
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
company_code VARCHAR(500) NOT NULL,
|
||||||
|
work_order_work_item_id VARCHAR(500) NOT NULL,
|
||||||
|
work_item_detail_id VARCHAR(500) NOT NULL,
|
||||||
|
detail_type VARCHAR(500),
|
||||||
|
result_value VARCHAR(500),
|
||||||
|
is_passed VARCHAR(500),
|
||||||
|
remark TEXT,
|
||||||
|
recorded_by VARCHAR(500),
|
||||||
|
recorded_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
created_date TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_date TIMESTAMP DEFAULT NOW(),
|
||||||
|
writer VARCHAR(500)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wowir_work_order_work_item_id ON work_order_work_item_result(work_order_work_item_id);
|
||||||
|
CREATE INDEX idx_wowir_detail_id ON work_order_work_item_result(work_item_detail_id);
|
||||||
|
CREATE INDEX idx_wowir_company_code ON work_order_work_item_result(company_code);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 상태값 정의
|
||||||
|
|
||||||
|
### work_instruction.status (작업지시 상태)
|
||||||
|
| 값 | 의미 |
|
||||||
|
|----|------|
|
||||||
|
| waiting | 대기 |
|
||||||
|
| in_progress | 진행중 |
|
||||||
|
| completed | 완료 |
|
||||||
|
| cancelled | 취소 |
|
||||||
|
|
||||||
|
### work_order_process.status (공정 상태)
|
||||||
|
| 값 | 의미 |
|
||||||
|
|----|------|
|
||||||
|
| waiting | 대기 (아직 시작 안 함) |
|
||||||
|
| in_progress | 진행중 (작업자가 시작) |
|
||||||
|
| completed | 완료 |
|
||||||
|
| skipped | 건너뜀 (선택 공정인 경우) |
|
||||||
|
|
||||||
|
### work_order_work_item.status (작업기준정보 항목 상태)
|
||||||
|
| 값 | 의미 |
|
||||||
|
|----|------|
|
||||||
|
| pending | 미완료 |
|
||||||
|
| completed | 완료 |
|
||||||
|
| skipped | 건너뜀 |
|
||||||
|
| failed | 실패 (검사 불합격 등) |
|
||||||
|
|
||||||
|
### work_order_work_item_result.is_passed (검사 합격여부)
|
||||||
|
| 값 | 의미 |
|
||||||
|
|----|------|
|
||||||
|
| Y | 합격 |
|
||||||
|
| N | 불합격 |
|
||||||
|
| null | 해당없음 (체크/입력 항목) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 설계 의도 요약
|
||||||
|
|
||||||
|
1. **마스터와 트랜잭션 분리**: 라우팅/작업기준정보는 마스터(템플릿), 실제 진행은 트랜잭션 테이블에서 관리
|
||||||
|
2. **조회 경로**: `work_instruction.item_id` → `item_info.item_number` → `item_routing_version` → `item_routing_detail` → `process_work_item` → `process_work_item_detail`
|
||||||
|
3. **진행 경로**: `work_order_process.routing_detail_id`로 마스터 작업기준정보를 참조하되, 실제 진행/결과는 `work_order_work_item` + `work_order_work_item_result`에 저장
|
||||||
|
4. **중복 저장 최소화**: 작업지시에 공정/작업기준정보 ID를 넣지 않음. 품목만 있으면 전부 파생 조회 가능
|
||||||
|
5. **work_order_process**: 작업지시 생성 시 라우팅 공정을 복사하는 이유는 진행 중 수량/상태/시간 등 트랜잭션 데이터를 기록해야 하기 때문 (마스터가 변경되어도 이미 발행된 작업지시의 공정은 유지)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 주의사항
|
||||||
|
|
||||||
|
- `work_order_process`에 공정 정보를 복사(스냅샷)하는 이유: 마스터 라우팅이 나중에 변경되어도 이미 진행 중인 작업지시의 공정 구성은 영향받지 않아야 함
|
||||||
|
- `routing_detail_id`는 "이 공정이 어떤 마스터 라우팅에서 왔는지" 추적용. 작업기준정보 조회 키로 사용
|
||||||
|
- POP에서 작업기준정보를 표시할 때는 항상 마스터(`process_work_item`)를 조회하고, 결과만 트랜잭션 테이블에 저장
|
||||||
|
- 모든 테이블에 `company_code` 필수 (멀티테넌시)
|
||||||
|
|
@ -1,331 +0,0 @@
|
||||||
# 화면 전체 분석 보고서
|
|
||||||
|
|
||||||
> **분석 대상**: `/Users/kimjuseok/Downloads/화면개발 8` 폴더 내 핵심 업무 화면
|
|
||||||
> **분석 기준**: 메뉴별 분류, 3개 이상 재활용 가능한 컴포넌트 식별
|
|
||||||
> **분석 일자**: 2026-01-30
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 현재 사용 중인 V2 컴포넌트 목록
|
|
||||||
|
|
||||||
> **중요**: v2- 접두사가 붙은 컴포넌트만 사용합니다.
|
|
||||||
|
|
||||||
### 입력 컴포넌트
|
|
||||||
| ID | 이름 | 용도 |
|
|
||||||
|----|------|------|
|
|
||||||
| `v2-input` | V2 입력 | 텍스트, 숫자, 비밀번호, 이메일 등 입력 |
|
|
||||||
| `v2-select` | V2 선택 | 드롭다운, 콤보박스, 라디오, 체크박스 |
|
|
||||||
| `v2-date` | V2 날짜 | 날짜, 시간, 날짜범위 입력 |
|
|
||||||
|
|
||||||
### 표시 컴포넌트
|
|
||||||
| ID | 이름 | 용도 |
|
|
||||||
|----|------|------|
|
|
||||||
| `v2-text-display` | 텍스트 표시 | 라벨, 텍스트 표시 |
|
|
||||||
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 |
|
|
||||||
| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수 등 집계 표시 |
|
|
||||||
|
|
||||||
### 테이블/데이터 컴포넌트
|
|
||||||
| ID | 이름 | 용도 |
|
|
||||||
|----|------|------|
|
|
||||||
| `v2-table-list` | 테이블 리스트 | 데이터 테이블 표시, 페이지네이션, 정렬, 필터 |
|
|
||||||
| `v2-table-search-widget` | 검색 필터 | 화면 내 테이블 검색/필터/그룹 기능 |
|
|
||||||
| `v2-pivot-grid` | 피벗 그리드 | 다차원 데이터 분석 (피벗 테이블) |
|
|
||||||
|
|
||||||
### 레이아웃 컴포넌트
|
|
||||||
| ID | 이름 | 용도 |
|
|
||||||
|----|------|------|
|
|
||||||
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 레이아웃 |
|
|
||||||
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 배치 |
|
|
||||||
| `v2-section-card` | Section Card | 제목/테두리가 있는 그룹화 컨테이너 |
|
|
||||||
| `v2-section-paper` | Section Paper | 배경색 기반 미니멀 그룹화 컨테이너 |
|
|
||||||
| `v2-divider-line` | 구분선 | 영역 구분 |
|
|
||||||
| `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 내부 컴포넌트 반복 렌더링 |
|
|
||||||
| `v2-repeater` | 리피터 | 반복 컨트롤 |
|
|
||||||
|
|
||||||
### 액션/기타 컴포넌트
|
|
||||||
| ID | 이름 | 용도 |
|
|
||||||
|----|------|------|
|
|
||||||
| `v2-button-primary` | 기본 버튼 | 저장, 삭제 등 액션 버튼 |
|
|
||||||
| `v2-numbering-rule` | 채번규칙 | 자동 코드/번호 생성 |
|
|
||||||
| `v2-category-manager` | 카테고리 관리자 | 카테고리 관리 |
|
|
||||||
| `v2-location-swap-selector` | 위치 교환 선택기 | 위치 교환 기능 |
|
|
||||||
| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 |
|
|
||||||
| `v2-media` | 미디어 | 미디어 표시 |
|
|
||||||
|
|
||||||
**총 23개 V2 컴포넌트**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 화면 분류 (메뉴별)
|
|
||||||
|
|
||||||
### 01. 기준정보 (master-data)
|
|
||||||
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|
|
||||||
|--------|--------|------|----------|
|
|
||||||
| 회사정보 | 회사정보.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
| 부서정보 | 부서정보.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
| 품목정보 | 품목정보.html | 검색+테이블+그룹화 | ⚠️ 그룹화 미지원 |
|
|
||||||
| BOM관리 | BOM관리.html | 분할패널+트리 | ⚠️ 트리뷰 미지원 |
|
|
||||||
| 공정정보관리 | 공정정보관리.html | 분할패널+테이블 | ✅ 완전 |
|
|
||||||
| 공정작업기준 | 공정작업기준관리.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
| 품목라우팅 | 품목라우팅관리.html | 분할패널+테이블 | ✅ 완전 |
|
|
||||||
|
|
||||||
### 02. 영업관리 (sales)
|
|
||||||
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|
|
||||||
|--------|--------|------|----------|
|
|
||||||
| 수주관리 | 수주관리.html | 분할패널+테이블 | ✅ 완전 |
|
|
||||||
| 견적관리 | 견적관리.html | 분할패널+테이블 | ✅ 완전 |
|
|
||||||
| 거래처관리 | 거래처관리.html | 분할패널+탭+그룹화 | ⚠️ 그룹화 미지원 |
|
|
||||||
| 판매품목정보 | 판매품목정보.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
| 출하계획관리 | 출하계획관리.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
|
|
||||||
### 03. 생산관리 (production)
|
|
||||||
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|
|
||||||
|--------|--------|------|----------|
|
|
||||||
| 생산계획관리 | 생산계획관리.html | 분할패널+탭+타임라인 | ❌ 타임라인 미지원 |
|
|
||||||
| 생산관리 | 생산관리.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
| 생산실적관리 | 생산실적관리.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
| 작업지시 | 작업지시.html | 탭+그룹화테이블+분할패널 | ⚠️ 그룹화 미지원 |
|
|
||||||
| 공정관리 | 공정관리.html | 분할패널+테이블 | ✅ 완전 |
|
|
||||||
|
|
||||||
### 04. 구매관리 (purchase)
|
|
||||||
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|
|
||||||
|--------|--------|------|----------|
|
|
||||||
| 발주관리 | 발주관리.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
| 공급업체관리 | 공급업체관리.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
| 구매입고 | pages/구매입고.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
|
|
||||||
### 05. 설비관리 (equipment)
|
|
||||||
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|
|
||||||
|--------|--------|------|----------|
|
|
||||||
| 설비정보 | 설비정보.html | 분할패널+카드+탭 | ✅ v2-card-display 활용 |
|
|
||||||
|
|
||||||
### 06. 물류관리 (logistics)
|
|
||||||
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|
|
||||||
|--------|--------|------|----------|
|
|
||||||
| 창고관리 | 창고관리.html | 모바일앱스타일+iframe | ❌ 별도개발 필요 |
|
|
||||||
| 창고정보관리 | 창고정보관리.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
| 입출고관리 | 입출고관리.html | 검색+테이블+그룹화 | ⚠️ 그룹화 미지원 |
|
|
||||||
| 재고현황 | 재고현황.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
|
|
||||||
### 07. 품질관리 (quality)
|
|
||||||
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|
|
||||||
|--------|--------|------|----------|
|
|
||||||
| 검사기준 | 검사기준.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
| 검사정보관리 | 검사정보관리.html | 탭+테이블 | ✅ 완전 |
|
|
||||||
| 검사장비관리 | 검사장비관리.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
| 불량관리 | 불량관리.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
| 클레임관리 | 클레임관리.html | 검색+테이블 | ✅ 완전 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 화면 UI 패턴 분석
|
|
||||||
|
|
||||||
### 패턴 A: 검색 + 테이블 (가장 기본)
|
|
||||||
**해당 화면**: 약 60% (15개 이상)
|
|
||||||
|
|
||||||
**사용 컴포넌트**:
|
|
||||||
- `v2-table-search-widget`: 검색 필터
|
|
||||||
- `v2-table-list`: 데이터 테이블
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ [검색필드들...] [조회] [엑셀] │ ← v2-table-search-widget
|
|
||||||
├─────────────────────────────────────────┤
|
|
||||||
│ 테이블 제목 [신규등록] [삭제] │
|
|
||||||
│ ────────────────────────────────────── │
|
|
||||||
│ □ | 코드 | 이름 | 상태 | 등록일 | │ ← v2-table-list
|
|
||||||
│ □ | A001 | 테스트| 사용 | 2026-01-30 | │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 패턴 B: 분할 패널 (마스터-디테일)
|
|
||||||
**해당 화면**: 약 25% (8개)
|
|
||||||
|
|
||||||
**사용 컴포넌트**:
|
|
||||||
- `v2-split-panel-layout`: 좌우 분할
|
|
||||||
- `v2-table-list`: 마스터/디테일 테이블
|
|
||||||
- `v2-tabs-widget`: 상세 탭 (선택)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────┬──────────────────────┐
|
|
||||||
│ 마스터 리스트 │ 상세 정보 / 탭 │
|
|
||||||
│ ─────────────── │ ┌────┬────┬────┐ │
|
|
||||||
│ □ A001 제품A │ │기본│이력│첨부│ │
|
|
||||||
│ □ A002 제품B ← │ └────┴────┴────┘ │
|
|
||||||
│ □ A003 제품C │ [테이블 or 폼] │
|
|
||||||
└──────────────────┴──────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 패턴 C: 탭 + 테이블
|
|
||||||
**해당 화면**: 약 10% (3개)
|
|
||||||
|
|
||||||
**사용 컴포넌트**:
|
|
||||||
- `v2-tabs-widget`: 탭 전환
|
|
||||||
- `v2-table-list`: 탭별 테이블
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ [탭1] [탭2] [탭3] │
|
|
||||||
├─────────────────────────────────────────┤
|
|
||||||
│ [테이블 영역] │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 패턴 D: 특수 UI
|
|
||||||
**해당 화면**: 약 5% (2개)
|
|
||||||
|
|
||||||
- 생산계획관리: 타임라인/간트 차트 → **v2-timeline 미존재**
|
|
||||||
- 창고관리: 모바일 앱 스타일 → **별도 개발 필요**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 신규 컴포넌트 분석 (3개 이상 재활용 기준)
|
|
||||||
|
|
||||||
### 4.1 v2-grouped-table (그룹화 테이블)
|
|
||||||
**재활용 화면 수**: 5개 이상 ✅
|
|
||||||
|
|
||||||
| 화면 | 그룹화 기준 |
|
|
||||||
|------|------------|
|
|
||||||
| 품목정보 | 품목구분, 카테고리 |
|
|
||||||
| 거래처관리 | 거래처유형, 지역 |
|
|
||||||
| 작업지시 | 작업일자, 공정 |
|
|
||||||
| 입출고관리 | 입출고구분, 창고 |
|
|
||||||
| 견적관리 | 상태, 거래처 |
|
|
||||||
|
|
||||||
**기능 요구사항**:
|
|
||||||
- 특정 컬럼 기준 그룹핑
|
|
||||||
- 그룹 접기/펼치기
|
|
||||||
- 그룹 헤더에 집계 표시
|
|
||||||
- 다중 그룹핑 지원
|
|
||||||
|
|
||||||
**구현 복잡도**: 중
|
|
||||||
|
|
||||||
### 4.2 v2-tree-view (트리 뷰)
|
|
||||||
**재활용 화면 수**: 3개 ✅
|
|
||||||
|
|
||||||
| 화면 | 트리 용도 |
|
|
||||||
|------|----------|
|
|
||||||
| BOM관리 | BOM 구조 (정전개/역전개) |
|
|
||||||
| 부서정보 | 조직도 |
|
|
||||||
| 메뉴관리 | 메뉴 계층 |
|
|
||||||
|
|
||||||
**기능 요구사항**:
|
|
||||||
- 노드 접기/펼치기
|
|
||||||
- 드래그앤드롭 (선택)
|
|
||||||
- 정전개/역전개 전환
|
|
||||||
- 노드 선택 이벤트
|
|
||||||
|
|
||||||
**구현 복잡도**: 중상
|
|
||||||
|
|
||||||
### 4.3 v2-timeline-scheduler (타임라인)
|
|
||||||
**재활용 화면 수**: 1~2개 (기준 미달)
|
|
||||||
|
|
||||||
| 화면 | 용도 |
|
|
||||||
|------|------|
|
|
||||||
| 생산계획관리 | 간트 차트 |
|
|
||||||
| 설비 가동 현황 | 타임라인 |
|
|
||||||
|
|
||||||
**기능 요구사항**:
|
|
||||||
- 시간축 기반 배치
|
|
||||||
- 드래그로 일정 변경
|
|
||||||
- 공정별 색상 구분
|
|
||||||
- 줌 인/아웃
|
|
||||||
|
|
||||||
**구현 복잡도**: 상
|
|
||||||
|
|
||||||
> **참고**: 3개 미만이므로 우선순위 하향
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 컴포넌트 커버리지
|
|
||||||
|
|
||||||
### 현재 V2 컴포넌트로 구현 가능
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 17개 화면 (65%) │
|
|
||||||
│ - 기본 검색 + 테이블 패턴 │
|
|
||||||
│ - 분할 패널 │
|
|
||||||
│ - 탭 전환 │
|
|
||||||
│ - 카드 디스플레이 │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### v2-grouped-table 개발 후
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ +5개 화면 (22개, 85%) │
|
|
||||||
│ - 품목정보, 거래처관리, 작업지시 │
|
|
||||||
│ - 입출고관리, 견적관리 │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### v2-tree-view 개발 후
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ +2개 화면 (24개, 92%) │
|
|
||||||
│ - BOM관리, 부서정보(계층) │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 별도 개발 필요
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 2개 화면 (8%) │
|
|
||||||
│ - 생산계획관리 (타임라인) │
|
|
||||||
│ - 창고관리 (모바일 앱 스타일) │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 신규 컴포넌트 개발 우선순위
|
|
||||||
|
|
||||||
| 순위 | 컴포넌트 | 재활용 화면 수 | 복잡도 | ROI |
|
|
||||||
|------|----------|--------------|--------|-----|
|
|
||||||
| 1 | v2-grouped-table | 5+ | 중 | ⭐⭐⭐⭐⭐ |
|
|
||||||
| 2 | v2-tree-view | 3 | 중상 | ⭐⭐⭐⭐ |
|
|
||||||
| 3 | v2-timeline-scheduler | 1~2 | 상 | ⭐⭐ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 권장 구현 전략
|
|
||||||
|
|
||||||
### Phase 1: 즉시 구현 (현재 V2 컴포넌트)
|
|
||||||
- 회사정보, 부서정보
|
|
||||||
- 발주관리, 공급업체관리
|
|
||||||
- 검사기준, 검사장비관리, 불량관리
|
|
||||||
- 창고정보관리, 재고현황
|
|
||||||
- 공정작업기준관리
|
|
||||||
- 수주관리, 견적관리, 공정관리
|
|
||||||
- 설비정보 (v2-card-display 활용)
|
|
||||||
- 검사정보관리
|
|
||||||
|
|
||||||
### Phase 2: v2-grouped-table 개발 후
|
|
||||||
- 품목정보, 거래처관리, 입출고관리
|
|
||||||
- 작업지시
|
|
||||||
|
|
||||||
### Phase 3: v2-tree-view 개발 후
|
|
||||||
- BOM관리
|
|
||||||
- 부서정보 (계층 뷰)
|
|
||||||
|
|
||||||
### Phase 4: 개별 개발
|
|
||||||
- 생산계획관리 (타임라인)
|
|
||||||
- 창고관리 (모바일 스타일)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 요약
|
|
||||||
|
|
||||||
| 항목 | 수치 |
|
|
||||||
|------|------|
|
|
||||||
| 전체 분석 화면 수 | 26개 |
|
|
||||||
| 현재 즉시 구현 가능 | 17개 (65%) |
|
|
||||||
| v2-grouped-table 추가 시 | 22개 (85%) |
|
|
||||||
| v2-tree-view 추가 시 | 24개 (92%) |
|
|
||||||
| 별도 개발 필요 | 2개 (8%) |
|
|
||||||
|
|
||||||
**핵심 결론**:
|
|
||||||
1. **현재 V2 컴포넌트**로 65% 화면 구현 가능
|
|
||||||
2. **v2-grouped-table** 1개 컴포넌트 개발로 85%까지 확대
|
|
||||||
3. **v2-tree-view** 추가로 92% 도달
|
|
||||||
4. 나머지 8%는 화면별 특수 UI (타임라인, 모바일 스타일)로 개별 개발 필요
|
|
||||||
|
|
@ -1,631 +0,0 @@
|
||||||
# V2 공통 컴포넌트 사용 가이드
|
|
||||||
|
|
||||||
> **목적**: 다양한 회사에서 V2 컴포넌트를 활용하여 화면을 개발할 때 참고하는 범용 가이드
|
|
||||||
> **대상**: 화면 설계자, 개발자
|
|
||||||
> **버전**: 1.1.0
|
|
||||||
> **작성일**: 2026-02-23 (최종 업데이트)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. V2 컴포넌트로 가능한 것 / 불가능한 것
|
|
||||||
|
|
||||||
### 1.1 가능한 화면 유형
|
|
||||||
|
|
||||||
| 화면 유형 | 설명 | 대표 예시 |
|
|
||||||
|-----------|------|----------|
|
|
||||||
| 마스터 관리 | 단일 테이블 CRUD | 회사정보, 부서정보, 코드관리 |
|
|
||||||
| 마스터-디테일 | 좌측 선택 → 우측 상세 | 공정관리, 품목라우팅, 견적관리 |
|
|
||||||
| 탭 기반 화면 | 탭별 다른 테이블/뷰 | 검사정보관리, 거래처관리 |
|
|
||||||
| 카드 뷰 | 이미지+정보 카드 형태 | 설비정보, 대시보드 |
|
|
||||||
| 피벗 분석 | 다차원 집계 | 매출분석, 재고현황 |
|
|
||||||
| 반복 컨테이너 | 데이터 수만큼 UI 반복 | 주문 상세, 항목 리스트 |
|
|
||||||
| 그룹화 테이블 | 그룹핑 기능 포함 테이블 | 카테고리별 집계, 부서별 현황 |
|
|
||||||
| 타임라인/스케줄 | 시간축 기반 일정 관리 | 생산일정, 작업스케줄 |
|
|
||||||
|
|
||||||
### 1.2 불가능한 화면 유형 (별도 개발 필요)
|
|
||||||
|
|
||||||
| 화면 유형 | 이유 | 해결 방안 |
|
|
||||||
|-----------|------|----------|
|
|
||||||
| 트리 뷰 (계층 구조) | 트리 컴포넌트 미존재 | `v2-tree-view` 개발 필요 |
|
|
||||||
| 드래그앤드롭 보드 | 칸반 스타일 UI 없음 | 별도 개발 |
|
|
||||||
| 모바일 앱 스타일 | 네이티브 앱 UI | 별도 개발 |
|
|
||||||
| 복잡한 차트 | 기본 집계 외 시각화 | 차트 라이브러리 연동 |
|
|
||||||
|
|
||||||
> **참고**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)는 v1.1에서 추가되어 이제 지원됩니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. V2 컴포넌트 전체 목록 (25개)
|
|
||||||
|
|
||||||
### 2.1 입력 컴포넌트 (4개)
|
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
|
||||||
|----|------|------|----------|
|
|
||||||
| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 슬라이더, 컬러 | inputType(text/number/password/slider/color/button), format(email/tel/url/currency/biz_no), required, readonly, maxLength, min, max, step |
|
|
||||||
| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크, 태그, 토글, 스왑 | mode(dropdown/combobox/radio/check/tag/tagbox/toggle/swap), source(static/code/db/api/entity/category/distinct/select), searchable, multiple, cascading |
|
|
||||||
| `v2-date` | 날짜 | 날짜, 시간, 날짜시간 | dateType(date/time/datetime), format, range, minDate, maxDate, showToday |
|
|
||||||
| `v2-file-upload` | 파일 업로드 | 파일/이미지 업로드 | - |
|
|
||||||
|
|
||||||
### 2.2 표시 컴포넌트 (3개)
|
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
|
||||||
|----|------|------|----------|
|
|
||||||
| `v2-text-display` | 텍스트 표시 | 라벨, 제목, 설명 텍스트 | fontSize, fontWeight, color, textAlign |
|
|
||||||
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | cardsPerRow, cardSpacing, columnMapping(titleColumn/subtitleColumn/descriptionColumn/imageColumn), cardStyle(imagePosition/imageSize), dataSource(table/static/api) |
|
|
||||||
| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수, 최대, 최소 | items, filters, layout |
|
|
||||||
|
|
||||||
### 2.3 테이블/데이터 컴포넌트 (4개)
|
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
|
||||||
|----|------|------|----------|
|
|
||||||
| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 | selectedTable, columns, pagination, filter, displayMode(table/card), checkbox, horizontalScroll, linkedFilters, excludeFilter, toolbar, tableStyle, autoLoad |
|
|
||||||
| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터/그룹 | autoSelectFirstTable, showTableSelector, title |
|
|
||||||
| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 (행/열/데이터 영역) | fields(area: row/column/data/filter, summaryType: sum/avg/count/min/max/countDistinct, groupInterval: year/quarter/month/week/day), dataSource(type: table/api/static, joinConfigs, filterConditions) |
|
|
||||||
| `v2-table-grouped` | 그룹화 테이블 | 그룹핑 기능이 포함된 테이블 | - |
|
|
||||||
|
|
||||||
### 2.4 레이아웃 컴포넌트 (7개)
|
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
|
||||||
|----|------|------|----------|
|
|
||||||
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, minLeftWidth, minRightWidth, syncSelection, panel별: displayMode(list/table/custom), relation(type/foreignKey), editButton, addButton, deleteButton, additionalTabs |
|
|
||||||
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs(id/label/order/disabled/components), defaultTab, orientation(horizontal/vertical), allowCloseable, persistSelection |
|
|
||||||
| `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding |
|
|
||||||
| `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow |
|
|
||||||
| `v2-divider-line` | 구분선 | 영역 구분 | orientation, thickness |
|
|
||||||
| `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 반복 렌더링 | dataSourceType, layout, gridColumns |
|
|
||||||
| `v2-repeater` | 리피터 | 반복 컨트롤 (inline/modal) | - |
|
|
||||||
|
|
||||||
### 2.5 액션/특수 컴포넌트 (7개)
|
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
|
||||||
|----|------|------|----------|
|
|
||||||
| `v2-button-primary` | 기본 버튼 | 저장, 삭제 등 액션 | text, actionType, variant |
|
|
||||||
| `v2-numbering-rule` | 채번규칙 | 자동 코드/번호 생성 | rule, prefix, format |
|
|
||||||
| `v2-category-manager` | 카테고리 관리자 | 카테고리 관리 UI | - |
|
|
||||||
| `v2-location-swap-selector` | 위치 교환 | 위치 선택/교환 | - |
|
|
||||||
| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 | - |
|
|
||||||
| `v2-media` | 미디어 | 이미지/동영상 표시 | - |
|
|
||||||
| `v2-timeline-scheduler` | 타임라인 스케줄러 | 시간축 기반 일정/작업 관리 | - |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 화면 패턴별 컴포넌트 조합
|
|
||||||
|
|
||||||
### 3.1 패턴 A: 기본 마스터 화면 (가장 흔함)
|
|
||||||
|
|
||||||
**적용 화면**: 코드관리, 사용자관리, 부서정보, 창고정보 등
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ v2-table-search-widget │
|
|
||||||
│ [검색필드1] [검색필드2] [조회] [엑셀] │
|
|
||||||
├─────────────────────────────────────────────────┤
|
|
||||||
│ v2-table-list │
|
|
||||||
│ 제목 [신규] [삭제] │
|
|
||||||
│ ─────────────────────────────────────────────── │
|
|
||||||
│ □ | 코드 | 이름 | 상태 | 등록일 | │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**필수 컴포넌트**:
|
|
||||||
- `v2-table-search-widget` (1개)
|
|
||||||
- `v2-table-list` (1개)
|
|
||||||
|
|
||||||
**설정 포인트**:
|
|
||||||
- 테이블명 지정
|
|
||||||
- 검색 대상 컬럼 설정
|
|
||||||
- 컬럼 표시/숨김 설정
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.2 패턴 B: 마스터-디테일 화면
|
|
||||||
|
|
||||||
**적용 화면**: 공정관리, 견적관리, 수주관리, 품목라우팅 등
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────┬──────────────────────────────┐
|
|
||||||
│ v2-table-list │ v2-table-list 또는 폼 │
|
|
||||||
│ (마스터) │ (디테일) │
|
|
||||||
│ ─────────────── │ │
|
|
||||||
│ □ A001 항목1 │ [상세 정보] │
|
|
||||||
│ □ A002 항목2 ← │ │
|
|
||||||
│ □ A003 항목3 │ │
|
|
||||||
└──────────────────┴──────────────────────────────┘
|
|
||||||
v2-split-panel-layout
|
|
||||||
```
|
|
||||||
|
|
||||||
**필수 컴포넌트**:
|
|
||||||
- `v2-split-panel-layout` (1개)
|
|
||||||
- `v2-table-list` (2개: 마스터, 디테일)
|
|
||||||
|
|
||||||
**설정 포인트**:
|
|
||||||
- `splitRatio`: 좌우 비율 (기본 30:70)
|
|
||||||
- `relation.type`: join / detail / custom
|
|
||||||
- `relation.foreignKey`: 연결 키 컬럼
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.3 패턴 C: 마스터-디테일 + 탭
|
|
||||||
|
|
||||||
**적용 화면**: 거래처관리, 품목정보, 설비정보 등
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────┬──────────────────────────────┐
|
|
||||||
│ v2-table-list │ v2-tabs-widget │
|
|
||||||
│ (마스터) │ ┌────┬────┬────┐ │
|
|
||||||
│ │ │기본│이력│첨부│ │
|
|
||||||
│ □ A001 거래처1 │ └────┴────┴────┘ │
|
|
||||||
│ □ A002 거래처2 ← │ [탭별 컨텐츠] │
|
|
||||||
└──────────────────┴──────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**필수 컴포넌트**:
|
|
||||||
- `v2-split-panel-layout` (1개)
|
|
||||||
- `v2-table-list` (1개: 마스터)
|
|
||||||
- `v2-tabs-widget` (1개)
|
|
||||||
|
|
||||||
**설정 포인트**:
|
|
||||||
- 탭별 표시할 테이블/폼 설정
|
|
||||||
- 마스터 선택 시 탭 컨텐츠 연동
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.4 패턴 D: 카드 뷰
|
|
||||||
|
|
||||||
**적용 화면**: 설비정보, 대시보드, 상품 카탈로그 등
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ v2-table-search-widget │
|
|
||||||
├─────────────────────────────────────────────────┤
|
|
||||||
│ v2-card-display │
|
|
||||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
|
||||||
│ │ [이미지]│ │ [이미지]│ │ [이미지]│ │
|
|
||||||
│ │ 제목 │ │ 제목 │ │ 제목 │ │
|
|
||||||
│ │ 설명 │ │ 설명 │ │ 설명 │ │
|
|
||||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**필수 컴포넌트**:
|
|
||||||
- `v2-table-search-widget` (1개)
|
|
||||||
- `v2-card-display` (1개)
|
|
||||||
|
|
||||||
**설정 포인트**:
|
|
||||||
- `cardsPerRow`: 한 행당 카드 수
|
|
||||||
- `columnMapping`: 제목, 부제목, 이미지, 상태 필드 매핑
|
|
||||||
- `cardStyle`: 이미지 위치, 크기
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.5 패턴 E: 피벗 분석
|
|
||||||
|
|
||||||
**적용 화면**: 매출분석, 재고현황, 생산실적 분석 등
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ v2-pivot-grid │
|
|
||||||
│ │ 2024년 │ 2025년 │ 2026년 │ 합계 │
|
|
||||||
│ ─────────────────────────────────────────────── │
|
|
||||||
│ 지역A │ 1,000 │ 1,200 │ 1,500 │ 3,700 │
|
|
||||||
│ 지역B │ 2,000 │ 2,500 │ 3,000 │ 7,500 │
|
|
||||||
│ 합계 │ 3,000 │ 3,700 │ 4,500 │ 11,200 │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**필수 컴포넌트**:
|
|
||||||
- `v2-pivot-grid` (1개)
|
|
||||||
|
|
||||||
**설정 포인트**:
|
|
||||||
- `fields[].area`: row / column / data / filter
|
|
||||||
- `fields[].summaryType`: sum / avg / count / min / max
|
|
||||||
- `fields[].groupInterval`: 날짜 그룹화 (year/quarter/month)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 회사별 개발 시 핵심 체크포인트
|
|
||||||
|
|
||||||
### 4.1 테이블 설계 확인
|
|
||||||
|
|
||||||
**가장 먼저 확인**:
|
|
||||||
1. 회사에서 사용할 테이블 목록
|
|
||||||
2. 테이블 간 관계 (FK)
|
|
||||||
3. 조회 조건으로 쓸 컬럼
|
|
||||||
|
|
||||||
```
|
|
||||||
✅ 체크리스트:
|
|
||||||
□ 테이블명이 DB에 존재하는가?
|
|
||||||
□ company_code 컬럼이 있는가? (멀티테넌시)
|
|
||||||
□ 마스터-디테일 관계의 FK가 정의되어 있는가?
|
|
||||||
□ 검색 대상 컬럼에 인덱스가 있는가?
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 화면 패턴 판단
|
|
||||||
|
|
||||||
**질문을 통한 판단**:
|
|
||||||
|
|
||||||
| 질문 | 예 → 패턴 |
|
|
||||||
|------|----------|
|
|
||||||
| 단일 테이블만 조회/편집? | 패턴 A (기본 마스터) |
|
|
||||||
| 마스터 선택 시 디테일 표시? | 패턴 B (마스터-디테일) |
|
|
||||||
| 상세에 탭이 필요? | 패턴 C (마스터-디테일+탭) |
|
|
||||||
| 이미지+정보 카드 형태? | 패턴 D (카드 뷰) |
|
|
||||||
| 다차원 집계/분석? | 패턴 E (피벗) |
|
|
||||||
|
|
||||||
### 4.3 컴포넌트 설정 필수 항목
|
|
||||||
|
|
||||||
#### v2-table-list 필수 설정
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
selectedTable: "테이블명", // 필수
|
|
||||||
columns: [ // 표시할 컬럼
|
|
||||||
{ columnName: "id", displayName: "ID", visible: true, sortable: true },
|
|
||||||
// ...
|
|
||||||
],
|
|
||||||
pagination: {
|
|
||||||
enabled: true,
|
|
||||||
pageSize: 20,
|
|
||||||
showSizeSelector: true,
|
|
||||||
showPageInfo: true
|
|
||||||
},
|
|
||||||
displayMode: "table", // "table" | "card"
|
|
||||||
checkbox: {
|
|
||||||
enabled: true,
|
|
||||||
multiple: true,
|
|
||||||
position: "left",
|
|
||||||
selectAll: true
|
|
||||||
},
|
|
||||||
horizontalScroll: { // 가로 스크롤 설정
|
|
||||||
enabled: true,
|
|
||||||
maxVisibleColumns: 8
|
|
||||||
},
|
|
||||||
linkedFilters: [], // 연결 필터 (다른 컴포넌트와 연동)
|
|
||||||
excludeFilter: {}, // 제외 필터
|
|
||||||
autoLoad: true, // 자동 데이터 로드
|
|
||||||
stickyHeader: false, // 헤더 고정
|
|
||||||
autoWidth: true // 자동 너비 조정
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### v2-split-panel-layout 필수 설정
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
leftPanel: {
|
|
||||||
displayMode: "table", // "list" | "table" | "custom"
|
|
||||||
tableName: "마스터_테이블명",
|
|
||||||
columns: [], // 컬럼 설정
|
|
||||||
editButton: { // 수정 버튼 설정
|
|
||||||
enabled: true,
|
|
||||||
mode: "auto", // "auto" | "modal"
|
|
||||||
modalScreenId: "" // 모달 모드 시 화면 ID
|
|
||||||
},
|
|
||||||
addButton: { // 추가 버튼 설정
|
|
||||||
enabled: true,
|
|
||||||
mode: "auto",
|
|
||||||
modalScreenId: ""
|
|
||||||
},
|
|
||||||
deleteButton: { // 삭제 버튼 설정
|
|
||||||
enabled: true,
|
|
||||||
buttonLabel: "삭제",
|
|
||||||
confirmMessage: "삭제하시겠습니까?"
|
|
||||||
},
|
|
||||||
addModalColumns: [], // 추가 모달 전용 컬럼
|
|
||||||
additionalTabs: [] // 추가 탭 설정
|
|
||||||
},
|
|
||||||
rightPanel: {
|
|
||||||
displayMode: "table",
|
|
||||||
tableName: "디테일_테이블명",
|
|
||||||
relation: {
|
|
||||||
type: "detail", // "join" | "detail" | "custom"
|
|
||||||
foreignKey: "master_id", // 연결 키
|
|
||||||
leftColumn: "", // 좌측 연결 컬럼
|
|
||||||
rightColumn: "", // 우측 연결 컬럼
|
|
||||||
keys: [] // 복합 키
|
|
||||||
}
|
|
||||||
},
|
|
||||||
splitRatio: 30, // 좌측 비율 (0-100)
|
|
||||||
resizable: true, // 리사이즈 가능
|
|
||||||
minLeftWidth: 200, // 좌측 최소 너비
|
|
||||||
minRightWidth: 300, // 우측 최소 너비
|
|
||||||
syncSelection: true, // 선택 동기화
|
|
||||||
autoLoad: true // 자동 로드
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### v2-split-panel-layout 커스텀 모드 (NEW)
|
|
||||||
|
|
||||||
패널 내부에 자유롭게 컴포넌트를 배치할 수 있습니다. (v2-tabs-widget과 동일 구조)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
leftPanel: {
|
|
||||||
displayMode: "custom", // 커스텀 모드 활성화
|
|
||||||
components: [ // 내부 컴포넌트 배열
|
|
||||||
{
|
|
||||||
id: "btn-save",
|
|
||||||
componentType: "v2-button-primary",
|
|
||||||
label: "저장",
|
|
||||||
position: { x: 10, y: 10 },
|
|
||||||
size: { width: 100, height: 40 },
|
|
||||||
componentConfig: { buttonAction: "save" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tbl-list",
|
|
||||||
componentType: "v2-table-list",
|
|
||||||
label: "목록",
|
|
||||||
position: { x: 10, y: 60 },
|
|
||||||
size: { width: 400, height: 300 },
|
|
||||||
componentConfig: { selectedTable: "테이블명" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
rightPanel: {
|
|
||||||
displayMode: "table" // 기존 모드 유지
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**디자인 모드 기능**:
|
|
||||||
- 컴포넌트 클릭 → 좌측 설정 패널에서 속성 편집
|
|
||||||
- 드래그 핸들(상단)로 이동
|
|
||||||
- 리사이즈 핸들(모서리)로 크기 조절
|
|
||||||
- 실제 컴포넌트 미리보기 렌더링
|
|
||||||
|
|
||||||
#### v2-card-display 필수 설정
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
dataSource: "table",
|
|
||||||
columnMapping: {
|
|
||||||
title: "name", // 제목 필드
|
|
||||||
subtitle: "code", // 부제목 필드
|
|
||||||
image: "image_url", // 이미지 필드 (선택)
|
|
||||||
status: "status" // 상태 필드 (선택)
|
|
||||||
},
|
|
||||||
cardsPerRow: 3
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 공통 컴포넌트 한계점
|
|
||||||
|
|
||||||
### 5.1 현재 불가능한 기능
|
|
||||||
|
|
||||||
| 기능 | 상태 | 대안 |
|
|
||||||
|------|------|------|
|
|
||||||
| 트리 뷰 (BOM, 조직도) | ❌ 미지원 | 테이블로 대체 or 별도 개발 |
|
|
||||||
| 드래그앤드롭 정렬 | ❌ 미지원 | 순서 컬럼으로 대체 |
|
|
||||||
| 인라인 편집 | ⚠️ 제한적 | 모달 편집으로 대체 |
|
|
||||||
| 복잡한 차트 | ❌ 미지원 | 외부 라이브러리 연동 |
|
|
||||||
|
|
||||||
> **v1.1 업데이트**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)가 추가되어 해당 기능은 이제 지원됩니다.
|
|
||||||
|
|
||||||
### 5.2 권장하지 않는 조합
|
|
||||||
|
|
||||||
| 조합 | 이유 |
|
|
||||||
|------|------|
|
|
||||||
| 3단계 이상 중첩 분할 | 화면 복잡도 증가, 성능 저하 |
|
|
||||||
| 탭 안에 탭 | 사용성 저하 |
|
|
||||||
| 한 화면에 3개 이상 테이블 | 데이터 로딩 성능 |
|
|
||||||
| 피벗 + 상세 테이블 동시 | 데이터 과부하 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 제어관리 (비즈니스 로직) - 별도 설정 필수
|
|
||||||
|
|
||||||
> **핵심**: V2 컴포넌트는 **UI만 담당**합니다. 비즈니스 로직은 **제어관리**에서 별도 설정해야 합니다.
|
|
||||||
|
|
||||||
### 6.1 UI vs 제어 분리 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 화면 구성 │
|
|
||||||
├─────────────────────────────┬───────────────────────────────────┤
|
|
||||||
│ UI 레이아웃 │ 제어관리 │
|
|
||||||
│ (screen_layouts_v2) │ (dataflow_diagrams) │
|
|
||||||
├─────────────────────────────┼───────────────────────────────────┤
|
|
||||||
│ • 컴포넌트 배치 │ • 버튼 클릭 시 액션 │
|
|
||||||
│ • 검색 필드 구성 │ • INSERT/UPDATE/DELETE 설정 │
|
|
||||||
│ • 테이블 컬럼 표시 │ • 조건부 실행 │
|
|
||||||
│ • 카드/탭 레이아웃 │ • 다중 행 처리 │
|
|
||||||
│ │ • 테이블 간 데이터 이동 │
|
|
||||||
└─────────────────────────────┴───────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 HTML에서 파악 가능/불가능
|
|
||||||
|
|
||||||
| 구분 | HTML에서 파악 | 이유 |
|
|
||||||
|------|--------------|------|
|
|
||||||
| 컴포넌트 배치 | ✅ 가능 | HTML 구조에서 보임 |
|
|
||||||
| 검색 필드 | ✅ 가능 | input 태그로 확인 |
|
|
||||||
| 테이블 컬럼 | ✅ 가능 | thead에서 확인 |
|
|
||||||
| **저장 테이블** | ❌ 불가능 | JS/백엔드에서 처리 |
|
|
||||||
| **버튼 액션** | ❌ 불가능 | 제어관리에서 설정 |
|
|
||||||
| **전/후 처리** | ❌ 불가능 | 제어관리에서 설정 |
|
|
||||||
| **다중 행 처리** | ❌ 불가능 | 제어관리에서 설정 |
|
|
||||||
| **테이블 간 관계** | ❌ 불가능 | DB/제어관리에서 설정 |
|
|
||||||
|
|
||||||
### 6.3 제어관리 설정 항목
|
|
||||||
|
|
||||||
#### 트리거 타입
|
|
||||||
- **버튼 클릭 전 (before)**: 클릭 직전 실행
|
|
||||||
- **버튼 클릭 후 (after)**: 클릭 완료 후 실행
|
|
||||||
|
|
||||||
#### 액션 타입
|
|
||||||
- **INSERT**: 새로운 데이터 삽입
|
|
||||||
- **UPDATE**: 기존 데이터 수정
|
|
||||||
- **DELETE**: 데이터 삭제
|
|
||||||
|
|
||||||
#### 조건 설정
|
|
||||||
```typescript
|
|
||||||
// 예: 선택된 행의 상태가 '대기'인 경우에만 실행
|
|
||||||
{
|
|
||||||
field: "status",
|
|
||||||
operator: "=",
|
|
||||||
value: "대기",
|
|
||||||
dataType: "string"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 필드 매핑
|
|
||||||
```typescript
|
|
||||||
// 예: 소스 테이블의 값을 타겟 테이블로 이동
|
|
||||||
{
|
|
||||||
sourceTable: "order_master",
|
|
||||||
sourceField: "order_no",
|
|
||||||
targetTable: "order_history",
|
|
||||||
targetField: "order_no"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.4 제어관리 예시: 수주 확정 버튼
|
|
||||||
|
|
||||||
**시나리오**: 수주 목록에서 3건 선택 후 [확정] 버튼 클릭
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ [확정] 버튼 클릭 │
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ 1. 조건 체크: status = '대기' 인 행만 │
|
|
||||||
│ 2. UPDATE order_master SET status = '확정' WHERE id IN (선택) │
|
|
||||||
│ 3. INSERT order_history (수주이력 테이블에 기록) │
|
|
||||||
│ 4. 외부 시스템 호출 (ERP 연동) │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**제어관리 설정**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"triggerType": "after",
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"actionType": "update",
|
|
||||||
"targetTable": "order_master",
|
|
||||||
"conditions": [{ "field": "status", "operator": "=", "value": "대기" }],
|
|
||||||
"fieldMappings": [{ "targetField": "status", "defaultValue": "확정" }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"actionType": "insert",
|
|
||||||
"targetTable": "order_history",
|
|
||||||
"fieldMappings": [
|
|
||||||
{ "sourceField": "order_no", "targetField": "order_no" },
|
|
||||||
{ "sourceField": "customer_name", "targetField": "customer_name" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.5 회사별 개발 시 제어관리 체크리스트
|
|
||||||
|
|
||||||
```
|
|
||||||
□ 버튼별 액션 정의
|
|
||||||
- 어떤 버튼이 있는가?
|
|
||||||
- 각 버튼 클릭 시 무슨 동작?
|
|
||||||
|
|
||||||
□ 저장/수정/삭제 대상 테이블
|
|
||||||
- 메인 테이블은?
|
|
||||||
- 이력 테이블은?
|
|
||||||
- 연관 테이블은?
|
|
||||||
|
|
||||||
□ 조건부 실행
|
|
||||||
- 특정 상태일 때만 실행?
|
|
||||||
- 특정 값 체크 필요?
|
|
||||||
|
|
||||||
□ 다중 행 처리
|
|
||||||
- 여러 행 선택 후 일괄 처리?
|
|
||||||
- 각 행별 개별 처리?
|
|
||||||
|
|
||||||
□ 외부 연동
|
|
||||||
- ERP/MES 등 외부 시스템 호출?
|
|
||||||
- API 연동 필요?
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 회사별 커스터마이징 영역
|
|
||||||
|
|
||||||
### 7.1 컴포넌트로 처리되는 영역 (표준화)
|
|
||||||
|
|
||||||
| 영역 | 설명 |
|
|
||||||
|------|------|
|
|
||||||
| UI 레이아웃 | 컴포넌트 배치, 크기, 위치 |
|
|
||||||
| 검색 조건 | 화면 디자이너에서 설정 |
|
|
||||||
| 테이블 컬럼 | 표시/숨김, 순서, 너비 |
|
|
||||||
| 기본 CRUD | 조회, 저장, 삭제 자동 처리 |
|
|
||||||
| 페이지네이션 | 자동 처리 |
|
|
||||||
| 정렬/필터 | 자동 처리 |
|
|
||||||
|
|
||||||
### 7.2 회사별 개발 필요 영역
|
|
||||||
|
|
||||||
| 영역 | 설명 | 개발 방법 |
|
|
||||||
|------|------|----------|
|
|
||||||
| 비즈니스 로직 | 저장 전/후 검증, 계산 | 데이터플로우 또는 백엔드 API |
|
|
||||||
| 특수 UI | 간트, 트리, 차트 등 | 별도 컴포넌트 개발 |
|
|
||||||
| 외부 연동 | ERP, MES 등 연계 | 외부 호출 설정 |
|
|
||||||
| 리포트/인쇄 | 전표, 라벨 출력 | 리포트 컴포넌트 |
|
|
||||||
| 결재 프로세스 | 승인/반려 흐름 | 워크플로우 설정 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 빠른 개발 가이드
|
|
||||||
|
|
||||||
### Step 1: 화면 분석
|
|
||||||
1. 어떤 테이블을 사용하는가?
|
|
||||||
2. 테이블 간 관계는?
|
|
||||||
3. 어떤 패턴인가? (A/B/C/D/E)
|
|
||||||
|
|
||||||
### Step 2: 컴포넌트 배치
|
|
||||||
1. 화면 디자이너에서 패턴에 맞는 컴포넌트 배치
|
|
||||||
2. 각 컴포넌트에 테이블/컬럼 설정
|
|
||||||
|
|
||||||
### Step 3: 연동 설정
|
|
||||||
1. 마스터-디테일 관계 설정 (FK)
|
|
||||||
2. 검색 조건 설정
|
|
||||||
3. 버튼 액션 설정
|
|
||||||
|
|
||||||
### Step 4: 테스트
|
|
||||||
1. 데이터 조회 확인
|
|
||||||
2. 마스터 선택 시 디테일 연동 확인
|
|
||||||
3. 저장/삭제 동작 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 요약
|
|
||||||
|
|
||||||
### V2 컴포넌트 커버리지
|
|
||||||
|
|
||||||
| 화면 유형 | 지원 여부 | 주요 컴포넌트 |
|
|
||||||
|-----------|----------|--------------|
|
|
||||||
| 기본 CRUD | ✅ 완전 | v2-table-list, v2-table-search-widget |
|
|
||||||
| 마스터-디테일 | ✅ 완전 | v2-split-panel-layout |
|
|
||||||
| 탭 화면 | ✅ 완전 | v2-tabs-widget |
|
|
||||||
| 카드 뷰 | ✅ 완전 | v2-card-display |
|
|
||||||
| 피벗 분석 | ✅ 완전 | v2-pivot-grid |
|
|
||||||
| 그룹화 테이블 | ✅ 지원 | v2-table-grouped |
|
|
||||||
| 타임라인/스케줄 | ✅ 지원 | v2-timeline-scheduler |
|
|
||||||
| 파일 업로드 | ✅ 지원 | v2-file-upload |
|
|
||||||
| 트리 뷰 | ❌ 미지원 | 개발 필요 |
|
|
||||||
|
|
||||||
### 개발 시 핵심 원칙
|
|
||||||
|
|
||||||
1. **테이블 먼저**: DB 테이블 구조 확인이 최우선
|
|
||||||
2. **패턴 판단**: 5가지 패턴 중 어디에 해당하는지 판단
|
|
||||||
3. **표준 조합**: 검증된 컴포넌트 조합 사용
|
|
||||||
4. **한계 인식**: 불가능한 UI는 조기에 식별하여 별도 개발 계획
|
|
||||||
5. **멀티테넌시**: 모든 테이블에 company_code 필터링 필수
|
|
||||||
6. **제어관리 필수**: UI 완성 후 버튼별 비즈니스 로직 설정 필수
|
|
||||||
|
|
||||||
### UI vs 제어 구분
|
|
||||||
|
|
||||||
| 영역 | 담당 | 설정 위치 |
|
|
||||||
|------|------|----------|
|
|
||||||
| 화면 레이아웃 | V2 컴포넌트 | 화면 디자이너 |
|
|
||||||
| 비즈니스 로직 | 제어관리 | dataflow_diagrams |
|
|
||||||
| 외부 연동 | 외부호출 설정 | external_call_configs |
|
|
||||||
|
|
||||||
**HTML에서 배낄 수 있는 것**: UI 구조만
|
|
||||||
**별도 설정 필요한 것**: 저장 테이블, 버튼 액션, 조건 처리, 다중 행 처리
|
|
||||||
|
|
@ -0,0 +1,952 @@
|
||||||
|
# WACE 화면 시스템 - DB 스키마 & 컴포넌트 설정 전체 레퍼런스
|
||||||
|
|
||||||
|
> **최종 업데이트**: 2026-03-13
|
||||||
|
> **용도**: AI 챗봇이 화면 생성 시 참조하는 DB 스키마, 컴포넌트 전체 설정 사전
|
||||||
|
> **관련 문서**: `v2-component-usage-guide.md` (SQL 템플릿, 실행 예시)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 시스템 아키텍처
|
||||||
|
|
||||||
|
### 렌더링 파이프라인
|
||||||
|
|
||||||
|
```
|
||||||
|
[DB] screen_definitions + screen_layouts_v2
|
||||||
|
→ [Backend API] GET /api/screens/:screenId
|
||||||
|
→ [layoutV2Converter] V2 JSON → Legacy 변환 (기본값 + overrides 병합)
|
||||||
|
→ [ResponsiveGridRenderer] → DynamicComponentRenderer
|
||||||
|
→ [ComponentRegistry] → 실제 React 컴포넌트
|
||||||
|
```
|
||||||
|
|
||||||
|
### 테이블 관계도
|
||||||
|
|
||||||
|
```
|
||||||
|
비즈니스 테이블 ←── table_labels (라벨)
|
||||||
|
←── table_type_columns (컬럼 타입, company_code='*')
|
||||||
|
←── column_labels (한글 라벨)
|
||||||
|
|
||||||
|
screen_definitions ←── screen_layouts_v2 (layout_data JSON)
|
||||||
|
menu_info (메뉴 트리, menu_url → /screen/{screen_code})
|
||||||
|
|
||||||
|
[선택] dataflow_diagrams (비즈니스 로직)
|
||||||
|
[선택] numbering_rules + numbering_rule_parts (채번)
|
||||||
|
[선택] table_column_category_values (카테고리)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. DB 테이블 스키마
|
||||||
|
|
||||||
|
### 2.1 비즈니스 테이블 필수 구조
|
||||||
|
|
||||||
|
> **[최우선 규칙] 비즈니스 테이블에 NOT NULL / UNIQUE 제약조건 절대 금지!**
|
||||||
|
>
|
||||||
|
> 멀티테넌시 환경에서 회사별로 필수값/유니크 규칙이 다를 수 있으므로,
|
||||||
|
> 제약조건은 DB 레벨이 아닌 **`table_type_columns`의 메타데이터(`is_nullable`, `is_unique`)로 논리적 제어**한다.
|
||||||
|
> DB에 직접 NOT NULL/UNIQUE/CHECK/FOREIGN KEY를 걸면 멀티테넌시가 깨진다.
|
||||||
|
>
|
||||||
|
> **허용**: `id` PRIMARY KEY, `DEFAULT` 값만 DB 레벨 설정
|
||||||
|
> **금지**: 비즈니스 컬럼에 `NOT NULL`, `UNIQUE`, `CHECK`, `FOREIGN KEY`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE "{테이블명}" (
|
||||||
|
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
"created_date" timestamp DEFAULT now(),
|
||||||
|
"updated_date" timestamp DEFAULT now(),
|
||||||
|
"writer" varchar(500) DEFAULT NULL,
|
||||||
|
"company_code" varchar(500),
|
||||||
|
-- 모든 비즈니스 컬럼은 varchar(500), NOT NULL/UNIQUE 제약조건 금지
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 table_labels
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| table_name | varchar PK | 테이블명 |
|
||||||
|
| table_label | varchar | 한글 라벨 |
|
||||||
|
| description | text | 설명 |
|
||||||
|
| use_log_table | varchar(1) | 'Y'/'N' |
|
||||||
|
|
||||||
|
### 2.3 table_type_columns
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | serial PK | 자동 증가 |
|
||||||
|
| table_name | varchar | UNIQUE(+column_name+company_code) |
|
||||||
|
| column_name | varchar | 컬럼명 |
|
||||||
|
| company_code | varchar | `'*'` = 전체 공통 |
|
||||||
|
| input_type | varchar | text/number/date/code/entity/select/checkbox/radio/textarea/category/numbering |
|
||||||
|
| detail_settings | text | JSON (code/entity/select 상세) |
|
||||||
|
| is_nullable | varchar | `'Y'`/`'N'` (논리적 필수값 제어) |
|
||||||
|
| display_order | integer | -5~-1: 기본, 0~: 비즈니스 |
|
||||||
|
| column_label | varchar | 컬럼 한글 라벨 |
|
||||||
|
| description | text | 컬럼 설명 |
|
||||||
|
| is_visible | boolean | 화면 표시 여부 (기본 true) |
|
||||||
|
| code_category | varchar | input_type=code일 때 코드 카테고리 |
|
||||||
|
| code_value | varchar | 코드 값 |
|
||||||
|
| reference_table | varchar | input_type=entity일 때 참조 테이블 |
|
||||||
|
| reference_column | varchar | 참조 컬럼 |
|
||||||
|
| display_column | varchar | 참조 표시 컬럼 |
|
||||||
|
| is_unique | varchar | `'Y'`/`'N'` (논리적 유니크 제어) |
|
||||||
|
| category_ref | varchar | 카테고리 참조 |
|
||||||
|
|
||||||
|
### 2.4 screen_definitions
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| screen_id | serial PK | 자동 증가 |
|
||||||
|
| screen_name | varchar NOT NULL | 화면명 |
|
||||||
|
| screen_code | varchar | **조건부 UNIQUE** (`WHERE is_active <> 'D'`) |
|
||||||
|
| table_name | varchar | 메인 테이블명 |
|
||||||
|
| company_code | varchar NOT NULL | 회사 코드 |
|
||||||
|
| description | text | 화면 설명 |
|
||||||
|
| is_active | char(1) | `'Y'`/`'N'`/`'D'` (D=삭제) |
|
||||||
|
| layout_metadata | jsonb | 레이아웃 메타데이터 |
|
||||||
|
| created_date | timestamp | 생성일시 |
|
||||||
|
| created_by | varchar | 생성자 |
|
||||||
|
| updated_date | timestamp | 수정일시 |
|
||||||
|
| updated_by | varchar | 수정자 |
|
||||||
|
| deleted_date | timestamp | 삭제일시 |
|
||||||
|
| deleted_by | varchar | 삭제자 |
|
||||||
|
| delete_reason | text | 삭제 사유 |
|
||||||
|
| db_source_type | varchar | `'internal'` (기본) / `'external'` |
|
||||||
|
| db_connection_id | integer | 외부 DB 연결 ID |
|
||||||
|
| data_source_type | varchar | `'database'` (기본) / `'rest_api'` |
|
||||||
|
| rest_api_connection_id | integer | REST API 연결 ID |
|
||||||
|
| rest_api_endpoint | varchar | REST API 엔드포인트 |
|
||||||
|
| rest_api_json_path | varchar | JSON 응답 경로 (기본 `'data'`) |
|
||||||
|
| source_screen_id | integer | 원본 화면 ID (복사본일 때) |
|
||||||
|
|
||||||
|
> **screen_code UNIQUE 주의**: `is_active = 'D'`(삭제)인 화면은 UNIQUE 대상에서 제외된다. 삭제된 화면과 같은 코드로 새 화면을 만들 수 있지만, 활성 상태(`'Y'`/`'N'`)에서는 중복 불가.
|
||||||
|
|
||||||
|
### 2.5 screen_layouts_v2
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| layout_id | serial PK | 자동 증가 |
|
||||||
|
| screen_id | integer FK | UNIQUE(+company_code+layer_id) |
|
||||||
|
| company_code | varchar NOT NULL | 회사 코드 |
|
||||||
|
| layout_data | jsonb NOT NULL | 전체 레이아웃 JSON (기본 `'{}'`) |
|
||||||
|
| created_at | timestamptz | 생성일시 |
|
||||||
|
| updated_at | timestamptz | 수정일시 |
|
||||||
|
| layer_id | integer | 1=기본 레이어 (기본값 1) |
|
||||||
|
| layer_name | varchar | 레이어명 (기본 `'기본 레이어'`) |
|
||||||
|
| condition_config | jsonb | 레이어 조건부 표시 설정 |
|
||||||
|
|
||||||
|
### 2.6 menu_info
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| objid | numeric PK | BIGINT 고유값 |
|
||||||
|
| menu_type | numeric | 0=화면, 1=폴더 |
|
||||||
|
| parent_obj_id | numeric | 부모 메뉴 objid |
|
||||||
|
| menu_name_kor | varchar | 메뉴명 (한글) |
|
||||||
|
| menu_name_eng | varchar | 메뉴명 (영문) |
|
||||||
|
| seq | numeric | 정렬 순서 |
|
||||||
|
| menu_url | varchar | `/screen/{screen_code}` |
|
||||||
|
| menu_desc | varchar | 메뉴 설명 |
|
||||||
|
| writer | varchar | 작성자 |
|
||||||
|
| regdate | timestamp | 등록일시 |
|
||||||
|
| status | varchar | 상태 (`'active'` 등) |
|
||||||
|
| company_code | varchar | 회사 코드 (기본 `'*'`) |
|
||||||
|
| screen_code | varchar | 연결 화면 코드 |
|
||||||
|
| system_name | varchar | 시스템명 |
|
||||||
|
| lang_key | varchar | 다국어 키 |
|
||||||
|
| lang_key_desc | varchar | 다국어 설명 키 |
|
||||||
|
| menu_code | varchar | 메뉴 코드 |
|
||||||
|
| source_menu_objid | bigint | 원본 메뉴 objid (복사본일 때) |
|
||||||
|
| screen_group_id | integer | 화면 그룹 ID |
|
||||||
|
| menu_icon | varchar | 메뉴 아이콘 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 컴포넌트 전체 설정 레퍼런스 (32개)
|
||||||
|
|
||||||
|
> 아래 설정은 layout_data JSON의 각 컴포넌트 `overrides` 안에 들어가는 값이다.
|
||||||
|
> 기본값과 다른 부분만 overrides에 지정하면 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.1 v2-table-list (데이터 테이블)
|
||||||
|
|
||||||
|
**용도**: DB 테이블 데이터를 테이블/카드 형태로 조회/편집. 가장 핵심적인 컴포넌트.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| tableName | string | - | 조회할 DB 테이블명 |
|
||||||
|
| selectedTable | string | - | tableName 별칭 |
|
||||||
|
| displayMode | `"table"\|"card"` | `"table"` | 테이블 모드 또는 카드 모드 |
|
||||||
|
| autoLoad | boolean | `true` | 화면 로드 시 자동으로 데이터 조회 |
|
||||||
|
| isReadOnly | boolean | false | 읽기 전용 (편집 불가) |
|
||||||
|
| columns | ColumnConfig[] | `[]` | 표시할 컬럼 설정 배열 |
|
||||||
|
| title | string | - | 테이블 상단 제목 |
|
||||||
|
| showHeader | boolean | `true` | 테이블 헤더 행 표시 |
|
||||||
|
| showFooter | boolean | `true` | 테이블 푸터 표시 |
|
||||||
|
| height | string | `"auto"` | 높이 모드 (`"auto"`, `"fixed"`, `"viewport"`) |
|
||||||
|
| fixedHeight | number | - | height="fixed"일 때 고정 높이(px) |
|
||||||
|
| autoWidth | boolean | `true` | 컬럼 너비 자동 계산 |
|
||||||
|
| stickyHeader | boolean | `false` | 스크롤 시 헤더 고정 |
|
||||||
|
|
||||||
|
**checkbox (체크박스 설정)**:
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| enabled | boolean | `true` | 체크박스 사용 여부 |
|
||||||
|
| multiple | boolean | `true` | 다중 선택 허용 |
|
||||||
|
| position | `"left"\|"right"` | `"left"` | 체크박스 위치 |
|
||||||
|
| selectAll | boolean | `true` | 전체 선택 버튼 표시 |
|
||||||
|
|
||||||
|
**pagination (페이지네이션)**:
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| enabled | boolean | `true` | 페이지네이션 사용 |
|
||||||
|
| pageSize | number | `20` | 한 페이지당 행 수 |
|
||||||
|
| showSizeSelector | boolean | `true` | 페이지 크기 변경 드롭다운 |
|
||||||
|
| showPageInfo | boolean | `true` | "1-20 / 100건" 같은 정보 표시 |
|
||||||
|
| pageSizeOptions | number[] | `[10,20,50,100]` | 선택 가능한 페이지 크기 |
|
||||||
|
|
||||||
|
**horizontalScroll (가로 스크롤)**:
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| enabled | boolean | `true` | 가로 스크롤 사용 |
|
||||||
|
| maxVisibleColumns | number | `8` | 스크롤 없이 보이는 최대 컬럼 수 |
|
||||||
|
| minColumnWidth | number | `100` | 컬럼 최소 너비(px) |
|
||||||
|
| maxColumnWidth | number | `300` | 컬럼 최대 너비(px) |
|
||||||
|
|
||||||
|
**tableStyle (스타일)**:
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| theme | string | `"default"` | 테마 (`default`/`striped`/`bordered`/`minimal`) |
|
||||||
|
| headerStyle | string | `"default"` | 헤더 스타일 (`default`/`dark`/`light`) |
|
||||||
|
| rowHeight | string | `"normal"` | 행 높이 (`compact`/`normal`/`comfortable`) |
|
||||||
|
| alternateRows | boolean | `true` | 짝수/홀수 행 색상 교차 |
|
||||||
|
| hoverEffect | boolean | `true` | 마우스 호버 시 행 강조 |
|
||||||
|
| borderStyle | string | `"light"` | 테두리 (`none`/`light`/`heavy`) |
|
||||||
|
|
||||||
|
**toolbar (툴바 버튼)**:
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| showEditMode | boolean | `false` | 즉시저장/배치저장 모드 전환 버튼 |
|
||||||
|
| showExcel | boolean | `false` | Excel 내보내기 버튼 |
|
||||||
|
| showPdf | boolean | `false` | PDF 내보내기 버튼 |
|
||||||
|
| showSearch | boolean | `false` | 테이블 내 검색 |
|
||||||
|
| showRefresh | boolean | `false` | 상단 새로고침 버튼 |
|
||||||
|
| showPaginationRefresh | boolean | `true` | 하단 새로고침 버튼 |
|
||||||
|
|
||||||
|
**filter (필터)**:
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| enabled | boolean | `true` | 필터 기능 사용 |
|
||||||
|
| filters | array | `[]` | 사전 정의 필터 목록 |
|
||||||
|
|
||||||
|
**ColumnConfig (columns 배열 요소)**:
|
||||||
|
|
||||||
|
| 설정 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| columnName | string | DB 컬럼명 |
|
||||||
|
| displayName | string | 화면 표시명 |
|
||||||
|
| visible | boolean | 표시 여부 |
|
||||||
|
| sortable | boolean | 정렬 가능 여부 |
|
||||||
|
| searchable | boolean | 검색 가능 여부 |
|
||||||
|
| editable | boolean | 인라인 편집 가능 여부 |
|
||||||
|
| width | number | 컬럼 너비(px) |
|
||||||
|
| align | `"left"\|"center"\|"right"` | 텍스트 정렬 |
|
||||||
|
| format | string | 포맷 (`text`/`number`/`date`/`currency`/`boolean`) |
|
||||||
|
| hidden | boolean | 숨김 (데이터는 로드하되 표시 안 함) |
|
||||||
|
| fixed | `"left"\|"right"\|false` | 컬럼 고정 위치 |
|
||||||
|
| thousandSeparator | boolean | 숫자 천 단위 콤마 |
|
||||||
|
| isEntityJoin | boolean | 엔티티 조인 사용 여부 |
|
||||||
|
| entityJoinInfo | object | 조인 정보 (`sourceTable`, `sourceColumn`, `referenceTable`, `joinAlias`) |
|
||||||
|
|
||||||
|
**cardConfig (displayMode="card"일 때)**:
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| idColumn | string | `"id"` | ID 컬럼 |
|
||||||
|
| titleColumn | string | `"name"` | 카드 제목 컬럼 |
|
||||||
|
| subtitleColumn | string | - | 부제목 컬럼 |
|
||||||
|
| descriptionColumn | string | - | 설명 컬럼 |
|
||||||
|
| imageColumn | string | - | 이미지 URL 컬럼 |
|
||||||
|
| cardsPerRow | number | `3` | 행당 카드 수 |
|
||||||
|
| cardSpacing | number | `16` | 카드 간격(px) |
|
||||||
|
| showActions | boolean | `true` | 카드 액션 버튼 표시 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 v2-split-panel-layout (마스터-디테일 분할)
|
||||||
|
|
||||||
|
**용도**: 좌측 마스터 테이블 선택 → 우측 디테일 테이블 연동. 가장 복잡한 컴포넌트.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| splitRatio | number | `30` | 좌측 패널 비율(0~100) |
|
||||||
|
| resizable | boolean | `true` | 사용자가 분할선 드래그로 비율 변경 가능 |
|
||||||
|
| minLeftWidth | number | `200` | 좌측 최소 너비(px) |
|
||||||
|
| minRightWidth | number | `300` | 우측 최소 너비(px) |
|
||||||
|
| autoLoad | boolean | `true` | 화면 로드 시 자동 데이터 조회 |
|
||||||
|
| syncSelection | boolean | `true` | 좌측 선택 시 우측 자동 갱신 |
|
||||||
|
|
||||||
|
**leftPanel / rightPanel 공통 설정**:
|
||||||
|
|
||||||
|
| 설정 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| title | string | 패널 제목 |
|
||||||
|
| tableName | string | DB 테이블명 |
|
||||||
|
| displayMode | `"list"\|"table"\|"custom"` | `list`: 리스트, `table`: 테이블, `custom`: 자유 배치 |
|
||||||
|
| columns | array | 컬럼 설정 (`name`, `label`, `width`, `sortable`, `align`, `isEntityJoin`, `joinInfo`) |
|
||||||
|
| showSearch | boolean | 패널 내 검색 바 표시 |
|
||||||
|
| showAdd | boolean | 추가 버튼 표시 |
|
||||||
|
| showEdit | boolean | 수정 버튼 표시 |
|
||||||
|
| showDelete | boolean | 삭제 버튼 표시 |
|
||||||
|
| addButton | object | `{ enabled, mode("auto"/"modal"), modalScreenId }` |
|
||||||
|
| editButton | object | `{ enabled, mode("auto"/"modal"), modalScreenId, buttonLabel }` |
|
||||||
|
| deleteButton | object | `{ enabled, buttonLabel, confirmMessage }` |
|
||||||
|
| addModalColumns | array | 추가 모달 전용 컬럼 (`name`, `label`, `required`) |
|
||||||
|
| dataFilter | object | `{ enabled, filters, matchType("all"/"any") }` |
|
||||||
|
| tableConfig | object | `{ showCheckbox, showRowNumber, rowHeight, headerHeight, striped, bordered, hoverable, stickyHeader }` |
|
||||||
|
| components | array | displayMode="custom"일 때 내부 컴포넌트 배열 |
|
||||||
|
|
||||||
|
**rightPanel 전용 설정**:
|
||||||
|
|
||||||
|
| 설정 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| relation | object | 마스터-디테일 연결 관계 |
|
||||||
|
| relation.type | `"detail"\|"join"` | detail: FK 관계, join: 테이블 JOIN |
|
||||||
|
| relation.leftColumn | string | 좌측(마스터) 연결 컬럼 (보통 `"id"`) |
|
||||||
|
| relation.rightColumn | string | 우측(디테일) 연결 컬럼 (FK) |
|
||||||
|
| relation.foreignKey | string | FK 컬럼명 (rightColumn과 동일) |
|
||||||
|
| relation.keys | array | 복합키 `[{ leftColumn, rightColumn }]` |
|
||||||
|
| additionalTabs | array | 우측 패널에 탭 추가 (각 탭은 rightPanel과 동일 구조 + `tabId`, `label`) |
|
||||||
|
| addConfig | object | `{ targetTable, autoFillColumns, leftPanelColumn, targetColumn }` |
|
||||||
|
| deduplication | object | `{ enabled, groupByColumn, keepStrategy, sortColumn }` |
|
||||||
|
| summaryColumnCount | number | 요약 표시 컬럼 수 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 v2-table-search-widget (검색 바)
|
||||||
|
|
||||||
|
**용도**: 테이블 상단에 배치하여 검색/필터 기능 제공. 대상 테이블 컬럼을 자동 감지.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| autoSelectFirstTable | boolean | `true` | 화면 내 첫 번째 테이블 자동 연결 |
|
||||||
|
| showTableSelector | boolean | `true` | 테이블 선택 드롭다운 표시 |
|
||||||
|
| title | string | `"테이블 검색"` | 검색 바 제목 |
|
||||||
|
| filterMode | `"dynamic"\|"preset"` | `"dynamic"` | dynamic: 자동 필터, preset: 고정 필터 |
|
||||||
|
| presetFilters | array | `[]` | 고정 필터 목록 (`{ columnName, columnLabel, filterType, width }`) |
|
||||||
|
| targetPanelPosition | `"left"\|"right"\|"auto"` | `"left"` | split-panel에서 대상 패널 위치 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 v2-input (텍스트/숫자 입력)
|
||||||
|
|
||||||
|
**용도**: 텍스트, 숫자, 비밀번호, textarea, 슬라이더, 컬러, 버튼 등 단일 값 입력.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| inputType | string | `"text"` | 입력 유형: `text`/`number`/`password`/`slider`/`color`/`button`/`textarea` |
|
||||||
|
| format | string | `"none"` | 포맷 검증: `none`/`email`/`tel`/`url`/`currency`/`biz_no` |
|
||||||
|
| placeholder | string | `""` | 입력 힌트 텍스트 |
|
||||||
|
| required | boolean | `false` | 필수 입력 표시 |
|
||||||
|
| readonly | boolean | `false` | 읽기 전용 |
|
||||||
|
| disabled | boolean | `false` | 비활성화 |
|
||||||
|
| maxLength | number | - | 최대 입력 글자 수 |
|
||||||
|
| minLength | number | - | 최소 입력 글자 수 |
|
||||||
|
| pattern | string | - | 정규식 패턴 검증 |
|
||||||
|
| showCounter | boolean | `false` | 글자 수 카운터 표시 |
|
||||||
|
| min | number | - | 최소값 (number/slider) |
|
||||||
|
| max | number | - | 최대값 (number/slider) |
|
||||||
|
| step | number | - | 증감 단위 (number/slider) |
|
||||||
|
| buttonText | string | - | 버튼 텍스트 (inputType=button) |
|
||||||
|
| tableName | string | - | 바인딩 테이블명 |
|
||||||
|
| columnName | string | - | 바인딩 컬럼명 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 v2-select (선택)
|
||||||
|
|
||||||
|
**용도**: 드롭다운, 콤보박스, 라디오, 체크박스, 태그, 토글 등 선택형 입력.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| mode | string | `"dropdown"` | 선택 모드: `dropdown`/`combobox`/`radio`/`check`/`tag`/`tagbox`/`toggle`/`swap` |
|
||||||
|
| source | string | `"distinct"` | 데이터 소스: `static`/`code`/`db`/`api`/`entity`/`category`/`distinct`/`select` |
|
||||||
|
| options | array | `[]` | source=static일 때 옵션 목록 `[{ label, value }]` |
|
||||||
|
| codeGroup | string | - | source=code일 때 코드 그룹 |
|
||||||
|
| codeCategory | string | - | source=code일 때 코드 카테고리 |
|
||||||
|
| table | string | - | source=db일 때 테이블명 |
|
||||||
|
| valueColumn | string | - | source=db일 때 값 컬럼 |
|
||||||
|
| labelColumn | string | - | source=db일 때 표시 컬럼 |
|
||||||
|
| entityTable | string | - | source=entity일 때 엔티티 테이블 |
|
||||||
|
| entityValueField | string | - | source=entity일 때 값 필드 |
|
||||||
|
| entityLabelField | string | - | source=entity일 때 표시 필드 |
|
||||||
|
| searchable | boolean | `true` | 검색 가능 (combobox에서 기본 활성) |
|
||||||
|
| multiple | boolean | `false` | 다중 선택 허용 |
|
||||||
|
| maxSelect | number | - | 최대 선택 수 |
|
||||||
|
| allowClear | boolean | - | 선택 해제 허용 |
|
||||||
|
| placeholder | string | `"선택하세요"` | 힌트 텍스트 |
|
||||||
|
| required | boolean | `false` | 필수 선택 |
|
||||||
|
| readonly | boolean | `false` | 읽기 전용 |
|
||||||
|
| disabled | boolean | `false` | 비활성화 |
|
||||||
|
| cascading | object | - | 연쇄 선택 (상위 select 값에 따라 하위 옵션 변경) |
|
||||||
|
| hierarchical | boolean | - | 계층 구조 (부모-자식 관계) |
|
||||||
|
| parentField | string | - | 부모 필드명 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 v2-date (날짜)
|
||||||
|
|
||||||
|
**용도**: 날짜, 시간, 날짜시간, 날짜범위, 월, 연도 입력.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| dateType | string | `"date"` | 날짜 유형: `date`/`datetime`/`time`/`daterange`/`month`/`year` |
|
||||||
|
| format | string | `"YYYY-MM-DD"` | 표시/저장 형식 |
|
||||||
|
| placeholder | string | `"날짜 선택"` | 힌트 텍스트 |
|
||||||
|
| required | boolean | `false` | 필수 입력 |
|
||||||
|
| readonly | boolean | `false` | 읽기 전용 |
|
||||||
|
| disabled | boolean | `false` | 비활성화 |
|
||||||
|
| showTime | boolean | `false` | 시간 선택 표시 (datetime) |
|
||||||
|
| use24Hours | boolean | `true` | 24시간 형식 |
|
||||||
|
| range | boolean | - | 범위 선택 (시작~종료) |
|
||||||
|
| minDate | string | - | 선택 가능 최소 날짜 (ISO 8601) |
|
||||||
|
| maxDate | string | - | 선택 가능 최대 날짜 |
|
||||||
|
| showToday | boolean | - | 오늘 버튼 표시 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.7 v2-button-primary (액션 버튼)
|
||||||
|
|
||||||
|
**용도**: 저장, 삭제, 조회, 커스텀 등 액션 버튼. 제어관리(dataflow)와 연결 가능.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| text | string | `"저장"` | 버튼 텍스트 |
|
||||||
|
| actionType | string | `"button"` | 버튼 타입: `button`/`submit`/`reset` |
|
||||||
|
| variant | string | `"primary"` | 스타일: `primary`/`secondary`/`danger` |
|
||||||
|
| size | string | `"md"` | 크기: `sm`/`md`/`lg` |
|
||||||
|
| disabled | boolean | `false` | 비활성화 |
|
||||||
|
| action | object | - | 액션 설정 |
|
||||||
|
| action.type | string | `"save"` | 액션 유형: `save`/`delete`/`edit`/`copy`/`navigate`/`modal`/`control`/`custom` |
|
||||||
|
| action.successMessage | string | `"저장되었습니다."` | 성공 시 토스트 메시지 |
|
||||||
|
| action.errorMessage | string | `"오류가 발생했습니다."` | 실패 시 토스트 메시지 |
|
||||||
|
| webTypeConfig | object | - | 제어관리 연결 설정 |
|
||||||
|
| webTypeConfig.enableDataflowControl | boolean | - | 제어관리 활성화 |
|
||||||
|
| webTypeConfig.dataflowConfig | object | - | 제어관리 설정 |
|
||||||
|
| webTypeConfig.dataflowConfig.controlMode | string | - | `"relationship"`/`"flow"`/`"none"` |
|
||||||
|
| webTypeConfig.dataflowConfig.relationshipConfig | object | - | `{ relationshipId, executionTiming("before"/"after"/"replace") }` |
|
||||||
|
| webTypeConfig.dataflowConfig.flowConfig | object | - | `{ flowId, executionTiming }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.8 v2-table-grouped (그룹화 테이블)
|
||||||
|
|
||||||
|
**용도**: 특정 컬럼 기준으로 데이터를 그룹화. 그룹별 접기/펼치기, 집계 표시.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| selectedTable | string | `""` | DB 테이블명 |
|
||||||
|
| columns | array | `[]` | 컬럼 설정 (v2-table-list와 동일) |
|
||||||
|
| showCheckbox | boolean | `false` | 체크박스 표시 |
|
||||||
|
| checkboxMode | `"single"\|"multi"` | `"multi"` | 체크박스 모드 |
|
||||||
|
| isReadOnly | boolean | `false` | 읽기 전용 |
|
||||||
|
| rowClickable | boolean | `true` | 행 클릭 가능 |
|
||||||
|
| showExpandAllButton | boolean | `true` | 전체 펼치기/접기 버튼 |
|
||||||
|
| groupHeaderStyle | string | `"default"` | 그룹 헤더 스타일 (`default`/`compact`/`card`) |
|
||||||
|
| emptyMessage | string | `"데이터가 없습니다."` | 빈 데이터 메시지 |
|
||||||
|
| height | string\|number | `"auto"` | 높이 |
|
||||||
|
| maxHeight | number | `600` | 최대 높이(px) |
|
||||||
|
| pagination.enabled | boolean | `false` | 페이지네이션 사용 |
|
||||||
|
| pagination.pageSize | number | `10` | 페이지 크기 |
|
||||||
|
|
||||||
|
**groupConfig (그룹화 설정)**:
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| groupByColumn | string | `""` | **필수**. 그룹화 기준 컬럼 |
|
||||||
|
| groupLabelFormat | string | `"{value}"` | 그룹 라벨 포맷 |
|
||||||
|
| defaultExpanded | boolean | `true` | 초기 펼침 여부 |
|
||||||
|
| sortDirection | `"asc"\|"desc"` | `"asc"` | 그룹 정렬 방향 |
|
||||||
|
| summary.showCount | boolean | `true` | 그룹별 건수 표시 |
|
||||||
|
| summary.sumColumns | string[] | `[]` | 합계 표시할 컬럼 목록 |
|
||||||
|
| summary.avgColumns | string[] | - | 평균 표시 컬럼 |
|
||||||
|
| summary.maxColumns | string[] | - | 최대값 표시 컬럼 |
|
||||||
|
| summary.minColumns | string[] | - | 최소값 표시 컬럼 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.9 v2-pivot-grid (피벗 분석)
|
||||||
|
|
||||||
|
**용도**: 다차원 데이터 분석. 행/열/데이터/필터 영역에 필드를 배치하여 집계.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| fields | array | `[]` | **필수**. 피벗 필드 배열 |
|
||||||
|
| dataSource | object | - | 데이터 소스 (`type`, `tableName`, `joinConfigs`, `filterConditions`) |
|
||||||
|
| allowSortingBySummary | boolean | - | 집계값 기준 정렬 허용 |
|
||||||
|
| allowFiltering | boolean | - | 필터링 허용 |
|
||||||
|
| allowExpandAll | boolean | - | 전체 확장/축소 허용 |
|
||||||
|
| wordWrapEnabled | boolean | - | 텍스트 줄바꿈 |
|
||||||
|
| height | string\|number | - | 높이 |
|
||||||
|
| totals.showRowGrandTotals | boolean | - | 행 총합계 표시 |
|
||||||
|
| totals.showColumnGrandTotals | boolean | - | 열 총합계 표시 |
|
||||||
|
| chart.enabled | boolean | - | 차트 연동 표시 |
|
||||||
|
| chart.type | string | - | 차트 타입 (`bar`/`line`/`area`/`pie`/`stackedBar`) |
|
||||||
|
|
||||||
|
**fields 배열 요소**:
|
||||||
|
|
||||||
|
| 설정 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| field | string | DB 컬럼명 |
|
||||||
|
| caption | string | 표시 라벨 |
|
||||||
|
| area | `"row"\|"column"\|"data"\|"filter"` | **필수**. 배치 영역 |
|
||||||
|
| summaryType | string | area=data일 때: `sum`/`count`/`avg`/`min`/`max`/`countDistinct` |
|
||||||
|
| groupInterval | string | 날짜 그룹화: `year`/`quarter`/`month`/`week`/`day` |
|
||||||
|
| sortBy | string | 정렬 기준: `value`/`caption` |
|
||||||
|
| sortOrder | string | 정렬 방향: `asc`/`desc`/`none` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.10 v2-card-display (카드 뷰)
|
||||||
|
|
||||||
|
**용도**: 테이블 데이터를 카드 형태로 표시. 이미지+제목+설명 구조.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| dataSource | string | `"table"` | 데이터 소스: `table`/`static` |
|
||||||
|
| tableName | string | - | DB 테이블명 |
|
||||||
|
| cardsPerRow | number | `3` | 행당 카드 수 (1~6) |
|
||||||
|
| cardSpacing | number | `16` | 카드 간격(px) |
|
||||||
|
| columnMapping | object | `{}` | 필드 매핑 (`title`, `subtitle`, `description`, `image`, `status`) |
|
||||||
|
| cardStyle.showTitle | boolean | `true` | 제목 표시 |
|
||||||
|
| cardStyle.showSubtitle | boolean | `true` | 부제목 표시 |
|
||||||
|
| cardStyle.showDescription | boolean | `true` | 설명 표시 |
|
||||||
|
| cardStyle.showImage | boolean | `false` | 이미지 표시 |
|
||||||
|
| cardStyle.showActions | boolean | `true` | 액션 버튼 표시 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.11 v2-timeline-scheduler (간트차트)
|
||||||
|
|
||||||
|
**용도**: 시간축 기반 일정/계획 시각화. 드래그/리사이즈로 일정 편집.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| selectedTable | string | - | 스케줄 데이터 테이블 |
|
||||||
|
| resourceTable | string | `"equipment_mng"` | 리소스(설비/작업자) 테이블 |
|
||||||
|
| scheduleType | string | `"PRODUCTION"` | 스케줄 유형: `PRODUCTION`/`MAINTENANCE`/`SHIPPING`/`WORK_ASSIGN` |
|
||||||
|
| defaultZoomLevel | string | `"day"` | 초기 줌: `day`/`week`/`month` |
|
||||||
|
| editable | boolean | `true` | 편집 가능 |
|
||||||
|
| draggable | boolean | `true` | 드래그 이동 허용 |
|
||||||
|
| resizable | boolean | `true` | 기간 리사이즈 허용 |
|
||||||
|
| rowHeight | number | `50` | 행 높이(px) |
|
||||||
|
| headerHeight | number | `60` | 헤더 높이(px) |
|
||||||
|
| resourceColumnWidth | number | `150` | 리소스 컬럼 너비(px) |
|
||||||
|
| cellWidth.day | number | `60` | 일 단위 셀 너비 |
|
||||||
|
| cellWidth.week | number | `120` | 주 단위 셀 너비 |
|
||||||
|
| cellWidth.month | number | `40` | 월 단위 셀 너비 |
|
||||||
|
| showConflicts | boolean | `true` | 시간 겹침 충돌 표시 |
|
||||||
|
| showProgress | boolean | `true` | 진행률 바 표시 |
|
||||||
|
| showTodayLine | boolean | `true` | 오늘 날짜 표시선 |
|
||||||
|
| showToolbar | boolean | `true` | 상단 툴바 표시 |
|
||||||
|
| showAddButton | boolean | `true` | 추가 버튼 |
|
||||||
|
| height | number | `500` | 높이(px) |
|
||||||
|
|
||||||
|
**fieldMapping (필수)**:
|
||||||
|
|
||||||
|
| 설정 | 기본값 | 설명 |
|
||||||
|
|------|--------|------|
|
||||||
|
| id | `"schedule_id"` | 스케줄 PK 필드 |
|
||||||
|
| resourceId | `"resource_id"` | 리소스 FK 필드 |
|
||||||
|
| title | `"schedule_name"` | 제목 필드 |
|
||||||
|
| startDate | `"start_date"` | 시작일 필드 |
|
||||||
|
| endDate | `"end_date"` | 종료일 필드 |
|
||||||
|
| status | - | 상태 필드 |
|
||||||
|
| progress | - | 진행률 필드 (0~100) |
|
||||||
|
|
||||||
|
**resourceFieldMapping**:
|
||||||
|
|
||||||
|
| 설정 | 기본값 | 설명 |
|
||||||
|
|------|--------|------|
|
||||||
|
| id | `"equipment_code"` | 리소스 PK |
|
||||||
|
| name | `"equipment_name"` | 리소스 표시명 |
|
||||||
|
| group | - | 리소스 그룹 |
|
||||||
|
|
||||||
|
**statusColors (상태별 색상)**:
|
||||||
|
|
||||||
|
| 상태 | 기본 색상 |
|
||||||
|
|------|----------|
|
||||||
|
| planned | `"#3b82f6"` (파랑) |
|
||||||
|
| in_progress | `"#f59e0b"` (주황) |
|
||||||
|
| completed | `"#10b981"` (초록) |
|
||||||
|
| delayed | `"#ef4444"` (빨강) |
|
||||||
|
| cancelled | `"#6b7280"` (회색) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.12 v2-tabs-widget (탭)
|
||||||
|
|
||||||
|
**용도**: 탭 전환. 각 탭 내부에 컴포넌트 배치 가능.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| tabs | array | `[{id:"tab-1",label:"탭1",...}]` | 탭 배열 |
|
||||||
|
| defaultTab | string | `"tab-1"` | 기본 활성 탭 ID |
|
||||||
|
| orientation | string | `"horizontal"` | 탭 방향: `horizontal`/`vertical` |
|
||||||
|
| variant | string | `"default"` | 스타일: `default`/`pills`/`underline` |
|
||||||
|
| allowCloseable | boolean | `false` | 탭 닫기 버튼 표시 |
|
||||||
|
| persistSelection | boolean | `false` | 탭 선택 상태 localStorage 저장 |
|
||||||
|
|
||||||
|
**tabs 배열 요소**: `{ id, label, order, disabled, icon, components[] }`
|
||||||
|
**components 요소**: `{ id, componentType, label, position, size, componentConfig }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.13 v2-aggregation-widget (집계 카드)
|
||||||
|
|
||||||
|
**용도**: 합계, 평균, 개수 등 집계값을 카드 형태로 표시.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| dataSourceType | string | `"table"` | 데이터 소스: `table`/`component`/`selection` |
|
||||||
|
| tableName | string | - | 테이블명 |
|
||||||
|
| items | array | `[]` | 집계 항목 배열 |
|
||||||
|
| layout | string | `"horizontal"` | 배치: `horizontal`/`vertical` |
|
||||||
|
| showLabels | boolean | `true` | 라벨 표시 |
|
||||||
|
| showIcons | boolean | `true` | 아이콘 표시 |
|
||||||
|
| gap | string | `"16px"` | 항목 간격 |
|
||||||
|
| autoRefresh | boolean | `false` | 자동 새로고침 |
|
||||||
|
| refreshOnFormChange | boolean | `true` | 폼 변경 시 새로고침 |
|
||||||
|
|
||||||
|
**items 요소**: `{ id, columnName, columnLabel, type("sum"/"avg"/"count"/"max"/"min"), format, decimalPlaces, prefix, suffix }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.14 v2-status-count (상태별 건수)
|
||||||
|
|
||||||
|
**용도**: 상태별 건수를 카드 형태로 표시. 대시보드/현황 화면용.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| title | string | `"상태 현황"` | 제목 |
|
||||||
|
| tableName | string | `""` | 대상 테이블 |
|
||||||
|
| statusColumn | string | `"status"` | 상태 컬럼명 |
|
||||||
|
| relationColumn | string | `""` | 관계 컬럼 (필터용) |
|
||||||
|
| items | array | - | 상태 항목 `[{ value, label, color }]` |
|
||||||
|
| showTotal | boolean | - | 합계 표시 |
|
||||||
|
| cardSize | string | `"md"` | 카드 크기: `sm`/`md`/`lg` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.15 v2-text-display (텍스트 표시)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| text | string | `"텍스트를 입력하세요"` | 표시 텍스트 |
|
||||||
|
| fontSize | string | `"14px"` | 폰트 크기 |
|
||||||
|
| fontWeight | string | `"normal"` | 폰트 굵기 |
|
||||||
|
| color | string | `"#212121"` | 텍스트 색상 |
|
||||||
|
| textAlign | string | `"left"` | 정렬: `left`/`center`/`right` |
|
||||||
|
| backgroundColor | string | - | 배경색 |
|
||||||
|
| padding | string | - | 패딩 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.16 v2-numbering-rule (자동 채번)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| ruleConfig | object | - | 채번 규칙 설정 |
|
||||||
|
| maxRules | number | `6` | 최대 파트 수 |
|
||||||
|
| readonly | boolean | `false` | 읽기 전용 |
|
||||||
|
| showPreview | boolean | `true` | 미리보기 표시 |
|
||||||
|
| showRuleList | boolean | `true` | 규칙 목록 표시 |
|
||||||
|
| cardLayout | string | `"vertical"` | 레이아웃: `vertical`/`horizontal` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.17 v2-file-upload (파일 업로드)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| placeholder | string | `"파일을 선택하세요"` | 힌트 텍스트 |
|
||||||
|
| multiple | boolean | `true` | 다중 업로드 |
|
||||||
|
| accept | string | `"*/*"` | 허용 파일 형식 (예: `"image/*"`, `".pdf,.xlsx"`) |
|
||||||
|
| maxSize | number | `10485760` | 최대 파일 크기(bytes, 기본 10MB) |
|
||||||
|
| maxFiles | number | - | 최대 파일 수 |
|
||||||
|
| showPreview | boolean | - | 미리보기 표시 |
|
||||||
|
| showFileList | boolean | - | 파일 목록 표시 |
|
||||||
|
| allowDelete | boolean | - | 삭제 허용 |
|
||||||
|
| allowDownload | boolean | - | 다운로드 허용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.18 v2-section-card (그룹 컨테이너 - 테두리)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| title | string | `"섹션 제목"` | 제목 |
|
||||||
|
| description | string | `""` | 설명 |
|
||||||
|
| showHeader | boolean | `true` | 헤더 표시 |
|
||||||
|
| padding | string | `"md"` | 패딩: `none`/`sm`/`md`/`lg` |
|
||||||
|
| backgroundColor | string | `"default"` | 배경: `default`/`muted`/`transparent` |
|
||||||
|
| borderStyle | string | `"solid"` | 테두리: `solid`/`dashed`/`none` |
|
||||||
|
| collapsible | boolean | `false` | 접기/펼치기 가능 |
|
||||||
|
| defaultOpen | boolean | `true` | 기본 펼침 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.19 v2-section-paper (그룹 컨테이너 - 배경색)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| backgroundColor | string | `"default"` | 배경: `default`/`muted`/`accent`/`primary`/`custom` |
|
||||||
|
| customColor | string | - | custom일 때 색상 |
|
||||||
|
| showBorder | boolean | `false` | 테두리 표시 |
|
||||||
|
| padding | string | `"md"` | 패딩: `none`/`sm`/`md`/`lg` |
|
||||||
|
| roundedCorners | string | `"md"` | 모서리: `none`/`sm`/`md`/`lg` |
|
||||||
|
| shadow | string | `"none"` | 그림자: `none`/`sm`/`md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.20 v2-divider-line (구분선)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| orientation | string | - | 방향 (가로/세로) |
|
||||||
|
| thickness | number | - | 두께 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.21 v2-split-line (캔버스 분할선)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| resizable | boolean | `true` | 드래그 리사이즈 허용 |
|
||||||
|
| lineColor | string | `"#e2e8f0"` | 분할선 색상 |
|
||||||
|
| lineWidth | number | `4` | 분할선 두께(px) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.22 v2-repeat-container (반복 렌더링)
|
||||||
|
|
||||||
|
**용도**: 데이터 수만큼 내부 컴포넌트를 반복 렌더링. 카드 리스트 등에 사용.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| dataSourceType | string | `"manual"` | 소스: `table-list`/`v2-repeater`/`externalData`/`manual` |
|
||||||
|
| dataSourceComponentId | string | - | 연결할 컴포넌트 ID |
|
||||||
|
| tableName | string | - | 테이블명 |
|
||||||
|
| layout | string | `"vertical"` | 배치: `vertical`/`horizontal`/`grid` |
|
||||||
|
| gridColumns | number | `2` | grid일 때 컬럼 수 |
|
||||||
|
| gap | string | `"16px"` | 아이템 간격 |
|
||||||
|
| showBorder | boolean | `true` | 카드 테두리 |
|
||||||
|
| showShadow | boolean | `false` | 카드 그림자 |
|
||||||
|
| borderRadius | string | `"8px"` | 모서리 둥글기 |
|
||||||
|
| backgroundColor | string | `"#ffffff"` | 배경색 |
|
||||||
|
| padding | string | `"16px"` | 패딩 |
|
||||||
|
| showItemTitle | boolean | `false` | 아이템 제목 표시 |
|
||||||
|
| itemTitleTemplate | string | `""` | 제목 템플릿 (예: `"{order_no} - {item}"`) |
|
||||||
|
| emptyMessage | string | `"데이터가 없습니다"` | 빈 상태 메시지 |
|
||||||
|
| clickable | boolean | `false` | 클릭 가능 |
|
||||||
|
| selectionMode | string | `"single"` | 선택 모드: `single`/`multiple` |
|
||||||
|
| usePaging | boolean | `false` | 페이징 사용 |
|
||||||
|
| pageSize | number | `10` | 페이지 크기 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.23 v2-repeater (반복 데이터 관리)
|
||||||
|
|
||||||
|
**용도**: 인라인/모달 모드로 반복 데이터(주문 상세 등) 관리. 행 추가/삭제/편집.
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| renderMode | string | `"inline"` | 모드: `inline` (인라인 편집) / `modal` (모달로 선택 추가) |
|
||||||
|
| mainTableName | string | - | 저장 대상 테이블 |
|
||||||
|
| foreignKeyColumn | string | - | 마스터 연결 FK 컬럼 |
|
||||||
|
| foreignKeySourceColumn | string | - | 마스터 PK 컬럼 |
|
||||||
|
| columns | array | `[]` | 컬럼 설정 |
|
||||||
|
| dataSource.tableName | string | - | 데이터 테이블 |
|
||||||
|
| dataSource.foreignKey | string | - | FK 컬럼 |
|
||||||
|
| dataSource.sourceTable | string | - | 모달용 소스 테이블 |
|
||||||
|
| modal.size | string | `"md"` | 모달 크기: `sm`/`md`/`lg`/`xl`/`full` |
|
||||||
|
| modal.title | string | - | 모달 제목 |
|
||||||
|
| modal.searchFields | string[] | - | 검색 필드 |
|
||||||
|
| features.showAddButton | boolean | `true` | 추가 버튼 |
|
||||||
|
| features.showDeleteButton | boolean | `true` | 삭제 버튼 |
|
||||||
|
| features.inlineEdit | boolean | `false` | 인라인 편집 |
|
||||||
|
| features.showRowNumber | boolean | `false` | 행 번호 표시 |
|
||||||
|
| calculationRules | array | - | 자동 계산 규칙 (예: 수량*단가=금액) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.24 v2-approval-step (결재 스테퍼)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| targetTable | string | `""` | 결재 대상 테이블 |
|
||||||
|
| targetRecordIdField | string | `""` | 레코드 ID 필드 |
|
||||||
|
| displayMode | string | `"horizontal"` | 표시 방향: `horizontal`/`vertical` |
|
||||||
|
| showComment | boolean | `true` | 결재 코멘트 표시 |
|
||||||
|
| showTimestamp | boolean | `true` | 결재 시간 표시 |
|
||||||
|
| showDept | boolean | `true` | 부서 표시 |
|
||||||
|
| compact | boolean | `false` | 컴팩트 모드 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.25 v2-bom-tree (BOM 트리)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| detailTable | string | `"bom_detail"` | BOM 디테일 테이블 |
|
||||||
|
| foreignKey | string | `"bom_id"` | BOM 마스터 FK |
|
||||||
|
| parentKey | string | `"parent_detail_id"` | 트리 부모 키 (자기참조) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.26 v2-bom-item-editor (BOM 편집)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| detailTable | string | `"bom_detail"` | BOM 디테일 테이블 |
|
||||||
|
| sourceTable | string | `"item_info"` | 품목 소스 테이블 |
|
||||||
|
| foreignKey | string | `"bom_id"` | BOM 마스터 FK |
|
||||||
|
| parentKey | string | `"parent_detail_id"` | 트리 부모 키 |
|
||||||
|
| itemCodeField | string | `"item_number"` | 품목 코드 필드 |
|
||||||
|
| itemNameField | string | `"item_name"` | 품목명 필드 |
|
||||||
|
| itemTypeField | string | `"type"` | 품목 유형 필드 |
|
||||||
|
| itemUnitField | string | `"unit"` | 품목 단위 필드 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.27 v2-category-manager (카테고리 관리)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| tableName | string | - | 대상 테이블 |
|
||||||
|
| columnName | string | - | 카테고리 컬럼 |
|
||||||
|
| menuObjid | number | - | 연결 메뉴 OBJID |
|
||||||
|
| viewMode | string | `"tree"` | 뷰 모드: `tree`/`list` |
|
||||||
|
| showViewModeToggle | boolean | `true` | 뷰 모드 토글 표시 |
|
||||||
|
| defaultExpandLevel | number | `1` | 기본 트리 펼침 레벨 |
|
||||||
|
| showInactiveItems | boolean | `false` | 비활성 항목 표시 |
|
||||||
|
| leftPanelWidth | number | `15` | 좌측 패널 너비 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.28 v2-media (미디어)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| mediaType | string | `"file"` | 미디어 타입: `file`/`image`/`video`/`audio` |
|
||||||
|
| multiple | boolean | `false` | 다중 업로드 |
|
||||||
|
| preview | boolean | `true` | 미리보기 |
|
||||||
|
| maxSize | number | `10` | 최대 크기(MB) |
|
||||||
|
| accept | string | `"*/*"` | 허용 형식 |
|
||||||
|
| showFileList | boolean | `true` | 파일 목록 |
|
||||||
|
| dragDrop | boolean | `true` | 드래그앤드롭 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.29 v2-location-swap-selector (위치 교환)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| dataSource.type | string | `"static"` | 소스: `static`/`table`/`code` |
|
||||||
|
| dataSource.tableName | string | - | 장소 테이블 |
|
||||||
|
| dataSource.valueField | string | `"location_code"` | 값 필드 |
|
||||||
|
| dataSource.labelField | string | `"location_name"` | 표시 필드 |
|
||||||
|
| dataSource.staticOptions | array | - | 정적 옵션 `[{value, label}]` |
|
||||||
|
| departureField | string | `"departure"` | 출발지 저장 필드 |
|
||||||
|
| destinationField | string | `"destination"` | 도착지 저장 필드 |
|
||||||
|
| departureLabel | string | `"출발지"` | 출발지 라벨 |
|
||||||
|
| destinationLabel | string | `"도착지"` | 도착지 라벨 |
|
||||||
|
| showSwapButton | boolean | `true` | 교환 버튼 표시 |
|
||||||
|
| variant | string | `"card"` | UI: `card`/`inline`/`minimal` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.30 v2-rack-structure (창고 랙)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| maxConditions | number | `10` | 최대 조건 수 |
|
||||||
|
| maxRows | number | `99` | 최대 열 수 |
|
||||||
|
| maxLevels | number | `20` | 최대 단 수 |
|
||||||
|
| codePattern | string | `"{warehouseCode}-{floor}{zone}-{row:02d}-{level}"` | 위치 코드 패턴 |
|
||||||
|
| namePattern | string | `"{zone}구역-{row:02d}열-{level}단"` | 위치 이름 패턴 |
|
||||||
|
| showTemplates | boolean | `true` | 템플릿 표시 |
|
||||||
|
| showPreview | boolean | `true` | 미리보기 |
|
||||||
|
| showStatistics | boolean | `true` | 통계 카드 |
|
||||||
|
| readonly | boolean | `false` | 읽기 전용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.31 v2-process-work-standard (공정 작업기준)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| dataSource.itemTable | string | `"item_info"` | 품목 테이블 |
|
||||||
|
| dataSource.routingVersionTable | string | `"item_routing_version"` | 라우팅 버전 테이블 |
|
||||||
|
| dataSource.routingDetailTable | string | `"item_routing_detail"` | 라우팅 디테일 테이블 |
|
||||||
|
| dataSource.processTable | string | `"process_mng"` | 공정 테이블 |
|
||||||
|
| splitRatio | number | `30` | 좌우 분할 비율 |
|
||||||
|
| leftPanelTitle | string | `"품목 및 공정 선택"` | 좌측 패널 제목 |
|
||||||
|
| readonly | boolean | `false` | 읽기 전용 |
|
||||||
|
| itemListMode | string | `"all"` | 품목 모드: `all`/`registered` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.32 v2-item-routing (품목 라우팅)
|
||||||
|
|
||||||
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| dataSource.itemTable | string | `"item_info"` | 품목 테이블 |
|
||||||
|
| dataSource.routingVersionTable | string | `"item_routing_version"` | 라우팅 버전 테이블 |
|
||||||
|
| dataSource.routingDetailTable | string | `"item_routing_detail"` | 라우팅 디테일 테이블 |
|
||||||
|
| dataSource.processTable | string | `"process_mng"` | 공정 테이블 |
|
||||||
|
| splitRatio | number | `40` | 좌우 분할 비율 |
|
||||||
|
| leftPanelTitle | string | `"품목 목록"` | 좌측 제목 |
|
||||||
|
| rightPanelTitle | string | `"공정 순서"` | 우측 제목 |
|
||||||
|
| readonly | boolean | `false` | 읽기 전용 |
|
||||||
|
| autoSelectFirstVersion | boolean | `true` | 첫 버전 자동 선택 |
|
||||||
|
| itemListMode | string | `"all"` | 품목 모드: `all`/`registered` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 패턴 의사결정 트리
|
||||||
|
|
||||||
|
```
|
||||||
|
Q1. 시간축 기반 일정/간트차트? → v2-timeline-scheduler
|
||||||
|
Q2. 다차원 피벗 분석? → v2-pivot-grid
|
||||||
|
Q3. 그룹별 접기/펼치기? → v2-table-grouped
|
||||||
|
Q4. 카드 형태 표시? → v2-card-display
|
||||||
|
Q5. 마스터-디테일?
|
||||||
|
├ 우측 멀티 탭? → v2-split-panel-layout + additionalTabs
|
||||||
|
└ 단일 디테일? → v2-split-panel-layout
|
||||||
|
Q6. 단일 테이블? → v2-table-search-widget + v2-table-list
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 관계(relation) 레퍼런스
|
||||||
|
|
||||||
|
| 관계 유형 | 설정 |
|
||||||
|
|----------|------|
|
||||||
|
| 단순 FK | `{ type:"detail", leftColumn:"id", rightColumn:"{FK}", foreignKey:"{FK}" }` |
|
||||||
|
| 복합 키 | `{ type:"detail", keys:[{ leftColumn:"a", rightColumn:"b" }] }` |
|
||||||
|
| JOIN | `{ type:"join", leftColumn:"{col}", rightColumn:"{col}" }` |
|
||||||
|
|
||||||
|
## 6. 엔티티 조인
|
||||||
|
|
||||||
|
FK 컬럼에 참조 테이블의 이름을 표시:
|
||||||
|
|
||||||
|
**table_type_columns**: `input_type='entity'`, `detail_settings='{"referenceTable":"X","referenceColumn":"id","displayColumn":"name"}'`
|
||||||
|
|
||||||
|
**layout_data columns**: `{ name:"fk_col", isEntityJoin:true, joinInfo:{ sourceTable:"A", sourceColumn:"fk_col", referenceTable:"X", joinAlias:"name" } }`
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 키 일괄 제거:
|
탭 닫기/새로고침 시 관련 sessionStorage 키 일괄 제거:
|
||||||
- `tab-cache-{screenId}-{menuObjid}`
|
- `tab-cache-{tabId}` (폼/스크롤 캐시)
|
||||||
- `page-scroll-{screenId}-{menuObjid}`
|
- `tableState_{tabId}_*` (컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터)
|
||||||
- `tsp-{screenId}-*`, `table-state-{screenId}-*`
|
- `pageSize_{tabId}_*` (표시갯수)
|
||||||
- `split-sel-{screenId}-*`, `catval-sel-{screenId}-*`
|
- `filterSettings_{tabId}_*` (검색 필터 설정)
|
||||||
- `bom-tree-{screenId}-*`
|
- `groupSettings_{tabId}_*` (그룹 설정)
|
||||||
- URL 탭: `tsp-{urlHash}-*`, `admin-scroll-{url}`
|
|
||||||
|
### 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: 컬럼 순서
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,17 @@ import { LoginForm } from "@/components/auth/LoginForm";
|
||||||
import { LoginFooter } from "@/components/auth/LoginFooter";
|
import { LoginFooter } from "@/components/auth/LoginFooter";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } =
|
const {
|
||||||
useLogin();
|
formData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
showPassword,
|
||||||
|
isPopMode,
|
||||||
|
handleInputChange,
|
||||||
|
handleLogin,
|
||||||
|
togglePasswordVisibility,
|
||||||
|
togglePopMode,
|
||||||
|
} = useLogin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4">
|
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4">
|
||||||
|
|
@ -19,9 +28,11 @@ export default function LoginPage() {
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
showPassword={showPassword}
|
showPassword={showPassword}
|
||||||
|
isPopMode={isPopMode}
|
||||||
onInputChange={handleInputChange}
|
onInputChange={handleInputChange}
|
||||||
onSubmit={handleLogin}
|
onSubmit={handleLogin}
|
||||||
onTogglePassword={togglePasswordVisibility}
|
onTogglePassword={togglePasswordVisibility}
|
||||||
|
onTogglePop={togglePopMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LoginFooter />
|
<LoginFooter />
|
||||||
|
|
|
||||||
|
|
@ -74,16 +74,15 @@ const RESOURCE_TYPE_CONFIG: Record<
|
||||||
SCREEN_LAYOUT: { label: "레이아웃", icon: Monitor, color: "bg-purple-100 text-purple-700" },
|
SCREEN_LAYOUT: { label: "레이아웃", icon: Monitor, color: "bg-purple-100 text-purple-700" },
|
||||||
FLOW: { label: "플로우", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" },
|
FLOW: { label: "플로우", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" },
|
||||||
FLOW_STEP: { 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" },
|
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
|
||||||
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||||
PERMISSION: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
|
||||||
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
||||||
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||||
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||||
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
|
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
|
||||||
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
|
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
|
||||||
NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" },
|
NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" },
|
||||||
BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_CONFIG: Record<string, { label: string; color: string }> = {
|
const ACTION_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
|
@ -816,7 +815,7 @@ export default function AuditLogPage() {
|
||||||
</Badge>
|
</Badge>
|
||||||
{entry.company_code && entry.company_code !== "*" && (
|
{entry.company_code && entry.company_code !== "*" && (
|
||||||
<span className="text-muted-foreground text-[10px]">
|
<span className="text-muted-foreground text-[10px]">
|
||||||
[{entry.company_code}]
|
[{entry.company_name || entry.company_code}]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -861,9 +860,11 @@ export default function AuditLogPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-muted-foreground text-xs">
|
<label className="text-muted-foreground text-xs">
|
||||||
회사코드
|
회사
|
||||||
</label>
|
</label>
|
||||||
<p className="font-medium">{selectedEntry.company_code}</p>
|
<p className="font-medium">
|
||||||
|
{selectedEntry.company_name || selectedEntry.company_code}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-muted-foreground text-xs">
|
<label className="text-muted-foreground text-xs">
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
import DataFlowPage from "../page";
|
||||||
* 제어 시스템 페이지 (리다이렉트)
|
|
||||||
* 이 페이지는 /admin/dataflow로 리다이렉트됩니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
export default function NodeEditorPage() {
|
export default function NodeEditorPage() {
|
||||||
const router = useRouter();
|
return <DataFlowPage />;
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
|
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw, LayoutGrid, Monitor } from "lucide-react";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
@ -285,14 +285,23 @@ function PopScreenViewPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 일반 모드 네비게이션 바 */}
|
||||||
|
{!isPreviewMode && (
|
||||||
|
<div className="sticky top-0 z-50 flex h-10 items-center justify-between border-b bg-white/80 px-3 backdrop-blur">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => router.push("/pop")} className="gap-1 text-xs">
|
||||||
|
<LayoutGrid className="h-3.5 w-3.5" />
|
||||||
|
POP 대시보드
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-gray-500">{screen.screenName}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => router.push("/")} className="gap-1 text-xs">
|
||||||
|
<Monitor className="h-3.5 w-3.5" />
|
||||||
|
PC 모드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* POP 화면 컨텐츠 */}
|
{/* POP 화면 컨텐츠 */}
|
||||||
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
|
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
|
||||||
{/* 현재 모드 표시 (일반 모드) */}
|
|
||||||
{!isPreviewMode && (
|
|
||||||
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
|
||||||
{currentModeKey.replace("_", " ")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-foreground" : "w-full min-h-full"}`}
|
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-foreground" : "w-full min-h-full"}`}
|
||||||
|
|
|
||||||
|
|
@ -458,6 +458,14 @@ select {
|
||||||
border-color: hsl(var(--destructive)) !important;
|
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 {
|
.validation-error-msg-wrapper {
|
||||||
height: 0;
|
height: 0;
|
||||||
|
|
|
||||||
|
|
@ -82,12 +82,19 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
// 화면 할당 관련 상태
|
// 화면 할당 관련 상태
|
||||||
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당)
|
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard" | "pop">("screen");
|
||||||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||||
const [screenSearchText, setScreenSearchText] = useState("");
|
const [screenSearchText, setScreenSearchText] = useState("");
|
||||||
const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false);
|
const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
// POP 화면 할당 관련 상태
|
||||||
|
const [selectedPopScreen, setSelectedPopScreen] = useState<ScreenDefinition | null>(null);
|
||||||
|
const [popScreenSearchText, setPopScreenSearchText] = useState("");
|
||||||
|
const [isPopScreenDropdownOpen, setIsPopScreenDropdownOpen] = useState(false);
|
||||||
|
const [isPopLanding, setIsPopLanding] = useState(false);
|
||||||
|
const [hasOtherPopLanding, setHasOtherPopLanding] = useState(false);
|
||||||
|
|
||||||
// 대시보드 할당 관련 상태
|
// 대시보드 할당 관련 상태
|
||||||
const [selectedDashboard, setSelectedDashboard] = useState<any | null>(null);
|
const [selectedDashboard, setSelectedDashboard] = useState<any | null>(null);
|
||||||
const [dashboards, setDashboards] = useState<any[]>([]);
|
const [dashboards, setDashboards] = useState<any[]>([]);
|
||||||
|
|
@ -196,8 +203,27 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`);
|
toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// POP 화면 선택 시 URL 자동 설정
|
||||||
|
const handlePopScreenSelect = (screen: ScreenDefinition) => {
|
||||||
|
const actualScreenId = screen.screenId || screen.id;
|
||||||
|
if (!actualScreenId) {
|
||||||
|
toast.error("화면 ID를 찾을 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedPopScreen(screen);
|
||||||
|
setIsPopScreenDropdownOpen(false);
|
||||||
|
|
||||||
|
const popUrl = `/pop/screens/${actualScreenId}`;
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
menuUrl: popUrl,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// URL 타입 변경 시 처리
|
// URL 타입 변경 시 처리
|
||||||
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => {
|
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard" | "pop") => {
|
||||||
// console.log("🔄 URL 타입 변경:", {
|
// console.log("🔄 URL 타입 변경:", {
|
||||||
// from: urlType,
|
// from: urlType,
|
||||||
// to: type,
|
// to: type,
|
||||||
|
|
@ -208,36 +234,53 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
setUrlType(type);
|
setUrlType(type);
|
||||||
|
|
||||||
if (type === "direct") {
|
if (type === "direct") {
|
||||||
// 직접 입력 모드로 변경 시 선택된 화면 초기화
|
|
||||||
setSelectedScreen(null);
|
setSelectedScreen(null);
|
||||||
// URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록)
|
setSelectedPopScreen(null);
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
menuUrl: "",
|
menuUrl: "",
|
||||||
screenCode: undefined, // 화면 코드도 함께 초기화
|
screenCode: undefined,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else if (type === "pop") {
|
||||||
// 화면 할당 모드로 변경 시
|
setSelectedScreen(null);
|
||||||
// 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지
|
if (selectedPopScreen) {
|
||||||
|
const actualScreenId = selectedPopScreen.screenId || selectedPopScreen.id;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
menuUrl: `/pop/screens/${actualScreenId}`,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
menuUrl: "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (type === "screen") {
|
||||||
|
setSelectedPopScreen(null);
|
||||||
if (selectedScreen) {
|
if (selectedScreen) {
|
||||||
console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName);
|
|
||||||
// 현재 선택된 화면으로 URL 재생성
|
|
||||||
const actualScreenId = selectedScreen.screenId || selectedScreen.id;
|
const actualScreenId = selectedScreen.screenId || selectedScreen.id;
|
||||||
let screenUrl = `/screens/${actualScreenId}`;
|
let screenUrl = `/screens/${actualScreenId}`;
|
||||||
|
|
||||||
// 관리자 메뉴인 경우 mode=admin 파라미터 추가
|
|
||||||
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
|
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
|
||||||
if (isAdminMenu) {
|
if (isAdminMenu) {
|
||||||
screenUrl += "?mode=admin";
|
screenUrl += "?mode=admin";
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
menuUrl: screenUrl,
|
menuUrl: screenUrl,
|
||||||
screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지
|
screenCode: selectedScreen.screenCode,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// 선택된 화면이 없으면 URL과 screenCode 초기화
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
menuUrl: "",
|
||||||
|
screenCode: undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// dashboard
|
||||||
|
setSelectedScreen(null);
|
||||||
|
setSelectedPopScreen(null);
|
||||||
|
if (!selectedDashboard) {
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
menuUrl: "",
|
menuUrl: "",
|
||||||
|
|
@ -297,8 +340,8 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
|
|
||||||
const menuUrl = menu.menu_url || menu.MENU_URL || "";
|
const menuUrl = menu.menu_url || menu.MENU_URL || "";
|
||||||
|
|
||||||
// URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정)
|
const isPopScreenUrl = menuUrl.startsWith("/pop/screens/");
|
||||||
const isScreenUrl = menuUrl.startsWith("/screens/");
|
const isScreenUrl = !isPopScreenUrl && menuUrl.startsWith("/screens/");
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
objid: menu.objid || menu.OBJID,
|
objid: menu.objid || menu.OBJID,
|
||||||
|
|
@ -360,10 +403,31 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (isPopScreenUrl) {
|
||||||
|
setUrlType("pop");
|
||||||
|
setSelectedScreen(null);
|
||||||
|
|
||||||
|
// [POP_LANDING] 태그 감지
|
||||||
|
const menuDesc = menu.menu_desc || menu.MENU_DESC || "";
|
||||||
|
setIsPopLanding(menuDesc.includes("[POP_LANDING]"));
|
||||||
|
|
||||||
|
const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1];
|
||||||
|
if (popScreenId) {
|
||||||
|
const setPopScreenFromId = () => {
|
||||||
|
const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId);
|
||||||
|
if (screen) {
|
||||||
|
setSelectedPopScreen(screen);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (screens.length > 0) {
|
||||||
|
setPopScreenFromId();
|
||||||
|
} else {
|
||||||
|
setTimeout(setPopScreenFromId, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (menuUrl.startsWith("/dashboard/")) {
|
} else if (menuUrl.startsWith("/dashboard/")) {
|
||||||
setUrlType("dashboard");
|
setUrlType("dashboard");
|
||||||
setSelectedScreen(null);
|
setSelectedScreen(null);
|
||||||
// 대시보드 ID 추출 및 선택은 useEffect에서 처리됨
|
|
||||||
} else {
|
} else {
|
||||||
setUrlType("direct");
|
setUrlType("direct");
|
||||||
setSelectedScreen(null);
|
setSelectedScreen(null);
|
||||||
|
|
@ -408,6 +472,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
} else {
|
} else {
|
||||||
console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType);
|
console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType);
|
||||||
setIsEdit(false);
|
setIsEdit(false);
|
||||||
|
setIsPopLanding(false);
|
||||||
|
|
||||||
// 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1)
|
// 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1)
|
||||||
let defaultMenuType = "1"; // 기본값은 사용자
|
let defaultMenuType = "1"; // 기본값은 사용자
|
||||||
|
|
@ -470,6 +535,31 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
}
|
}
|
||||||
}, [isOpen, formData.companyCode]);
|
}, [isOpen, formData.companyCode]);
|
||||||
|
|
||||||
|
// POP 기본 화면 중복 체크: 같은 부모 하위에 이미 [POP_LANDING]이 있는 다른 메뉴가 있는지 확인
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const checkOtherPopLanding = async () => {
|
||||||
|
try {
|
||||||
|
const res = await menuApi.getPopMenus();
|
||||||
|
if (res.success && res.data?.landingMenu) {
|
||||||
|
const landingObjId = res.data.landingMenu.objid?.toString();
|
||||||
|
const currentObjId = formData.objid?.toString();
|
||||||
|
// 현재 수정 중인 메뉴가 아닌 다른 메뉴에 [POP_LANDING]이 있으면 중복
|
||||||
|
setHasOtherPopLanding(!!landingObjId && landingObjId !== currentObjId);
|
||||||
|
} else {
|
||||||
|
setHasOtherPopLanding(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setHasOtherPopLanding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (urlType === "pop") {
|
||||||
|
checkOtherPopLanding();
|
||||||
|
}
|
||||||
|
}, [isOpen, urlType, formData.objid]);
|
||||||
|
|
||||||
// 화면 목록 및 대시보드 목록 로드
|
// 화면 목록 및 대시보드 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
|
@ -517,6 +607,22 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
}
|
}
|
||||||
}, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]);
|
}, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]);
|
||||||
|
|
||||||
|
// POP 화면 목록 로드 완료 후 기존 할당 설정
|
||||||
|
useEffect(() => {
|
||||||
|
if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "pop") {
|
||||||
|
const menuUrl = formData.menuUrl;
|
||||||
|
if (menuUrl.startsWith("/pop/screens/")) {
|
||||||
|
const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1];
|
||||||
|
if (popScreenId && !selectedPopScreen) {
|
||||||
|
const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId);
|
||||||
|
if (screen) {
|
||||||
|
setSelectedPopScreen(screen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [screens, isEdit, formData.menuUrl, urlType, selectedPopScreen]);
|
||||||
|
|
||||||
// 드롭다운 외부 클릭 시 닫기
|
// 드롭다운 외부 클릭 시 닫기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
|
@ -533,16 +639,20 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
setIsDashboardDropdownOpen(false);
|
setIsDashboardDropdownOpen(false);
|
||||||
setDashboardSearchText("");
|
setDashboardSearchText("");
|
||||||
}
|
}
|
||||||
|
if (!target.closest(".pop-screen-dropdown")) {
|
||||||
|
setIsPopScreenDropdownOpen(false);
|
||||||
|
setPopScreenSearchText("");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) {
|
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen || isPopScreenDropdownOpen) {
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [isLangKeyDropdownOpen, isScreenDropdownOpen]);
|
}, [isLangKeyDropdownOpen, isScreenDropdownOpen, isDashboardDropdownOpen, isPopScreenDropdownOpen]);
|
||||||
|
|
||||||
const loadCompanies = async () => {
|
const loadCompanies = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -590,10 +700,17 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
|
// POP 기본 화면 태그 처리
|
||||||
|
let finalMenuDesc = formData.menuDesc;
|
||||||
|
if (urlType === "pop") {
|
||||||
|
const descWithoutTag = finalMenuDesc.replace(/\[POP_LANDING\]/g, "").trim();
|
||||||
|
finalMenuDesc = isPopLanding ? `${descWithoutTag} [POP_LANDING]`.trim() : descWithoutTag;
|
||||||
|
}
|
||||||
|
|
||||||
// 백엔드에 전송할 데이터 변환
|
// 백엔드에 전송할 데이터 변환
|
||||||
const submitData = {
|
const submitData = {
|
||||||
...formData,
|
...formData,
|
||||||
// 상태를 소문자로 변환 (백엔드에서 소문자 기대)
|
menuDesc: finalMenuDesc,
|
||||||
status: formData.status.toLowerCase(),
|
status: formData.status.toLowerCase(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -853,7 +970,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
|
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
|
||||||
|
|
||||||
{/* URL 타입 선택 */}
|
{/* URL 타입 선택 */}
|
||||||
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex space-x-6">
|
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex flex-wrap gap-x-6 gap-y-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="screen" id="screen" />
|
<RadioGroupItem value="screen" id="screen" />
|
||||||
<Label htmlFor="screen" className="cursor-pointer">
|
<Label htmlFor="screen" className="cursor-pointer">
|
||||||
|
|
@ -866,6 +983,12 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
대시보드 할당
|
대시보드 할당
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="pop" id="pop" />
|
||||||
|
<Label htmlFor="pop" className="cursor-pointer">
|
||||||
|
POP 화면 할당
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="direct" id="direct" />
|
<RadioGroupItem value="direct" id="direct" />
|
||||||
<Label htmlFor="direct" className="cursor-pointer">
|
<Label htmlFor="direct" className="cursor-pointer">
|
||||||
|
|
@ -1031,6 +1154,106 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* POP 화면 할당 */}
|
||||||
|
{urlType === "pop" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsPopScreenDropdownOpen(!isPopScreenDropdownOpen)}
|
||||||
|
className="w-full justify-between"
|
||||||
|
>
|
||||||
|
<span className="text-left">
|
||||||
|
{selectedPopScreen ? selectedPopScreen.screenName : "POP 화면을 선택하세요"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isPopScreenDropdownOpen && (
|
||||||
|
<div className="pop-screen-dropdown absolute top-full right-0 left-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-md border bg-white shadow-lg">
|
||||||
|
<div className="sticky top-0 border-b bg-white p-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="POP 화면 검색..."
|
||||||
|
value={popScreenSearchText}
|
||||||
|
onChange={(e) => setPopScreenSearchText(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{screens
|
||||||
|
.filter(
|
||||||
|
(screen) =>
|
||||||
|
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
|
||||||
|
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
|
||||||
|
)
|
||||||
|
.map((screen, index) => (
|
||||||
|
<div
|
||||||
|
key={`pop-screen-${screen.screenId || screen.id || index}-${screen.screenCode || index}`}
|
||||||
|
onClick={() => handlePopScreenSelect(screen)}
|
||||||
|
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{screen.screenName}</div>
|
||||||
|
<div className="text-xs text-gray-500">{screen.screenCode}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">ID: {screen.screenId || screen.id || "N/A"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{screens.filter(
|
||||||
|
(screen) =>
|
||||||
|
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
|
||||||
|
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
|
||||||
|
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500">검색 결과가 없습니다.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedPopScreen && (
|
||||||
|
<div className="bg-accent rounded-md border p-3">
|
||||||
|
<div className="text-sm font-medium text-blue-900">{selectedPopScreen.screenName}</div>
|
||||||
|
<div className="text-primary text-xs">코드: {selectedPopScreen.screenCode}</div>
|
||||||
|
<div className="text-primary text-xs">생성된 URL: {formData.menuUrl}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* POP 기본 화면 설정 */}
|
||||||
|
<div className="flex items-center space-x-2 rounded-md border p-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="popLanding"
|
||||||
|
checked={isPopLanding}
|
||||||
|
disabled={!isPopLanding && hasOtherPopLanding}
|
||||||
|
onChange={(e) => setIsPopLanding(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="popLanding"
|
||||||
|
className={`text-sm font-medium ${!isPopLanding && hasOtherPopLanding ? "text-muted-foreground" : ""}`}
|
||||||
|
>
|
||||||
|
POP 기본 화면으로 설정
|
||||||
|
</label>
|
||||||
|
{!isPopLanding && hasOtherPopLanding && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
(이미 다른 메뉴가 기본 화면으로 설정되어 있습니다)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isPopLanding && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
프로필에서 POP 모드 전환 시 이 화면으로 바로 이동합니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* URL 직접 입력 */}
|
{/* URL 직접 입력 */}
|
||||||
{urlType === "direct" && (
|
{urlType === "direct" && (
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Eye, EyeOff, Loader2, Monitor } from "lucide-react";
|
||||||
import { LoginFormData } from "@/types/auth";
|
import { LoginFormData } from "@/types/auth";
|
||||||
import { ErrorMessage } from "./ErrorMessage";
|
import { ErrorMessage } from "./ErrorMessage";
|
||||||
|
|
||||||
|
|
@ -11,9 +12,11 @@ interface LoginFormProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
showPassword: boolean;
|
showPassword: boolean;
|
||||||
|
isPopMode: boolean;
|
||||||
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
onSubmit: (e: React.FormEvent) => void;
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
onTogglePassword: () => void;
|
onTogglePassword: () => void;
|
||||||
|
onTogglePop: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,9 +27,11 @@ export function LoginForm({
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
showPassword,
|
showPassword,
|
||||||
|
isPopMode,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onTogglePassword,
|
onTogglePassword,
|
||||||
|
onTogglePop,
|
||||||
}: LoginFormProps) {
|
}: LoginFormProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="border shadow-lg">
|
<Card className="border shadow-lg">
|
||||||
|
|
@ -82,6 +87,19 @@ export function LoginForm({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* POP 모드 토글 */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Monitor className="h-4 w-4 text-slate-500" />
|
||||||
|
<span className="text-sm text-slate-600">POP 모드</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isPopMode}
|
||||||
|
onCheckedChange={onTogglePop}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 로그인 버튼 */}
|
{/* 로그인 버튼 */}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,12 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||||
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
|
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
|
||||||
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// 바코드 리더 초기화
|
// 바코드 리더 초기화 + 모달 열릴 때 상태 리셋
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
setScannedCode("");
|
||||||
|
setError("");
|
||||||
|
setIsScanning(false);
|
||||||
codeReaderRef.current = new BrowserMultiFormatReader();
|
codeReaderRef.current = new BrowserMultiFormatReader();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,7 +279,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||||
{/* 스캔 가이드 오버레이 */}
|
{/* 스캔 가이드 오버레이 */}
|
||||||
{isScanning && (
|
{isScanning && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="h-32 w-32 border-4 border-primary animate-pulse rounded-lg" />
|
<div className="h-3/5 w-4/5 rounded-lg border-4 border-primary/70 animate-pulse" />
|
||||||
<div className="absolute bottom-4 left-0 right-0 text-center">
|
<div className="absolute bottom-4 left-0 right-0 text-center">
|
||||||
<div className="inline-flex items-center gap-2 rounded-full bg-background/80 px-4 py-2 text-xs font-medium">
|
<div className="inline-flex items-center gap-2 rounded-full bg-background/80 px-4 py-2 text-xs font-medium">
|
||||||
<Scan className="h-4 w-4 animate-pulse text-primary" />
|
<Scan className="h-4 w-4 animate-pulse text-primary" />
|
||||||
|
|
@ -355,6 +358,20 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{scannedCode && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setScannedCode("");
|
||||||
|
startScanning();
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
<Camera className="mr-2 h-4 w-4" />
|
||||||
|
다시 스캔
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{scannedCode && !autoSubmit && (
|
{scannedCode && !autoSubmit && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Zap,
|
Zap,
|
||||||
Copy,
|
Copy,
|
||||||
|
|
@ -136,6 +137,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
// 중복 처리 방법 (전역 설정)
|
// 중복 처리 방법 (전역 설정)
|
||||||
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
|
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
|
||||||
|
|
||||||
|
// 엑셀 데이터 사전 검증 결과
|
||||||
|
const [isDataValidating, setIsDataValidating] = useState(false);
|
||||||
|
const [validationResult, setValidationResult] = useState<import("@/lib/api/tableManagement").ExcelValidationResult | null>(null);
|
||||||
|
|
||||||
// 카테고리 검증 관련
|
// 카테고리 검증 관련
|
||||||
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
|
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
|
||||||
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
|
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
|
||||||
|
|
@ -874,6 +879,43 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setShowCategoryValidation(true);
|
setShowCategoryValidation(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 데이터 사전 검증 (NOT NULL 값 누락, UNIQUE 중복)
|
||||||
|
setIsDataValidating(true);
|
||||||
|
try {
|
||||||
|
const { validateExcelData: validateExcel } = await import("@/lib/api/tableManagement");
|
||||||
|
|
||||||
|
// 매핑된 데이터 구성
|
||||||
|
const mappedForValidation = allData.map((row) => {
|
||||||
|
const mapped: Record<string, any> = {};
|
||||||
|
columnMappings.forEach((m) => {
|
||||||
|
if (m.systemColumn) {
|
||||||
|
let colName = m.systemColumn;
|
||||||
|
if (isMasterDetail && colName.includes(".")) {
|
||||||
|
colName = colName.split(".")[1];
|
||||||
|
}
|
||||||
|
mapped[colName] = row[m.excelColumn];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return mapped;
|
||||||
|
}).filter((row) => Object.values(row).some((v) => v !== null && v !== undefined && String(v).trim() !== ""));
|
||||||
|
|
||||||
|
if (mappedForValidation.length > 0) {
|
||||||
|
const result = await validateExcel(tableName, mappedForValidation);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setValidationResult(result.data);
|
||||||
|
} else {
|
||||||
|
setValidationResult(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setValidationResult(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("데이터 사전 검증 실패 (무시):", err);
|
||||||
|
setValidationResult(null);
|
||||||
|
} finally {
|
||||||
|
setIsDataValidating(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||||
|
|
@ -1301,6 +1343,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setSystemColumns([]);
|
setSystemColumns([]);
|
||||||
setColumnMappings([]);
|
setColumnMappings([]);
|
||||||
setDuplicateAction("skip");
|
setDuplicateAction("skip");
|
||||||
|
// 검증 상태 초기화
|
||||||
|
setValidationResult(null);
|
||||||
|
setIsDataValidating(false);
|
||||||
// 카테고리 검증 초기화
|
// 카테고리 검증 초기화
|
||||||
setShowCategoryValidation(false);
|
setShowCategoryValidation(false);
|
||||||
setCategoryMismatches({});
|
setCategoryMismatches({});
|
||||||
|
|
@ -1870,6 +1915,100 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 검증 결과 */}
|
||||||
|
{validationResult && !validationResult.isValid && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* NOT NULL 에러 */}
|
||||||
|
{validationResult.notNullErrors.length > 0 && (
|
||||||
|
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-medium text-destructive sm:text-base">
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
필수값 누락 ({validationResult.notNullErrors.length}건)
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-destructive sm:text-xs">
|
||||||
|
{(() => {
|
||||||
|
const grouped = new Map<string, number[]>();
|
||||||
|
for (const err of validationResult.notNullErrors) {
|
||||||
|
const key = err.label;
|
||||||
|
if (!grouped.has(key)) grouped.set(key, []);
|
||||||
|
grouped.get(key)!.push(err.row);
|
||||||
|
}
|
||||||
|
return Array.from(grouped).map(([label, rows]) => (
|
||||||
|
<p key={label}>
|
||||||
|
<span className="font-medium">{label}</span>: {rows.length > 5 ? `행 ${rows.slice(0, 5).join(", ")} 외 ${rows.length - 5}건` : `행 ${rows.join(", ")}`}
|
||||||
|
</p>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 엑셀 내부 중복 */}
|
||||||
|
{validationResult.uniqueInExcelErrors.length > 0 && (
|
||||||
|
<div className="rounded-md border border-warning bg-warning/10 p-4">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-medium text-warning sm:text-base">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
엑셀 내 중복 ({validationResult.uniqueInExcelErrors.length}건)
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-warning sm:text-xs">
|
||||||
|
{validationResult.uniqueInExcelErrors.slice(0, 10).map((err, i) => (
|
||||||
|
<p key={i}>
|
||||||
|
<span className="font-medium">{err.label}</span> "{err.value}": 행 {err.rows.join(", ")}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{validationResult.uniqueInExcelErrors.length > 10 && (
|
||||||
|
<p className="font-medium">...외 {validationResult.uniqueInExcelErrors.length - 10}건</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DB 기존 데이터 중복 */}
|
||||||
|
{validationResult.uniqueInDbErrors.length > 0 && (
|
||||||
|
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-medium text-destructive sm:text-base">
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
DB 기존 데이터와 중복 ({validationResult.uniqueInDbErrors.length}건)
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-destructive sm:text-xs">
|
||||||
|
{(() => {
|
||||||
|
const grouped = new Map<string, { value: string; rows: number[] }[]>();
|
||||||
|
for (const err of validationResult.uniqueInDbErrors) {
|
||||||
|
const key = err.label;
|
||||||
|
if (!grouped.has(key)) grouped.set(key, []);
|
||||||
|
const existing = grouped.get(key)!.find((e) => e.value === err.value);
|
||||||
|
if (existing) existing.rows.push(err.row);
|
||||||
|
else grouped.get(key)!.push({ value: err.value, rows: [err.row] });
|
||||||
|
}
|
||||||
|
return Array.from(grouped).map(([label, items]) => (
|
||||||
|
<div key={label}>
|
||||||
|
{items.slice(0, 5).map((item, i) => (
|
||||||
|
<p key={i}>
|
||||||
|
<span className="font-medium">{label}</span> "{item.value}": 행 {item.rows.join(", ")}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{items.length > 5 && <p className="font-medium">...외 {items.length - 5}건</p>}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validationResult?.isValid && (
|
||||||
|
<div className="rounded-md border border-success bg-success/10 p-4">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-medium text-success sm:text-base">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
데이터 검증 통과
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-[10px] text-success sm:text-xs">
|
||||||
|
필수값 및 중복 검사를 통과했습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="rounded-md border border-border bg-muted/50 p-4">
|
<div className="rounded-md border border-border bg-muted/50 p-4">
|
||||||
<h3 className="text-sm font-medium sm:text-base">컬럼 매핑</h3>
|
<h3 className="text-sm font-medium sm:text-base">컬럼 매핑</h3>
|
||||||
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
|
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||||
|
|
@ -1948,10 +2087,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
{currentStep < 3 ? (
|
{currentStep < 3 ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
|
disabled={isUploading || isCategoryValidating || isDataValidating || (currentStep === 1 && !file)}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
{isCategoryValidating ? (
|
{isCategoryValidating || isDataValidating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
검증 중...
|
검증 중...
|
||||||
|
|
@ -1964,11 +2103,13 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={
|
disabled={
|
||||||
isUploading || columnMappings.filter((m) => m.systemColumn).length === 0
|
isUploading ||
|
||||||
|
columnMappings.filter((m) => m.systemColumn).length === 0 ||
|
||||||
|
(validationResult !== null && !validationResult.isValid)
|
||||||
}
|
}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
{isUploading ? "업로드 중..." : "업로드"}
|
{isUploading ? "업로드 중..." : validationResult && !validationResult.isValid ? "검증 실패 - 이전으로 돌아가 수정" : "업로드"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,43 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||||
if (savedMode === "true") {
|
if (savedMode === "true") {
|
||||||
setContinuousMode(true);
|
setContinuousMode(true);
|
||||||
// console.log("🔄 연속 모드 복원: true");
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// dataBinding: 테이블 선택 시 바인딩된 input의 formData를 자동 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modalState.isOpen || !screenData?.components?.length) return;
|
||||||
|
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
if (!detail?.source || !detail?.data) return;
|
||||||
|
|
||||||
|
const bindingUpdates: Record<string, any> = {};
|
||||||
|
for (const comp of screenData.components) {
|
||||||
|
const db =
|
||||||
|
comp.componentConfig?.dataBinding ||
|
||||||
|
(comp as any).dataBinding;
|
||||||
|
if (!db?.sourceComponentId || !db?.sourceColumn) continue;
|
||||||
|
if (db.sourceComponentId !== detail.source) continue;
|
||||||
|
|
||||||
|
const colName = (comp as any).columnName || comp.componentConfig?.columnName;
|
||||||
|
if (!colName) continue;
|
||||||
|
|
||||||
|
const selectedRow = detail.data[0];
|
||||||
|
const value = selectedRow?.[db.sourceColumn] ?? "";
|
||||||
|
bindingUpdates[colName] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(bindingUpdates).length > 0) {
|
||||||
|
setFormData((prev) => ({ ...prev, ...bindingUpdates }));
|
||||||
|
formDataChangedRef.current = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("v2-table-selection", handler);
|
||||||
|
return () => window.removeEventListener("v2-table-selection", handler);
|
||||||
|
}, [modalState.isOpen, screenData?.components]);
|
||||||
|
|
||||||
// 화면의 실제 크기 계산 함수
|
// 화면의 실제 크기 계산 함수
|
||||||
const calculateScreenDimensions = (components: ComponentData[]) => {
|
const calculateScreenDimensions = (components: ComponentData[]) => {
|
||||||
if (components.length === 0) {
|
if (components.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useState, useEffect } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
const LoadingFallback = () => (
|
const LoadingFallback = () => (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
|
|
@ -10,11 +12,52 @@ const LoadingFallback = () => (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
function ScreenCodeResolver({ screenCode }: { screenCode: string }) {
|
||||||
* 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리.
|
const [screenId, setScreenId] = useState<number | null>(null);
|
||||||
* 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다.
|
const [loading, setLoading] = useState(true);
|
||||||
* 매핑되지 않은 URL은 catch-all fallback으로 처리된다.
|
|
||||||
*/
|
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>> = {
|
const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||||
// 관리자 메인
|
// 관리자 메인
|
||||||
"/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }),
|
"/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/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/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/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": 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 }),
|
"/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": 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/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-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/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/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/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/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/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 }) {
|
function AdminPageFallback({ url }: { url: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-lg font-semibold text-foreground">페이지 로딩 불가</p>
|
<p className="text-lg font-semibold text-foreground">페이지 로딩 불가</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">경로: {url}</p>
|
||||||
경로: {url}
|
<p className="mt-2 text-xs text-muted-foreground">해당 페이지가 존재하지 않습니다.</p>
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
AdminPageRenderer 레지스트리에 이 URL을 추가해주세요.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -95,15 +274,53 @@ interface AdminPageRendererProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||||
const PageComponent = useMemo(() => {
|
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
|
||||||
// URL에서 쿼리스트링/해시 제거 후 매칭
|
|
||||||
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
|
|
||||||
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
|
|
||||||
}, [url]);
|
|
||||||
|
|
||||||
if (!PageComponent) {
|
console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl });
|
||||||
return <AdminPageFallback url={url} />;
|
|
||||||
|
// 화면 할당: /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} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,12 @@ import {
|
||||||
User,
|
User,
|
||||||
Building2,
|
Building2,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
|
Monitor,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useProfile } from "@/hooks/useProfile";
|
import { useProfile } from "@/hooks/useProfile";
|
||||||
import { MenuItem } from "@/lib/api/menu";
|
import { MenuItem, menuApi } from "@/lib/api/menu";
|
||||||
import { menuScreenApi } from "@/lib/api/screen";
|
import { menuScreenApi } from "@/lib/api/screen";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -202,12 +203,26 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
|
||||||
|
|
||||||
const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle);
|
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 {
|
return {
|
||||||
id: menuId,
|
id: menuId,
|
||||||
|
objid: menuId,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
tabTitle,
|
tabTitle,
|
||||||
icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON),
|
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,
|
children: children.length > 0 ? children : undefined,
|
||||||
hasChildren: children.length > 0,
|
hasChildren: children.length > 0,
|
||||||
};
|
};
|
||||||
|
|
@ -341,42 +356,76 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
const handleMenuClick = async (menu: any) => {
|
const handleMenuClick = async (menu: any) => {
|
||||||
if (menu.hasChildren) {
|
if (menu.hasChildren) {
|
||||||
toggleMenu(menu.id);
|
toggleMenu(menu.id);
|
||||||
} else {
|
return;
|
||||||
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
|
}
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
localStorage.setItem("currentMenuName", menuName);
|
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 {
|
try {
|
||||||
const menuObjid = menu.objid || menu.id;
|
console.log("[handleMenuClick] → screen_menu_assignments 조회 시도, menuObjid:", menuObjid);
|
||||||
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
|
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
|
||||||
|
console.log("[handleMenuClick] → 조회 결과:", assignedScreens);
|
||||||
if (assignedScreens.length > 0) {
|
if (assignedScreens.length > 0) {
|
||||||
const firstScreen = assignedScreens[0];
|
console.log("[handleMenuClick] → screen 탭 (assignments):", assignedScreens[0].screenId);
|
||||||
openTab({
|
openTab({ type: "screen", title: menuName, screenId: assignedScreens[0].screenId, menuObjid });
|
||||||
type: "screen",
|
|
||||||
title: menuName,
|
|
||||||
screenId: firstScreen.screenId,
|
|
||||||
menuObjid: parseInt(menuObjid),
|
|
||||||
});
|
|
||||||
if (isMobile) setSidebarOpen(false);
|
if (isMobile) setSidebarOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
console.warn("할당된 화면 조회 실패");
|
console.error("[handleMenuClick] 할당된 화면 조회 실패:", err);
|
||||||
}
|
|
||||||
|
|
||||||
if (menu.url && menu.url !== "#") {
|
|
||||||
openTab({
|
|
||||||
type: "admin",
|
|
||||||
title: menuName,
|
|
||||||
adminUrl: menu.url,
|
|
||||||
});
|
|
||||||
if (isMobile) setSidebarOpen(false);
|
|
||||||
} else {
|
|
||||||
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 = () => {
|
const handleModeSwitch = () => {
|
||||||
|
|
@ -405,6 +454,31 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
e.dataTransfer.setData("text/plain", menuName);
|
e.dataTransfer.setData("text/plain", menuName);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// POP 모드 진입 핸들러
|
||||||
|
const handlePopModeClick = async () => {
|
||||||
|
try {
|
||||||
|
const response = await menuApi.getPopMenus();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const { childMenus, landingMenu } = response.data;
|
||||||
|
|
||||||
|
if (landingMenu?.menu_url) {
|
||||||
|
router.push(landingMenu.menu_url);
|
||||||
|
} else if (childMenus.length === 0) {
|
||||||
|
toast.info("설정된 POP 화면이 없습니다");
|
||||||
|
} else if (childMenus.length === 1) {
|
||||||
|
router.push(childMenus[0].menu_url);
|
||||||
|
} else {
|
||||||
|
router.push("/pop");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.info("설정된 POP 화면이 없습니다");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("POP 메뉴 조회 중 오류가 발생했습니다");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
|
||||||
const renderMenu = (menu: any, level: number = 0) => {
|
const renderMenu = (menu: any, level: number = 0) => {
|
||||||
const isExpanded = expandedMenus.has(menu.id);
|
const isExpanded = expandedMenus.has(menu.id);
|
||||||
const isLeaf = !menu.hasChildren;
|
const isLeaf = !menu.hasChildren;
|
||||||
|
|
@ -528,6 +602,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
<FileCheck className="mr-2 h-4 w-4" />
|
<FileCheck className="mr-2 h-4 w-4" />
|
||||||
<span>결재함</span>
|
<span>결재함</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||||
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
|
<span>POP 모드</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<div className="px-1 py-0.5">
|
<div className="px-1 py-0.5">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
@ -700,6 +778,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
<FileCheck className="mr-2 h-4 w-4" />
|
<FileCheck className="mr-2 h-4 w-4" />
|
||||||
<span>결재함</span>
|
<span>결재함</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||||
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
|
<span>POP 모드</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,14 @@ interface MainHeaderProps {
|
||||||
user: any;
|
user: any;
|
||||||
onSidebarToggle: () => void;
|
onSidebarToggle: () => void;
|
||||||
onProfileClick: () => void;
|
onProfileClick: () => void;
|
||||||
|
onPopModeClick?: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메인 헤더 컴포넌트
|
* 메인 헤더 컴포넌트
|
||||||
*/
|
*/
|
||||||
export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) {
|
export function MainHeader({ user, onSidebarToggle, onProfileClick, onPopModeClick, onLogout }: MainHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="bg-background/95 fixed top-0 z-50 h-14 min-h-14 w-full flex-shrink-0 border-b backdrop-blur">
|
<header className="bg-background/95 fixed top-0 z-50 h-14 min-h-14 w-full flex-shrink-0 border-b backdrop-blur">
|
||||||
<div className="flex h-full w-full items-center justify-between px-6">
|
<div className="flex h-full w-full items-center justify-between px-6">
|
||||||
|
|
@ -27,7 +28,7 @@ export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }:
|
||||||
|
|
||||||
{/* Right side - Admin Button + User Menu */}
|
{/* Right side - Admin Button + User Menu */}
|
||||||
<div className="flex h-8 items-center gap-2">
|
<div className="flex h-8 items-center gap-2">
|
||||||
<UserDropdown user={user} onProfileClick={onProfileClick} onLogout={onLogout} />
|
<UserDropdown user={user} onProfileClick={onProfileClick} onPopModeClick={onPopModeClick} onLogout={onLogout} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ import {
|
||||||
clearTabCache,
|
clearTabCache,
|
||||||
} from "@/lib/tabStateCache";
|
} from "@/lib/tabStateCache";
|
||||||
|
|
||||||
|
// 페이지 로드(F5 새로고침) 감지용 모듈 레벨 플래그.
|
||||||
|
// 전체 페이지 로드 시 모듈이 재실행되어 false로 리셋된다.
|
||||||
|
// SPA 내비게이션에서는 모듈이 유지되므로 true로 남는다.
|
||||||
|
let hasHandledPageLoad = false;
|
||||||
|
|
||||||
export function TabContent() {
|
export function TabContent() {
|
||||||
const tabs = useTabStore(selectTabs);
|
const tabs = useTabStore(selectTabs);
|
||||||
const activeTabId = useTabStore(selectActiveTabId);
|
const activeTabId = useTabStore(selectActiveTabId);
|
||||||
|
|
@ -39,6 +44,13 @@ export function TabContent() {
|
||||||
// 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함)
|
// 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함)
|
||||||
const pathCacheRef = useRef<WeakMap<HTMLElement, string | null>>(new WeakMap());
|
const pathCacheRef = useRef<WeakMap<HTMLElement, string | null>>(new WeakMap());
|
||||||
|
|
||||||
|
// 페이지 로드(F5) 시 활성 탭 캐시만 삭제 → fresh API 호출 유도
|
||||||
|
// 비활성 탭 캐시는 유지하여 탭 전환 시 복원
|
||||||
|
if (!hasHandledPageLoad && activeTabId) {
|
||||||
|
hasHandledPageLoad = true;
|
||||||
|
clearTabCache(activeTabId);
|
||||||
|
}
|
||||||
|
|
||||||
if (activeTabId) {
|
if (activeTabId) {
|
||||||
mountedTabIdsRef.current.add(activeTabId);
|
mountedTabIdsRef.current.add(activeTabId);
|
||||||
}
|
}
|
||||||
|
|
@ -226,6 +238,14 @@ function TabPageRenderer({
|
||||||
tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string };
|
tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string };
|
||||||
refreshKey: number;
|
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) {
|
if (tab.type === "screen" && tab.screenId != null) {
|
||||||
return (
|
return (
|
||||||
<ScreenViewPageWrapper
|
<ScreenViewPageWrapper
|
||||||
|
|
@ -244,5 +264,6 @@ function TabPageRenderer({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.warn("[TabPageRenderer] 렌더링 불가 - 매칭 조건 없음:", tab);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,20 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { LogOut, User, FileCheck } from "lucide-react";
|
import { LogOut, FileCheck, Monitor, User } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
interface UserDropdownProps {
|
interface UserDropdownProps {
|
||||||
user: any;
|
user: any;
|
||||||
onProfileClick: () => void;
|
onProfileClick: () => void;
|
||||||
|
onPopModeClick?: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자 드롭다운 메뉴 컴포넌트
|
* 사용자 드롭다운 메뉴 컴포넌트
|
||||||
*/
|
*/
|
||||||
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
|
export function UserDropdown({ user, onProfileClick, onPopModeClick, onLogout }: UserDropdownProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
@ -73,7 +74,6 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
|
||||||
? `${user.deptName}, ${user.positionName}`
|
? `${user.deptName}, ${user.positionName}`
|
||||||
: user.deptName || user.positionName || "부서 정보 없음"}
|
: user.deptName || user.positionName || "부서 정보 없음"}
|
||||||
</p>
|
</p>
|
||||||
{/* 사진 상태 표시 */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
@ -86,6 +86,12 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
|
||||||
<FileCheck className="mr-2 h-4 w-4" />
|
<FileCheck className="mr-2 h-4 w-4" />
|
||||||
<span>결재함</span>
|
<span>결재함</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{onPopModeClick && (
|
||||||
|
<DropdownMenuItem onClick={onPopModeClick}>
|
||||||
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
|
<span>POP 모드</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={onLogout}>
|
<DropdownMenuItem onClick={onLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Moon, Sun } from "lucide-react";
|
import { Moon, Sun, Monitor } from "lucide-react";
|
||||||
import { WeatherInfo, UserInfo, CompanyInfo } from "./types";
|
import { WeatherInfo, UserInfo, CompanyInfo } from "./types";
|
||||||
|
|
||||||
interface DashboardHeaderProps {
|
interface DashboardHeaderProps {
|
||||||
|
|
@ -11,6 +11,7 @@ interface DashboardHeaderProps {
|
||||||
company: CompanyInfo;
|
company: CompanyInfo;
|
||||||
onThemeToggle: () => void;
|
onThemeToggle: () => void;
|
||||||
onUserClick: () => void;
|
onUserClick: () => void;
|
||||||
|
onPcModeClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardHeader({
|
export function DashboardHeader({
|
||||||
|
|
@ -20,6 +21,7 @@ export function DashboardHeader({
|
||||||
company,
|
company,
|
||||||
onThemeToggle,
|
onThemeToggle,
|
||||||
onUserClick,
|
onUserClick,
|
||||||
|
onPcModeClick,
|
||||||
}: DashboardHeaderProps) {
|
}: DashboardHeaderProps) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
|
|
@ -81,6 +83,17 @@ export function DashboardHeader({
|
||||||
<div className="pop-dashboard-company-sub">{company.subTitle}</div>
|
<div className="pop-dashboard-company-sub">{company.subTitle}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* PC 모드 복귀 */}
|
||||||
|
{onPcModeClick && (
|
||||||
|
<button
|
||||||
|
className="pop-dashboard-theme-toggle"
|
||||||
|
onClick={onPcModeClick}
|
||||||
|
title="PC 모드로 돌아가기"
|
||||||
|
>
|
||||||
|
<Monitor size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 사용자 배지 */}
|
{/* 사용자 배지 */}
|
||||||
<button className="pop-dashboard-user-badge" onClick={onUserClick}>
|
<button className="pop-dashboard-user-badge" onClick={onUserClick}>
|
||||||
<div className="pop-dashboard-user-avatar">{user.avatar}</div>
|
<div className="pop-dashboard-user-avatar">{user.avatar}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { DashboardHeader } from "./DashboardHeader";
|
import { DashboardHeader } from "./DashboardHeader";
|
||||||
import { NoticeBanner } from "./NoticeBanner";
|
import { NoticeBanner } from "./NoticeBanner";
|
||||||
import { KpiBar } from "./KpiBar";
|
import { KpiBar } from "./KpiBar";
|
||||||
|
|
@ -8,6 +9,8 @@ import { MenuGrid } from "./MenuGrid";
|
||||||
import { ActivityList } from "./ActivityList";
|
import { ActivityList } from "./ActivityList";
|
||||||
import { NoticeList } from "./NoticeList";
|
import { NoticeList } from "./NoticeList";
|
||||||
import { DashboardFooter } from "./DashboardFooter";
|
import { DashboardFooter } from "./DashboardFooter";
|
||||||
|
import { MenuItem as DashboardMenuItem } from "./types";
|
||||||
|
import { menuApi, PopMenuItem } from "@/lib/api/menu";
|
||||||
import {
|
import {
|
||||||
KPI_ITEMS,
|
KPI_ITEMS,
|
||||||
MENU_ITEMS,
|
MENU_ITEMS,
|
||||||
|
|
@ -17,10 +20,31 @@ import {
|
||||||
} from "./data";
|
} from "./data";
|
||||||
import "./dashboard.css";
|
import "./dashboard.css";
|
||||||
|
|
||||||
export function PopDashboard() {
|
const CATEGORY_COLORS: DashboardMenuItem["category"][] = [
|
||||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
"production",
|
||||||
|
"material",
|
||||||
|
"quality",
|
||||||
|
"equipment",
|
||||||
|
"safety",
|
||||||
|
];
|
||||||
|
|
||||||
|
function convertPopMenuToMenuItem(item: PopMenuItem, index: number): DashboardMenuItem {
|
||||||
|
return {
|
||||||
|
id: item.objid,
|
||||||
|
title: item.menu_name_kor,
|
||||||
|
count: 0,
|
||||||
|
description: item.menu_desc?.replace("[POP]", "").trim() || "",
|
||||||
|
status: "",
|
||||||
|
category: CATEGORY_COLORS[index % CATEGORY_COLORS.length],
|
||||||
|
href: item.menu_url || "#",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopDashboard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||||
|
const [menuItems, setMenuItems] = useState<DashboardMenuItem[]>(MENU_ITEMS);
|
||||||
|
|
||||||
// 로컬 스토리지에서 테마 로드
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
|
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
|
||||||
if (savedTheme) {
|
if (savedTheme) {
|
||||||
|
|
@ -28,6 +52,22 @@ export function PopDashboard() {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// API에서 POP 메뉴 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPopMenus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await menuApi.getPopMenus();
|
||||||
|
if (response.success && response.data && response.data.childMenus.length > 0) {
|
||||||
|
const converted = response.data.childMenus.map(convertPopMenuToMenuItem);
|
||||||
|
setMenuItems(converted);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// API 실패 시 기존 하드코딩 데이터 유지
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadPopMenus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleThemeToggle = () => {
|
const handleThemeToggle = () => {
|
||||||
const newTheme = theme === "dark" ? "light" : "dark";
|
const newTheme = theme === "dark" ? "light" : "dark";
|
||||||
setTheme(newTheme);
|
setTheme(newTheme);
|
||||||
|
|
@ -40,6 +80,10 @@ export function PopDashboard() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePcModeClick = () => {
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
const handleActivityMore = () => {
|
const handleActivityMore = () => {
|
||||||
alert("전체 활동 내역 화면으로 이동합니다.");
|
alert("전체 활동 내역 화면으로 이동합니다.");
|
||||||
};
|
};
|
||||||
|
|
@ -58,13 +102,14 @@ export function PopDashboard() {
|
||||||
company={{ name: "탑씰", subTitle: "현장 관리 시스템" }}
|
company={{ name: "탑씰", subTitle: "현장 관리 시스템" }}
|
||||||
onThemeToggle={handleThemeToggle}
|
onThemeToggle={handleThemeToggle}
|
||||||
onUserClick={handleUserClick}
|
onUserClick={handleUserClick}
|
||||||
|
onPcModeClick={handlePcModeClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NoticeBanner text={NOTICE_MARQUEE_TEXT} />
|
<NoticeBanner text={NOTICE_MARQUEE_TEXT} />
|
||||||
|
|
||||||
<KpiBar items={KPI_ITEMS} />
|
<KpiBar items={KPI_ITEMS} />
|
||||||
|
|
||||||
<MenuGrid items={MENU_ITEMS} />
|
<MenuGrid items={menuItems} />
|
||||||
|
|
||||||
<div className="pop-dashboard-bottom-section">
|
<div className="pop-dashboard-bottom-section">
|
||||||
<ActivityList items={ACTIVITY_ITEMS} onMoreClick={handleActivityMore} />
|
<ActivityList items={ACTIVITY_ITEMS} onMoreClick={handleActivityMore} />
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ export default function PopDesigner({
|
||||||
try {
|
try {
|
||||||
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||||
|
|
||||||
if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) {
|
if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) {
|
||||||
// v5 레이아웃 로드
|
// v5 레이아웃 로드
|
||||||
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
|
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
|
||||||
if (!loadedLayout.settings.gapPreset) {
|
if (!loadedLayout.settings.gapPreset) {
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,12 @@ const COMPONENT_TYPE_LABELS: Record<string, string> = {
|
||||||
"pop-icon": "아이콘",
|
"pop-icon": "아이콘",
|
||||||
"pop-dashboard": "대시보드",
|
"pop-dashboard": "대시보드",
|
||||||
"pop-card-list": "카드 목록",
|
"pop-card-list": "카드 목록",
|
||||||
|
"pop-card-list-v2": "카드 목록 V2",
|
||||||
"pop-field": "필드",
|
"pop-field": "필드",
|
||||||
"pop-button": "버튼",
|
"pop-button": "버튼",
|
||||||
"pop-string-list": "리스트 목록",
|
"pop-string-list": "리스트 목록",
|
||||||
"pop-search": "검색",
|
"pop-search": "검색",
|
||||||
|
"pop-status-bar": "상태 바",
|
||||||
"pop-list": "리스트",
|
"pop-list": "리스트",
|
||||||
"pop-indicator": "인디케이터",
|
"pop-indicator": "인디케이터",
|
||||||
"pop-scanner": "스캐너",
|
"pop-scanner": "스캐너",
|
||||||
|
|
@ -169,9 +171,7 @@ export default function ComponentEditorPanel({
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{allComponents.map((comp) => {
|
{allComponents.map((comp) => {
|
||||||
const label = comp.label
|
const label = comp.label || comp.id;
|
||||||
|| COMPONENT_TYPE_LABELS[comp.type]
|
|
||||||
|| comp.type;
|
|
||||||
const isActive = comp.id === selectedComponentId;
|
const isActive = comp.id === selectedComponentId;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useDrag } from "react-dnd";
|
import { useDrag } from "react-dnd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PopComponentType } from "../types/pop-layout";
|
import { PopComponentType } from "../types/pop-layout";
|
||||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput } from "lucide-react";
|
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2 } from "lucide-react";
|
||||||
import { DND_ITEM_TYPES } from "../constants";
|
import { DND_ITEM_TYPES } from "../constants";
|
||||||
|
|
||||||
// 컴포넌트 정의
|
// 컴포넌트 정의
|
||||||
|
|
@ -45,6 +45,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
description: "테이블 데이터를 카드 형태로 표시",
|
description: "테이블 데이터를 카드 형태로 표시",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "pop-card-list-v2",
|
||||||
|
label: "카드 목록 V2",
|
||||||
|
icon: LayoutGrid,
|
||||||
|
description: "슬롯 기반 카드 (CSS Grid + 셀 타입별 렌더링)",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "pop-button",
|
type: "pop-button",
|
||||||
label: "버튼",
|
label: "버튼",
|
||||||
|
|
@ -63,12 +69,30 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
icon: Search,
|
icon: Search,
|
||||||
description: "조건 입력 (텍스트/날짜/선택/모달)",
|
description: "조건 입력 (텍스트/날짜/선택/모달)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "pop-status-bar",
|
||||||
|
label: "상태 바",
|
||||||
|
icon: BarChart2,
|
||||||
|
description: "상태별 건수 대시보드 + 필터",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "pop-field",
|
type: "pop-field",
|
||||||
label: "입력 필드",
|
label: "입력 필드",
|
||||||
icon: TextCursorInput,
|
icon: TextCursorInput,
|
||||||
description: "저장용 값 입력 (섹션별 멀티필드)",
|
description: "저장용 값 입력 (섹션별 멀티필드)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "pop-scanner",
|
||||||
|
label: "스캐너",
|
||||||
|
icon: ScanLine,
|
||||||
|
description: "바코드/QR 카메라 스캔",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-profile",
|
||||||
|
label: "프로필",
|
||||||
|
icon: UserCircle,
|
||||||
|
description: "사용자 프로필 / PC 전환 / 로그아웃",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import React from "react";
|
||||||
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
|
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -19,7 +18,6 @@ import {
|
||||||
} from "../types/pop-layout";
|
} from "../types/pop-layout";
|
||||||
import {
|
import {
|
||||||
PopComponentRegistry,
|
PopComponentRegistry,
|
||||||
type ComponentConnectionMeta,
|
|
||||||
} from "@/lib/registry/PopComponentRegistry";
|
} from "@/lib/registry/PopComponentRegistry";
|
||||||
import { getTableColumns } from "@/lib/api/tableManagement";
|
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
|
@ -36,15 +34,6 @@ interface ConnectionEditorProps {
|
||||||
onRemoveConnection?: (connectionId: string) => void;
|
onRemoveConnection?: (connectionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 소스 컴포넌트에 filter 타입 sendable이 있는지 판단
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean {
|
|
||||||
if (!meta?.sendable) return false;
|
|
||||||
return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// ConnectionEditor
|
// ConnectionEditor
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -84,17 +73,13 @@ export default function ConnectionEditor({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFilterSource = hasFilterSendable(meta);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{hasSendable && (
|
{hasSendable && (
|
||||||
<SendSection
|
<SendSection
|
||||||
component={component}
|
component={component}
|
||||||
meta={meta!}
|
|
||||||
allComponents={allComponents}
|
allComponents={allComponents}
|
||||||
outgoing={outgoing}
|
outgoing={outgoing}
|
||||||
isFilterSource={isFilterSource}
|
|
||||||
onAddConnection={onAddConnection}
|
onAddConnection={onAddConnection}
|
||||||
onUpdateConnection={onUpdateConnection}
|
onUpdateConnection={onUpdateConnection}
|
||||||
onRemoveConnection={onRemoveConnection}
|
onRemoveConnection={onRemoveConnection}
|
||||||
|
|
@ -112,47 +97,14 @@ export default function ConnectionEditor({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 대상 컴포넌트에서 정보 추출
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
|
|
||||||
if (!comp?.config) return [];
|
|
||||||
const cfg = comp.config as Record<string, unknown>;
|
|
||||||
const cols: string[] = [];
|
|
||||||
|
|
||||||
if (Array.isArray(cfg.listColumns)) {
|
|
||||||
(cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => {
|
|
||||||
if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(cfg.selectedColumns)) {
|
|
||||||
(cfg.selectedColumns as string[]).forEach((c) => {
|
|
||||||
if (!cols.includes(c)) cols.push(c);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return cols;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
|
|
||||||
if (!comp?.config) return "";
|
|
||||||
const cfg = comp.config as Record<string, unknown>;
|
|
||||||
const ds = cfg.dataSource as { tableName?: string } | undefined;
|
|
||||||
return ds?.tableName || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 보내기 섹션
|
// 보내기 섹션
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface SendSectionProps {
|
interface SendSectionProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinitionV5;
|
||||||
meta: ComponentConnectionMeta;
|
|
||||||
allComponents: PopComponentDefinitionV5[];
|
allComponents: PopComponentDefinitionV5[];
|
||||||
outgoing: PopDataConnection[];
|
outgoing: PopDataConnection[];
|
||||||
isFilterSource: boolean;
|
|
||||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||||
onRemoveConnection?: (connectionId: string) => void;
|
onRemoveConnection?: (connectionId: string) => void;
|
||||||
|
|
@ -160,10 +112,8 @@ interface SendSectionProps {
|
||||||
|
|
||||||
function SendSection({
|
function SendSection({
|
||||||
component,
|
component,
|
||||||
meta,
|
|
||||||
allComponents,
|
allComponents,
|
||||||
outgoing,
|
outgoing,
|
||||||
isFilterSource,
|
|
||||||
onAddConnection,
|
onAddConnection,
|
||||||
onUpdateConnection,
|
onUpdateConnection,
|
||||||
onRemoveConnection,
|
onRemoveConnection,
|
||||||
|
|
@ -180,34 +130,20 @@ function SendSection({
|
||||||
{outgoing.map((conn) => (
|
{outgoing.map((conn) => (
|
||||||
<div key={conn.id}>
|
<div key={conn.id}>
|
||||||
{editingId === conn.id ? (
|
{editingId === conn.id ? (
|
||||||
isFilterSource ? (
|
<SimpleConnectionForm
|
||||||
<FilterConnectionForm
|
component={component}
|
||||||
component={component}
|
allComponents={allComponents}
|
||||||
meta={meta}
|
initial={conn}
|
||||||
allComponents={allComponents}
|
onSubmit={(data) => {
|
||||||
initial={conn}
|
onUpdateConnection?.(conn.id, data);
|
||||||
onSubmit={(data) => {
|
setEditingId(null);
|
||||||
onUpdateConnection?.(conn.id, data);
|
}}
|
||||||
setEditingId(null);
|
onCancel={() => setEditingId(null)}
|
||||||
}}
|
submitLabel="수정"
|
||||||
onCancel={() => setEditingId(null)}
|
/>
|
||||||
submitLabel="수정"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SimpleConnectionForm
|
|
||||||
component={component}
|
|
||||||
allComponents={allComponents}
|
|
||||||
initial={conn}
|
|
||||||
onSubmit={(data) => {
|
|
||||||
onUpdateConnection?.(conn.id, data);
|
|
||||||
setEditingId(null);
|
|
||||||
}}
|
|
||||||
onCancel={() => setEditingId(null)}
|
|
||||||
submitLabel="수정"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1 rounded border bg-primary/10/50 px-3 py-2">
|
<div className="space-y-1 rounded border bg-primary/10 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<span className="flex-1 truncate text-xs">
|
<span className="flex-1 truncate text-xs">
|
||||||
{conn.label || `→ ${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
|
{conn.label || `→ ${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -225,27 +161,33 @@ function SendSection({
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{conn.filterConfig?.targetColumn && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<span className="rounded bg-white px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
||||||
|
{conn.filterConfig.targetColumn}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-white px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
||||||
|
{conn.filterConfig.filterMode}
|
||||||
|
</span>
|
||||||
|
{conn.filterConfig.isSubTable && (
|
||||||
|
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] text-amber-700">
|
||||||
|
하위 테이블
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isFilterSource ? (
|
<SimpleConnectionForm
|
||||||
<FilterConnectionForm
|
component={component}
|
||||||
component={component}
|
allComponents={allComponents}
|
||||||
meta={meta}
|
onSubmit={(data) => onAddConnection?.(data)}
|
||||||
allComponents={allComponents}
|
submitLabel="연결 추가"
|
||||||
onSubmit={(data) => onAddConnection?.(data)}
|
/>
|
||||||
submitLabel="연결 추가"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SimpleConnectionForm
|
|
||||||
component={component}
|
|
||||||
allComponents={allComponents}
|
|
||||||
onSubmit={(data) => onAddConnection?.(data)}
|
|
||||||
submitLabel="연결 추가"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -263,6 +205,19 @@ interface SimpleConnectionFormProps {
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractSubTableName(comp: PopComponentDefinitionV5): string | null {
|
||||||
|
const cfg = comp.config as Record<string, unknown> | undefined;
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
const grid = cfg.cardGrid as { cells?: Array<{ timelineSource?: { processTable?: string } }> } | undefined;
|
||||||
|
if (grid?.cells) {
|
||||||
|
for (const cell of grid.cells) {
|
||||||
|
if (cell.timelineSource?.processTable) return cell.timelineSource.processTable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function SimpleConnectionForm({
|
function SimpleConnectionForm({
|
||||||
component,
|
component,
|
||||||
allComponents,
|
allComponents,
|
||||||
|
|
@ -274,6 +229,18 @@ function SimpleConnectionForm({
|
||||||
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
||||||
initial?.targetComponent || ""
|
initial?.targetComponent || ""
|
||||||
);
|
);
|
||||||
|
const [isSubTable, setIsSubTable] = React.useState(
|
||||||
|
initial?.filterConfig?.isSubTable || false
|
||||||
|
);
|
||||||
|
const [targetColumn, setTargetColumn] = React.useState(
|
||||||
|
initial?.filterConfig?.targetColumn || ""
|
||||||
|
);
|
||||||
|
const [filterMode, setFilterMode] = React.useState<string>(
|
||||||
|
initial?.filterConfig?.filterMode || "equals"
|
||||||
|
);
|
||||||
|
|
||||||
|
const [subColumns, setSubColumns] = React.useState<string[]>([]);
|
||||||
|
const [loadingColumns, setLoadingColumns] = React.useState(false);
|
||||||
|
|
||||||
const targetCandidates = allComponents.filter((c) => {
|
const targetCandidates = allComponents.filter((c) => {
|
||||||
if (c.id === component.id) return false;
|
if (c.id === component.id) return false;
|
||||||
|
|
@ -281,14 +248,39 @@ function SimpleConnectionForm({
|
||||||
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
|
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sourceReg = PopComponentRegistry.getComponent(component.type);
|
||||||
|
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
|
||||||
|
const targetReg = targetComp ? PopComponentRegistry.getComponent(targetComp.type) : null;
|
||||||
|
const isFilterConnection = sourceReg?.connectionMeta?.sendable?.some((s) => s.type === "filter_value")
|
||||||
|
&& targetReg?.connectionMeta?.receivable?.some((r) => r.type === "filter_value");
|
||||||
|
|
||||||
|
const subTableName = targetComp ? extractSubTableName(targetComp) : null;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isSubTable || !subTableName) {
|
||||||
|
setSubColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingColumns(true);
|
||||||
|
getTableColumns(subTableName)
|
||||||
|
.then((res) => {
|
||||||
|
const cols = res.success && res.data?.columns;
|
||||||
|
if (Array.isArray(cols)) {
|
||||||
|
setSubColumns(cols.map((c) => c.columnName || "").filter(Boolean));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setSubColumns([]))
|
||||||
|
.finally(() => setLoadingColumns(false));
|
||||||
|
}, [isSubTable, subTableName]);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!selectedTargetId) return;
|
if (!selectedTargetId) return;
|
||||||
|
|
||||||
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
|
const tComp = allComponents.find((c) => c.id === selectedTargetId);
|
||||||
const srcLabel = component.label || component.id;
|
const srcLabel = component.label || component.id;
|
||||||
const tgtLabel = targetComp?.label || targetComp?.id || "?";
|
const tgtLabel = tComp?.label || tComp?.id || "?";
|
||||||
|
|
||||||
onSubmit({
|
const conn: Omit<PopDataConnection, "id"> = {
|
||||||
sourceComponent: component.id,
|
sourceComponent: component.id,
|
||||||
sourceField: "",
|
sourceField: "",
|
||||||
sourceOutput: "_auto",
|
sourceOutput: "_auto",
|
||||||
|
|
@ -296,10 +288,23 @@ function SimpleConnectionForm({
|
||||||
targetField: "",
|
targetField: "",
|
||||||
targetInput: "_auto",
|
targetInput: "_auto",
|
||||||
label: `${srcLabel} → ${tgtLabel}`,
|
label: `${srcLabel} → ${tgtLabel}`,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (isFilterConnection && isSubTable && targetColumn) {
|
||||||
|
conn.filterConfig = {
|
||||||
|
targetColumn,
|
||||||
|
filterMode: filterMode as "equals" | "contains" | "starts_with" | "range",
|
||||||
|
isSubTable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(conn);
|
||||||
|
|
||||||
if (!initial) {
|
if (!initial) {
|
||||||
setSelectedTargetId("");
|
setSelectedTargetId("");
|
||||||
|
setIsSubTable(false);
|
||||||
|
setTargetColumn("");
|
||||||
|
setFilterMode("equals");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -319,224 +324,12 @@ function SimpleConnectionForm({
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] text-muted-foreground">어디로?</span>
|
<span className="text-[10px] text-muted-foreground">어디로?</span>
|
||||||
<Select
|
|
||||||
value={selectedTargetId}
|
|
||||||
onValueChange={setSelectedTargetId}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-7 text-xs">
|
|
||||||
<SelectValue placeholder="컴포넌트 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{targetCandidates.map((c) => (
|
|
||||||
<SelectItem key={c.id} value={c.id} className="text-xs">
|
|
||||||
{c.label || c.id}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-7 w-full text-xs"
|
|
||||||
disabled={!selectedTargetId}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
{!initial && <Plus className="mr-1 h-3 w-3" />}
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지)
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface FilterConnectionFormProps {
|
|
||||||
component: PopComponentDefinitionV5;
|
|
||||||
meta: ComponentConnectionMeta;
|
|
||||||
allComponents: PopComponentDefinitionV5[];
|
|
||||||
initial?: PopDataConnection;
|
|
||||||
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
submitLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FilterConnectionForm({
|
|
||||||
component,
|
|
||||||
meta,
|
|
||||||
allComponents,
|
|
||||||
initial,
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
submitLabel,
|
|
||||||
}: FilterConnectionFormProps) {
|
|
||||||
const [selectedOutput, setSelectedOutput] = React.useState(
|
|
||||||
initial?.sourceOutput || meta.sendable[0]?.key || ""
|
|
||||||
);
|
|
||||||
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
|
||||||
initial?.targetComponent || ""
|
|
||||||
);
|
|
||||||
const [selectedTargetInput, setSelectedTargetInput] = React.useState(
|
|
||||||
initial?.targetInput || ""
|
|
||||||
);
|
|
||||||
const [filterColumns, setFilterColumns] = React.useState<string[]>(
|
|
||||||
initial?.filterConfig?.targetColumns ||
|
|
||||||
(initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : [])
|
|
||||||
);
|
|
||||||
const [filterMode, setFilterMode] = React.useState<
|
|
||||||
"equals" | "contains" | "starts_with" | "range"
|
|
||||||
>(initial?.filterConfig?.filterMode || "contains");
|
|
||||||
|
|
||||||
const targetCandidates = allComponents.filter((c) => {
|
|
||||||
if (c.id === component.id) return false;
|
|
||||||
const reg = PopComponentRegistry.getComponent(c.type);
|
|
||||||
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const targetComp = selectedTargetId
|
|
||||||
? allComponents.find((c) => c.id === selectedTargetId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const targetMeta = targetComp
|
|
||||||
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
|
|
||||||
: null;
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!selectedOutput || !targetMeta?.receivable?.length) return;
|
|
||||||
if (selectedTargetInput) return;
|
|
||||||
|
|
||||||
const receivables = targetMeta.receivable;
|
|
||||||
const exactMatch = receivables.find((r) => r.key === selectedOutput);
|
|
||||||
if (exactMatch) {
|
|
||||||
setSelectedTargetInput(exactMatch.key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (receivables.length === 1) {
|
|
||||||
setSelectedTargetInput(receivables[0].key);
|
|
||||||
}
|
|
||||||
}, [selectedOutput, targetMeta, selectedTargetInput]);
|
|
||||||
|
|
||||||
const displayColumns = React.useMemo(
|
|
||||||
() => extractDisplayColumns(targetComp || undefined),
|
|
||||||
[targetComp]
|
|
||||||
);
|
|
||||||
|
|
||||||
const tableName = React.useMemo(
|
|
||||||
() => extractTableName(targetComp || undefined),
|
|
||||||
[targetComp]
|
|
||||||
);
|
|
||||||
const [allDbColumns, setAllDbColumns] = React.useState<string[]>([]);
|
|
||||||
const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!tableName) {
|
|
||||||
setAllDbColumns([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
setDbColumnsLoading(true);
|
|
||||||
getTableColumns(tableName).then((res) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
if (res.success && res.data?.columns) {
|
|
||||||
setAllDbColumns(res.data.columns.map((c) => c.columnName));
|
|
||||||
} else {
|
|
||||||
setAllDbColumns([]);
|
|
||||||
}
|
|
||||||
setDbColumnsLoading(false);
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [tableName]);
|
|
||||||
|
|
||||||
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
|
|
||||||
const dataOnlyColumns = React.useMemo(
|
|
||||||
() => allDbColumns.filter((c) => !displaySet.has(c)),
|
|
||||||
[allDbColumns, displaySet]
|
|
||||||
);
|
|
||||||
const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0;
|
|
||||||
|
|
||||||
const toggleColumn = (col: string) => {
|
|
||||||
setFilterColumns((prev) =>
|
|
||||||
prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return;
|
|
||||||
|
|
||||||
const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput);
|
|
||||||
|
|
||||||
onSubmit({
|
|
||||||
sourceComponent: component.id,
|
|
||||||
sourceField: "",
|
|
||||||
sourceOutput: selectedOutput,
|
|
||||||
targetComponent: selectedTargetId,
|
|
||||||
targetField: "",
|
|
||||||
targetInput: selectedTargetInput,
|
|
||||||
filterConfig:
|
|
||||||
!isEvent && filterColumns.length > 0
|
|
||||||
? {
|
|
||||||
targetColumn: filterColumns[0],
|
|
||||||
targetColumns: filterColumns,
|
|
||||||
filterMode,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
label: buildConnectionLabel(
|
|
||||||
component,
|
|
||||||
selectedOutput,
|
|
||||||
allComponents.find((c) => c.id === selectedTargetId),
|
|
||||||
selectedTargetInput,
|
|
||||||
filterColumns
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!initial) {
|
|
||||||
setSelectedTargetId("");
|
|
||||||
setSelectedTargetInput("");
|
|
||||||
setFilterColumns([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2 rounded border border-dashed p-3">
|
|
||||||
{onCancel && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-[10px] font-medium text-muted-foreground">연결 수정</p>
|
|
||||||
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!onCancel && (
|
|
||||||
<p className="text-[10px] font-medium text-muted-foreground">새 연결 추가</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<span className="text-[10px] text-muted-foreground">보내는 값</span>
|
|
||||||
<Select value={selectedOutput} onValueChange={setSelectedOutput}>
|
|
||||||
<SelectTrigger className="h-7 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{meta.sendable.map((s) => (
|
|
||||||
<SelectItem key={s.key} value={s.key} className="text-xs">
|
|
||||||
{s.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<span className="text-[10px] text-muted-foreground">받는 컴포넌트</span>
|
|
||||||
<Select
|
<Select
|
||||||
value={selectedTargetId}
|
value={selectedTargetId}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setSelectedTargetId(v);
|
setSelectedTargetId(v);
|
||||||
setSelectedTargetInput("");
|
setIsSubTable(false);
|
||||||
setFilterColumns([]);
|
setTargetColumn("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 text-xs">
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
|
@ -552,109 +345,62 @@ function FilterConnectionForm({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{targetMeta && (
|
{isFilterConnection && selectedTargetId && subTableName && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-2 rounded bg-muted/50 p-2">
|
||||||
<span className="text-[10px] text-muted-foreground">받는 방식</span>
|
<div className="flex items-center gap-2">
|
||||||
<Select value={selectedTargetInput} onValueChange={setSelectedTargetInput}>
|
<Checkbox
|
||||||
<SelectTrigger className="h-7 text-xs">
|
id={`isSubTable_${component.id}`}
|
||||||
<SelectValue placeholder="선택" />
|
checked={isSubTable}
|
||||||
</SelectTrigger>
|
onCheckedChange={(v) => {
|
||||||
<SelectContent>
|
setIsSubTable(v === true);
|
||||||
{targetMeta.receivable.map((r) => (
|
if (!v) setTargetColumn("");
|
||||||
<SelectItem key={r.key} value={r.key} className="text-xs">
|
}}
|
||||||
{r.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
|
|
||||||
<div className="space-y-2 rounded bg-muted p-2">
|
|
||||||
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
|
||||||
|
|
||||||
{dbColumnsLoading ? (
|
|
||||||
<div className="flex items-center gap-2 py-2">
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
|
||||||
<span className="text-[10px] text-muted-foreground">컬럼 조회 중...</span>
|
|
||||||
</div>
|
|
||||||
) : hasAnyColumns ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{displayColumns.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[9px] font-medium text-emerald-600">화면 표시 컬럼</p>
|
|
||||||
{displayColumns.map((col) => (
|
|
||||||
<div key={col} className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`col-${col}-${initial?.id || "new"}`}
|
|
||||||
checked={filterColumns.includes(col)}
|
|
||||||
onCheckedChange={() => toggleColumn(col)}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`col-${col}-${initial?.id || "new"}`}
|
|
||||||
className="cursor-pointer text-xs"
|
|
||||||
>
|
|
||||||
{col}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dataOnlyColumns.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{displayColumns.length > 0 && (
|
|
||||||
<div className="my-1 h-px bg-muted/80" />
|
|
||||||
)}
|
|
||||||
<p className="text-[9px] font-medium text-amber-600">데이터 전용 컬럼</p>
|
|
||||||
{dataOnlyColumns.map((col) => (
|
|
||||||
<div key={col} className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`col-${col}-${initial?.id || "new"}`}
|
|
||||||
checked={filterColumns.includes(col)}
|
|
||||||
onCheckedChange={() => toggleColumn(col)}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`col-${col}-${initial?.id || "new"}`}
|
|
||||||
className="cursor-pointer text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{col}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
value={filterColumns[0] || ""}
|
|
||||||
onChange={(e) => setFilterColumns(e.target.value ? [e.target.value] : [])}
|
|
||||||
placeholder="컬럼명 입력"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
/>
|
||||||
)}
|
<label htmlFor={`isSubTable_${component.id}`} className="text-[10px] text-muted-foreground cursor-pointer">
|
||||||
|
하위 테이블 기준으로 필터 ({subTableName})
|
||||||
{filterColumns.length > 0 && (
|
</label>
|
||||||
<p className="text-[10px] text-primary">
|
|
||||||
{filterColumns.length}개 컬럼 중 하나라도 일치하면 표시
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[10px] text-muted-foreground">필터 방식</p>
|
|
||||||
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
|
|
||||||
<SelectTrigger className="h-7 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="contains" className="text-xs">포함</SelectItem>
|
|
||||||
<SelectItem value="equals" className="text-xs">일치</SelectItem>
|
|
||||||
<SelectItem value="starts_with" className="text-xs">시작</SelectItem>
|
|
||||||
<SelectItem value="range" className="text-xs">범위</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isSubTable && (
|
||||||
|
<div className="space-y-2 pl-5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">대상 컬럼</span>
|
||||||
|
{loadingColumns ? (
|
||||||
|
<div className="flex items-center gap-1 py-1">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-[10px] text-muted-foreground">컬럼 로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select value={targetColumn} onValueChange={setTargetColumn}>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{subColumns.filter(Boolean).map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">비교 방식</span>
|
||||||
|
<Select value={filterMode} onValueChange={setFilterMode}>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="equals" className="text-xs">일치 (equals)</SelectItem>
|
||||||
|
<SelectItem value="contains" className="text-xs">포함 (contains)</SelectItem>
|
||||||
|
<SelectItem value="starts_with" className="text-xs">시작 (starts_with)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -662,7 +408,7 @@ function FilterConnectionForm({
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 w-full text-xs"
|
className="h-7 w-full text-xs"
|
||||||
disabled={!selectedOutput || !selectedTargetId || !selectedTargetInput}
|
disabled={!selectedTargetId}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
{!initial && <Plus className="mr-1 h-3 w-3" />}
|
{!initial && <Plus className="mr-1 h-3 w-3" />}
|
||||||
|
|
@ -722,32 +468,3 @@ function ReceiveSection({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 유틸
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function isEventTypeConnection(
|
|
||||||
sourceMeta: ComponentConnectionMeta | undefined,
|
|
||||||
outputKey: string,
|
|
||||||
targetMeta: ComponentConnectionMeta | null | undefined,
|
|
||||||
inputKey: string,
|
|
||||||
): boolean {
|
|
||||||
const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey);
|
|
||||||
const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey);
|
|
||||||
return sourceItem?.type === "event" || targetItem?.type === "event";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildConnectionLabel(
|
|
||||||
source: PopComponentDefinitionV5,
|
|
||||||
_outputKey: string,
|
|
||||||
target: PopComponentDefinitionV5 | undefined,
|
|
||||||
_inputKey: string,
|
|
||||||
columns?: string[]
|
|
||||||
): string {
|
|
||||||
const srcLabel = source.label || source.id;
|
|
||||||
const tgtLabel = target?.label || target?.id || "?";
|
|
||||||
const colInfo = columns && columns.length > 0
|
|
||||||
? ` [${columns.join(", ")}]`
|
|
||||||
: "";
|
|
||||||
return `${srcLabel} → ${tgtLabel}${colInfo}`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,14 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||||
"pop-icon": "아이콘",
|
"pop-icon": "아이콘",
|
||||||
"pop-dashboard": "대시보드",
|
"pop-dashboard": "대시보드",
|
||||||
"pop-card-list": "카드 목록",
|
"pop-card-list": "카드 목록",
|
||||||
|
"pop-card-list-v2": "카드 목록 V2",
|
||||||
"pop-button": "버튼",
|
"pop-button": "버튼",
|
||||||
"pop-string-list": "리스트 목록",
|
"pop-string-list": "리스트 목록",
|
||||||
"pop-search": "검색",
|
"pop-search": "검색",
|
||||||
|
"pop-status-bar": "상태 바",
|
||||||
"pop-field": "입력",
|
"pop-field": "입력",
|
||||||
|
"pop-scanner": "스캐너",
|
||||||
|
"pop-profile": "프로필",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -554,7 +558,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
|
||||||
if (ActualComp) {
|
if (ActualComp) {
|
||||||
// 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
|
// 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
|
||||||
// CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용
|
// CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용
|
||||||
const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list";
|
const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list" || component.type === "pop-card-list-v2";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
/**
|
/**
|
||||||
* POP 컴포넌트 타입
|
* POP 컴포넌트 타입
|
||||||
*/
|
*/
|
||||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field";
|
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 흐름 정의
|
* 데이터 흐름 정의
|
||||||
|
|
@ -33,6 +33,7 @@ export interface PopDataConnection {
|
||||||
targetColumn: string;
|
targetColumn: string;
|
||||||
targetColumns?: string[];
|
targetColumns?: string[];
|
||||||
filterMode: "equals" | "contains" | "starts_with" | "range";
|
filterMode: "equals" | "contains" | "starts_with" | "range";
|
||||||
|
isSubTable?: boolean;
|
||||||
};
|
};
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -358,10 +359,14 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
|
||||||
"pop-icon": { colSpan: 1, rowSpan: 2 },
|
"pop-icon": { colSpan: 1, rowSpan: 2 },
|
||||||
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
|
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
|
||||||
"pop-card-list": { colSpan: 4, rowSpan: 3 },
|
"pop-card-list": { colSpan: 4, rowSpan: 3 },
|
||||||
|
"pop-card-list-v2": { colSpan: 4, rowSpan: 3 },
|
||||||
"pop-button": { colSpan: 2, rowSpan: 1 },
|
"pop-button": { colSpan: 2, rowSpan: 1 },
|
||||||
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
||||||
"pop-search": { colSpan: 4, rowSpan: 2 },
|
"pop-search": { colSpan: 2, rowSpan: 1 },
|
||||||
|
"pop-status-bar": { colSpan: 6, rowSpan: 1 },
|
||||||
"pop-field": { colSpan: 6, rowSpan: 2 },
|
"pop-field": { colSpan: 6, rowSpan: 2 },
|
||||||
|
"pop-scanner": { colSpan: 1, rowSpan: 1 },
|
||||||
|
"pop-profile": { colSpan: 1, rowSpan: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1165,6 +1165,28 @@ export default function CopyScreenModal({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 그룹 복제 요약 감사 로그 1건 기록
|
||||||
|
try {
|
||||||
|
await apiClient.post("/audit-log", {
|
||||||
|
action: "COPY",
|
||||||
|
resourceType: "SCREEN",
|
||||||
|
resourceId: String(sourceGroup.id),
|
||||||
|
resourceName: sourceGroup.group_name,
|
||||||
|
summary: `그룹 "${sourceGroup.group_name}" → "${rootGroupName}" 복제 (그룹 ${stats.groups}개, 화면 ${stats.screens}개)${finalCompanyCode !== sourceGroup.company_code ? ` [${sourceGroup.company_code} → ${finalCompanyCode}]` : ""}`,
|
||||||
|
changes: {
|
||||||
|
after: {
|
||||||
|
원본그룹: sourceGroup.group_name,
|
||||||
|
대상그룹: rootGroupName,
|
||||||
|
복제그룹수: stats.groups,
|
||||||
|
복제화면수: stats.screens,
|
||||||
|
대상회사: finalCompanyCode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (auditError) {
|
||||||
|
console.warn("그룹 복제 감사 로그 기록 실패 (무시):", auditError);
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
`그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)`
|
`그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -605,6 +605,23 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
}
|
}
|
||||||
}, [relatedButtonFilter]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const loadCategoryMappings = async () => {
|
const loadCategoryMappings = async () => {
|
||||||
|
|
|
||||||
|
|
@ -1850,7 +1850,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
try {
|
try {
|
||||||
// console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
|
// console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
|
||||||
|
|
||||||
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName);
|
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName, screenInfo?.id);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert("삭제되었습니다.");
|
alert("삭제되었습니다.");
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ interface RealtimePreviewProps {
|
||||||
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
|
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
|
||||||
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||||
selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID
|
selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID
|
||||||
|
onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void;
|
||||||
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
|
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
|
||||||
|
|
||||||
// 버튼 액션을 위한 props
|
// 버튼 액션을 위한 props
|
||||||
|
|
@ -150,6 +151,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
|
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
|
||||||
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||||
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
|
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
|
||||||
|
onNestedPanelSelect,
|
||||||
onResize, // 🆕 리사이즈 콜백
|
onResize, // 🆕 리사이즈 콜백
|
||||||
}) => {
|
}) => {
|
||||||
// 🆕 화면 다국어 컨텍스트
|
// 🆕 화면 다국어 컨텍스트
|
||||||
|
|
@ -768,6 +770,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
selectedTabComponentId={selectedTabComponentId}
|
selectedTabComponentId={selectedTabComponentId}
|
||||||
onSelectPanelComponent={onSelectPanelComponent}
|
onSelectPanelComponent={onSelectPanelComponent}
|
||||||
selectedPanelComponentId={selectedPanelComponentId}
|
selectedPanelComponentId={selectedPanelComponentId}
|
||||||
|
onNestedPanelSelect={onNestedPanelSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,8 @@ interface ProcessedRow {
|
||||||
mainComponent?: ComponentData;
|
mainComponent?: ComponentData;
|
||||||
overlayComps: ComponentData[];
|
overlayComps: ComponentData[];
|
||||||
normalComps: ComponentData[];
|
normalComps: ComponentData[];
|
||||||
|
rowMinY?: number;
|
||||||
|
rowMaxBottom?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FullWidthOverlayRow({
|
function FullWidthOverlayRow({
|
||||||
|
|
@ -202,6 +204,66 @@ function FullWidthOverlayRow({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProportionalRenderer({
|
||||||
|
components,
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
renderComponent,
|
||||||
|
}: ResponsiveGridRendererProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerW, setContainerW] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const w = entries[0]?.contentRect.width;
|
||||||
|
if (w && w > 0) setContainerW(w);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const topLevel = components.filter((c) => !c.parentId);
|
||||||
|
const ratio = containerW > 0 ? containerW / canvasWidth : 1;
|
||||||
|
|
||||||
|
const maxBottom = topLevel.reduce((max, c) => {
|
||||||
|
const bottom = c.position.y + (c.size?.height || 40);
|
||||||
|
return Math.max(max, bottom);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
data-screen-runtime="true"
|
||||||
|
className="bg-background relative w-full overflow-x-hidden"
|
||||||
|
style={{ minHeight: containerW > 0 ? `${maxBottom * ratio}px` : "200px" }}
|
||||||
|
>
|
||||||
|
{containerW > 0 &&
|
||||||
|
topLevel.map((component) => {
|
||||||
|
const typeId = getComponentTypeId(component);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={component.id}
|
||||||
|
data-component-id={component.id}
|
||||||
|
data-component-type={typeId}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: `${(component.position.x / canvasWidth) * 100}%`,
|
||||||
|
top: `${component.position.y * ratio}px`,
|
||||||
|
width: `${((component.size?.width || 100) / canvasWidth) * 100}%`,
|
||||||
|
height: `${(component.size?.height || 40) * ratio}px`,
|
||||||
|
zIndex: component.position.z || 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderComponent(component)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ResponsiveGridRenderer({
|
export function ResponsiveGridRenderer({
|
||||||
components,
|
components,
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
|
|
@ -211,6 +273,18 @@ export function ResponsiveGridRenderer({
|
||||||
const { isMobile } = useResponsive();
|
const { isMobile } = useResponsive();
|
||||||
|
|
||||||
const topLevel = components.filter((c) => !c.parentId);
|
const topLevel = components.filter((c) => !c.parentId);
|
||||||
|
const hasFullWidthComponent = topLevel.some((c) => isFullWidthComponent(c));
|
||||||
|
|
||||||
|
if (!isMobile && !hasFullWidthComponent) {
|
||||||
|
return (
|
||||||
|
<ProportionalRenderer
|
||||||
|
components={components}
|
||||||
|
canvasWidth={canvasWidth}
|
||||||
|
canvasHeight={canvasHeight}
|
||||||
|
renderComponent={renderComponent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const rows = groupComponentsIntoRows(topLevel);
|
const rows = groupComponentsIntoRows(topLevel);
|
||||||
const processedRows: ProcessedRow[] = [];
|
const processedRows: ProcessedRow[] = [];
|
||||||
|
|
@ -227,6 +301,10 @@ export function ResponsiveGridRenderer({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allComps = [...fullWidthComps, ...normalComps];
|
||||||
|
const rowMinY = allComps.length > 0 ? Math.min(...allComps.map(c => c.position.y)) : 0;
|
||||||
|
const rowMaxBottom = allComps.length > 0 ? Math.max(...allComps.map(c => c.position.y + (c.size?.height || 40))) : 0;
|
||||||
|
|
||||||
if (fullWidthComps.length > 0 && normalComps.length > 0) {
|
if (fullWidthComps.length > 0 && normalComps.length > 0) {
|
||||||
for (const fwComp of fullWidthComps) {
|
for (const fwComp of fullWidthComps) {
|
||||||
processedRows.push({
|
processedRows.push({
|
||||||
|
|
@ -234,6 +312,8 @@ export function ResponsiveGridRenderer({
|
||||||
mainComponent: fwComp,
|
mainComponent: fwComp,
|
||||||
overlayComps: normalComps,
|
overlayComps: normalComps,
|
||||||
normalComps: [],
|
normalComps: [],
|
||||||
|
rowMinY,
|
||||||
|
rowMaxBottom,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (fullWidthComps.length > 0) {
|
} else if (fullWidthComps.length > 0) {
|
||||||
|
|
@ -243,6 +323,8 @@ export function ResponsiveGridRenderer({
|
||||||
mainComponent: fwComp,
|
mainComponent: fwComp,
|
||||||
overlayComps: [],
|
overlayComps: [],
|
||||||
normalComps: [],
|
normalComps: [],
|
||||||
|
rowMinY,
|
||||||
|
rowMaxBottom,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -250,6 +332,8 @@ export function ResponsiveGridRenderer({
|
||||||
type: "normal",
|
type: "normal",
|
||||||
overlayComps: [],
|
overlayComps: [],
|
||||||
normalComps,
|
normalComps,
|
||||||
|
rowMinY,
|
||||||
|
rowMaxBottom,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -261,21 +345,71 @@ export function ResponsiveGridRenderer({
|
||||||
style={{ minHeight: "200px" }}
|
style={{ minHeight: "200px" }}
|
||||||
>
|
>
|
||||||
{processedRows.map((processedRow, rowIndex) => {
|
{processedRows.map((processedRow, rowIndex) => {
|
||||||
|
const rowMarginTop = (() => {
|
||||||
|
if (rowIndex === 0) return 0;
|
||||||
|
const prevRow = processedRows[rowIndex - 1];
|
||||||
|
const prevBottom = prevRow.rowMaxBottom ?? 0;
|
||||||
|
const currTop = processedRow.rowMinY ?? 0;
|
||||||
|
const designGap = currTop - prevBottom;
|
||||||
|
if (designGap <= 0) return 0;
|
||||||
|
return Math.min(Math.max(Math.round(designGap * 0.5), 4), 48);
|
||||||
|
})();
|
||||||
|
|
||||||
if (processedRow.type === "fullwidth" && processedRow.mainComponent) {
|
if (processedRow.type === "fullwidth" && processedRow.mainComponent) {
|
||||||
return (
|
return (
|
||||||
<FullWidthOverlayRow
|
<div key={`row-${rowIndex}`} style={{ marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}>
|
||||||
key={`row-${rowIndex}`}
|
<FullWidthOverlayRow
|
||||||
main={processedRow.mainComponent}
|
main={processedRow.mainComponent}
|
||||||
overlayComps={processedRow.overlayComps}
|
overlayComps={processedRow.overlayComps}
|
||||||
canvasWidth={canvasWidth}
|
canvasWidth={canvasWidth}
|
||||||
renderComponent={renderComponent}
|
renderComponent={renderComponent}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { normalComps } = processedRow;
|
const { normalComps } = processedRow;
|
||||||
const allButtons = normalComps.every((c) => isButtonComponent(c));
|
const allButtons = normalComps.every((c) => isButtonComponent(c));
|
||||||
const gap = isMobile ? 8 : allButtons ? 8 : getRowGap(normalComps, canvasWidth);
|
|
||||||
|
// 데스크톱에서 버튼만 있는 행: 디자이너의 x, width를 비율로 적용
|
||||||
|
if (allButtons && normalComps.length > 0 && !isMobile) {
|
||||||
|
const rowHeight = Math.max(...normalComps.map(c => c.size?.height || 40));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`row-${rowIndex}`}
|
||||||
|
className="relative w-full flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
height: `${rowHeight}px`,
|
||||||
|
marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{normalComps.map((component) => {
|
||||||
|
const typeId = getComponentTypeId(component);
|
||||||
|
const leftPct = (component.position.x / canvasWidth) * 100;
|
||||||
|
const widthPct = ((component.size?.width || 90) / canvasWidth) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={component.id}
|
||||||
|
data-component-id={component.id}
|
||||||
|
data-component-type={typeId}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: `${leftPct}%`,
|
||||||
|
width: `${widthPct}%`,
|
||||||
|
height: `${component.size?.height || 40}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderComponent(component)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = isMobile ? 8 : getRowGap(normalComps, canvasWidth);
|
||||||
|
|
||||||
const hasFlexHeightComp = normalComps.some((c) => {
|
const hasFlexHeightComp = normalComps.some((c) => {
|
||||||
const h = c.size?.height || 0;
|
const h = c.size?.height || 0;
|
||||||
|
|
@ -287,10 +421,9 @@ export function ResponsiveGridRenderer({
|
||||||
key={`row-${rowIndex}`}
|
key={`row-${rowIndex}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full flex-wrap overflow-hidden",
|
"flex w-full flex-wrap overflow-hidden",
|
||||||
allButtons && "justify-end px-2 py-1",
|
|
||||||
hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0"
|
hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0"
|
||||||
)}
|
)}
|
||||||
style={{ gap: `${gap}px` }}
|
style={{ gap: `${gap}px`, marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}
|
||||||
>
|
>
|
||||||
{normalComps.map((component) => {
|
{normalComps.map((component) => {
|
||||||
const typeId = getComponentTypeId(component);
|
const typeId = getComponentTypeId(component);
|
||||||
|
|
@ -334,13 +467,13 @@ export function ResponsiveGridRenderer({
|
||||||
style={{
|
style={{
|
||||||
width: isFullWidth ? "100%" : undefined,
|
width: isFullWidth ? "100%" : undefined,
|
||||||
flexBasis: useFlexHeight ? undefined : flexBasis,
|
flexBasis: useFlexHeight ? undefined : flexBasis,
|
||||||
flexGrow: 1,
|
flexGrow: percentWidth,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minWidth: isMobile ? "100%" : undefined,
|
minWidth: isMobile ? "100%" : undefined,
|
||||||
minHeight: useFlexHeight ? "300px" : undefined,
|
minHeight: useFlexHeight ? "300px" : (component.size?.height
|
||||||
height: useFlexHeight ? "100%" : (component.size?.height
|
|
||||||
? `${component.size.height}px`
|
? `${component.size.height}px`
|
||||||
: "auto"),
|
: undefined),
|
||||||
|
height: useFlexHeight ? "100%" : "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderComponent(component)}
|
{renderComponent(component)}
|
||||||
|
|
|
||||||
|
|
@ -2870,9 +2870,190 @@ export default function ScreenDesigner({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 탭 컨테이너 내부 드롭 처리 (중첩 구조 지원)
|
// 🎯 컨테이너 드롭 우선순위: 가장 안쪽(innermost) 컨테이너 우선
|
||||||
|
// 분할패널과 탭 둘 다 감지될 경우, DOM 트리에서 더 가까운 쪽을 우선 처리
|
||||||
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
|
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
|
||||||
if (tabsContainer) {
|
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
||||||
|
|
||||||
|
// 분할패널이 탭보다 안쪽에 있으면 분할패널 우선 처리
|
||||||
|
const splitPanelFirst =
|
||||||
|
splitPanelContainer &&
|
||||||
|
(!tabsContainer || tabsContainer.contains(splitPanelContainer));
|
||||||
|
|
||||||
|
if (splitPanelFirst && splitPanelContainer) {
|
||||||
|
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||||
|
const panelSide = splitPanelContainer.getAttribute("data-panel-side");
|
||||||
|
if (containerId && panelSide) {
|
||||||
|
// 분할 패널을 최상위 또는 중첩(탭 안)에서 찾기
|
||||||
|
let targetComponent: any = layout.components.find((c) => c.id === containerId);
|
||||||
|
let parentTabsId: string | null = null;
|
||||||
|
let parentTabId: string | null = null;
|
||||||
|
let parentSplitId: string | null = null;
|
||||||
|
let parentSplitSide: string | null = null;
|
||||||
|
|
||||||
|
if (!targetComponent) {
|
||||||
|
// 탭 안에 중첩된 분할패널 찾기
|
||||||
|
// top-level: overrides.type / overrides.tabs
|
||||||
|
// nested: componentType / componentConfig.tabs
|
||||||
|
for (const comp of layout.components) {
|
||||||
|
const compType = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||||
|
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||||
|
|
||||||
|
if (compType === "tabs-widget" || compType === "v2-tabs-widget") {
|
||||||
|
const tabs = compConfig.tabs || [];
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||||
|
if (found) {
|
||||||
|
targetComponent = found;
|
||||||
|
parentTabsId = comp.id;
|
||||||
|
parentTabId = tab.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") {
|
||||||
|
for (const side of ["leftPanel", "rightPanel"] as const) {
|
||||||
|
const panelComps = compConfig[side]?.components || [];
|
||||||
|
for (const pc of panelComps) {
|
||||||
|
const pct = pc.componentType || pc.overrides?.type;
|
||||||
|
if (pct === "tabs-widget" || pct === "v2-tabs-widget") {
|
||||||
|
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||||
|
if (found) {
|
||||||
|
targetComponent = found;
|
||||||
|
parentSplitId = comp.id;
|
||||||
|
parentSplitSide = side === "leftPanel" ? "left" : "right";
|
||||||
|
parentTabsId = pc.id;
|
||||||
|
parentTabId = tab.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const compType = (targetComponent as any)?.componentType;
|
||||||
|
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
|
||||||
|
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||||
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
|
const currentComponents = panelConfig.components || [];
|
||||||
|
|
||||||
|
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||||
|
const cs1 = window.getComputedStyle(splitPanelContainer);
|
||||||
|
const dropX = (e.clientX - panelRect.left - (parseFloat(cs1.paddingLeft) || 0)) / zoomLevel;
|
||||||
|
const dropY = (e.clientY - panelRect.top - (parseFloat(cs1.paddingTop) || 0)) / zoomLevel;
|
||||||
|
|
||||||
|
const componentType = component.id || component.componentType || "v2-text-display";
|
||||||
|
|
||||||
|
const newPanelComponent = {
|
||||||
|
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
componentType: componentType,
|
||||||
|
label: component.name || component.label || "새 컴포넌트",
|
||||||
|
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
|
||||||
|
size: component.defaultSize || { width: 200, height: 100 },
|
||||||
|
componentConfig: component.defaultConfig || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedPanelConfig = {
|
||||||
|
...panelConfig,
|
||||||
|
components: [...currentComponents, newPanelComponent],
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedSplitPanel = {
|
||||||
|
...targetComponent,
|
||||||
|
componentConfig: {
|
||||||
|
...currentConfig,
|
||||||
|
[panelKey]: updatedPanelConfig,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let newLayout;
|
||||||
|
if (parentTabsId && parentTabId) {
|
||||||
|
// 중첩: (최상위 분할패널 →) 탭 → 분할패널
|
||||||
|
const updateTabsComponent = (tabsComp: any) => {
|
||||||
|
const ck = tabsComp.componentConfig ? "componentConfig" : "overrides";
|
||||||
|
const cfg = tabsComp[ck] || {};
|
||||||
|
const tabs = cfg.tabs || [];
|
||||||
|
return {
|
||||||
|
...tabsComp,
|
||||||
|
[ck]: {
|
||||||
|
...cfg,
|
||||||
|
tabs: tabs.map((tab: any) =>
|
||||||
|
tab.id === parentTabId
|
||||||
|
? {
|
||||||
|
...tab,
|
||||||
|
components: (tab.components || []).map((c: any) =>
|
||||||
|
c.id === containerId ? updatedSplitPanel : c,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: tab,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parentSplitId && parentSplitSide) {
|
||||||
|
// 최상위 분할패널 → 탭 → 분할패널
|
||||||
|
const pKey = parentSplitSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c) => {
|
||||||
|
if (c.id === parentSplitId) {
|
||||||
|
const sc = (c as any).componentConfig || {};
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
componentConfig: {
|
||||||
|
...sc,
|
||||||
|
[pKey]: {
|
||||||
|
...sc[pKey],
|
||||||
|
components: (sc[pKey]?.components || []).map((pc: any) =>
|
||||||
|
pc.id === parentTabsId ? updateTabsComponent(pc) : pc,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 최상위 탭 → 분할패널
|
||||||
|
newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c) =>
|
||||||
|
c.id === parentTabsId ? updateTabsComponent(c) : c,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 최상위 분할패널
|
||||||
|
newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c) => (c.id === containerId ? updatedSplitPanel : c)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setLayout(newLayout);
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabsContainer && !splitPanelFirst) {
|
||||||
const containerId = tabsContainer.getAttribute("data-component-id");
|
const containerId = tabsContainer.getAttribute("data-component-id");
|
||||||
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
||||||
if (containerId && activeTabId) {
|
if (containerId && activeTabId) {
|
||||||
|
|
@ -3013,69 +3194,6 @@ export default function ScreenDesigner({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 분할 패널 커스텀 모드 컨테이너 내부 드롭 처리
|
|
||||||
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
|
||||||
if (splitPanelContainer) {
|
|
||||||
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
|
||||||
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
|
|
||||||
if (containerId && panelSide) {
|
|
||||||
const targetComponent = layout.components.find((c) => c.id === containerId);
|
|
||||||
const compType = (targetComponent as any)?.componentType;
|
|
||||||
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
|
|
||||||
const currentConfig = (targetComponent as any).componentConfig || {};
|
|
||||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
|
||||||
const panelConfig = currentConfig[panelKey] || {};
|
|
||||||
const currentComponents = panelConfig.components || [];
|
|
||||||
|
|
||||||
// 드롭 위치 계산
|
|
||||||
const panelRect = splitPanelContainer.getBoundingClientRect();
|
|
||||||
const dropX = (e.clientX - panelRect.left) / zoomLevel;
|
|
||||||
const dropY = (e.clientY - panelRect.top) / zoomLevel;
|
|
||||||
|
|
||||||
// 새 컴포넌트 생성
|
|
||||||
const componentType = component.id || component.componentType || "v2-text-display";
|
|
||||||
|
|
||||||
console.log("🎯 분할 패널에 컴포넌트 드롭:", {
|
|
||||||
componentId: component.id,
|
|
||||||
componentType: componentType,
|
|
||||||
panelSide: panelSide,
|
|
||||||
dropPosition: { x: dropX, y: dropY },
|
|
||||||
});
|
|
||||||
|
|
||||||
const newPanelComponent = {
|
|
||||||
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
||||||
componentType: componentType,
|
|
||||||
label: component.name || component.label || "새 컴포넌트",
|
|
||||||
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
|
|
||||||
size: component.defaultSize || { width: 200, height: 100 },
|
|
||||||
componentConfig: component.defaultConfig || {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedPanelConfig = {
|
|
||||||
...panelConfig,
|
|
||||||
components: [...currentComponents, newPanelComponent],
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedComponent = {
|
|
||||||
...targetComponent,
|
|
||||||
componentConfig: {
|
|
||||||
...currentConfig,
|
|
||||||
[panelKey]: updatedPanelConfig,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const newLayout = {
|
|
||||||
...layout,
|
|
||||||
components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)),
|
|
||||||
};
|
|
||||||
|
|
||||||
setLayout(newLayout);
|
|
||||||
saveToHistory(newLayout);
|
|
||||||
toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
|
|
||||||
return; // 분할 패널 처리 완료
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
@ -3387,15 +3505,12 @@ export default function ScreenDesigner({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const dragData = e.dataTransfer.getData("application/json");
|
const dragData = e.dataTransfer.getData("application/json");
|
||||||
// console.log("🎯 드롭 이벤트:", { dragData });
|
|
||||||
if (!dragData) {
|
if (!dragData) {
|
||||||
// console.log("❌ 드래그 데이터가 없습니다");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsedData = JSON.parse(dragData);
|
const parsedData = JSON.parse(dragData);
|
||||||
// console.log("📋 파싱된 데이터:", parsedData);
|
|
||||||
|
|
||||||
// 템플릿 드래그인 경우
|
// 템플릿 드래그인 경우
|
||||||
if (parsedData.type === "template") {
|
if (parsedData.type === "template") {
|
||||||
|
|
@ -3489,9 +3604,225 @@ export default function ScreenDesigner({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원)
|
// 🎯 컨테이너 감지: innermost 우선 (분할패널 > 탭)
|
||||||
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
|
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
|
||||||
if (tabsContainer && type === "column" && column) {
|
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
||||||
|
|
||||||
|
// 분할패널이 탭 안에 있으면 분할패널이 innermost → 분할패널 우선
|
||||||
|
const splitPanelFirst =
|
||||||
|
splitPanelContainer &&
|
||||||
|
(!tabsContainer || tabsContainer.contains(splitPanelContainer));
|
||||||
|
|
||||||
|
// 🎯 분할패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리 (우선 처리)
|
||||||
|
if (splitPanelFirst && splitPanelContainer && type === "column" && column) {
|
||||||
|
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||||
|
let panelSide = splitPanelContainer.getAttribute("data-panel-side");
|
||||||
|
|
||||||
|
// panelSide가 없으면 드롭 좌표와 splitRatio로 좌/우 판별
|
||||||
|
if (!panelSide) {
|
||||||
|
const splitRatio = parseInt(splitPanelContainer.getAttribute("data-split-ratio") || "40", 10);
|
||||||
|
const containerRect = splitPanelContainer.getBoundingClientRect();
|
||||||
|
const relativeX = e.clientX - containerRect.left;
|
||||||
|
const splitPoint = containerRect.width * (splitRatio / 100);
|
||||||
|
panelSide = relativeX < splitPoint ? "left" : "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerId && panelSide) {
|
||||||
|
// 최상위에서 찾기
|
||||||
|
let targetComponent: any = layout.components.find((c) => c.id === containerId);
|
||||||
|
let parentTabsId: string | null = null;
|
||||||
|
let parentTabId: string | null = null;
|
||||||
|
let parentSplitId: string | null = null;
|
||||||
|
let parentSplitSide: string | null = null;
|
||||||
|
|
||||||
|
if (!targetComponent) {
|
||||||
|
// 탭 안 중첩 분할패널 찾기
|
||||||
|
// top-level 컴포넌트: overrides.type / overrides.tabs
|
||||||
|
// nested 컴포넌트: componentType / componentConfig.tabs
|
||||||
|
for (const comp of layout.components) {
|
||||||
|
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||||
|
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||||
|
|
||||||
|
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||||
|
const tabs = compConfig.tabs || [];
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||||
|
if (found) {
|
||||||
|
targetComponent = found;
|
||||||
|
parentTabsId = comp.id;
|
||||||
|
parentTabId = tab.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
// 분할패널 → 탭 → 분할패널 중첩
|
||||||
|
if (ct === "split-panel-layout" || ct === "v2-split-panel-layout") {
|
||||||
|
for (const side of ["leftPanel", "rightPanel"] as const) {
|
||||||
|
const panelComps = compConfig[side]?.components || [];
|
||||||
|
for (const pc of panelComps) {
|
||||||
|
const pct = pc.componentType || pc.overrides?.type;
|
||||||
|
if (pct === "tabs-widget" || pct === "v2-tabs-widget") {
|
||||||
|
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||||
|
if (found) {
|
||||||
|
targetComponent = found;
|
||||||
|
parentSplitId = comp.id;
|
||||||
|
parentSplitSide = side === "leftPanel" ? "left" : "right";
|
||||||
|
parentTabsId = pc.id;
|
||||||
|
parentTabId = tab.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compType = (targetComponent as any)?.componentType;
|
||||||
|
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
|
||||||
|
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||||
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
|
const currentComponents = panelConfig.components || [];
|
||||||
|
|
||||||
|
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||||
|
const computedStyle = window.getComputedStyle(splitPanelContainer);
|
||||||
|
const padLeft = parseFloat(computedStyle.paddingLeft) || 0;
|
||||||
|
const padTop = parseFloat(computedStyle.paddingTop) || 0;
|
||||||
|
const dropX = (e.clientX - panelRect.left - padLeft) / zoomLevel;
|
||||||
|
const dropY = (e.clientY - panelRect.top - padTop) / zoomLevel;
|
||||||
|
|
||||||
|
const v2Mapping = createV2ConfigFromColumn({
|
||||||
|
widgetType: column.widgetType,
|
||||||
|
columnName: column.columnName,
|
||||||
|
columnLabel: column.columnLabel,
|
||||||
|
codeCategory: column.codeCategory,
|
||||||
|
inputType: column.inputType,
|
||||||
|
required: column.required,
|
||||||
|
detailSettings: column.detailSettings,
|
||||||
|
referenceTable: column.referenceTable,
|
||||||
|
referenceColumn: column.referenceColumn,
|
||||||
|
displayColumn: column.displayColumn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newPanelComponent = {
|
||||||
|
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
componentType: v2Mapping.componentType,
|
||||||
|
label: column.columnLabel || column.columnName,
|
||||||
|
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
|
||||||
|
size: { width: 200, height: 36 },
|
||||||
|
inputType: column.inputType || column.widgetType,
|
||||||
|
widgetType: column.widgetType,
|
||||||
|
componentConfig: {
|
||||||
|
...v2Mapping.componentConfig,
|
||||||
|
columnName: column.columnName,
|
||||||
|
tableName: column.tableName,
|
||||||
|
inputType: column.inputType || column.widgetType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedSplitPanel = {
|
||||||
|
...targetComponent,
|
||||||
|
componentConfig: {
|
||||||
|
...currentConfig,
|
||||||
|
[panelKey]: {
|
||||||
|
...panelConfig,
|
||||||
|
displayMode: "custom",
|
||||||
|
components: [...currentComponents, newPanelComponent],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let newLayout;
|
||||||
|
|
||||||
|
if (parentSplitId && parentSplitSide && parentTabsId && parentTabId) {
|
||||||
|
// 분할패널 → 탭 → 분할패널 3중 중첩
|
||||||
|
newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c) => {
|
||||||
|
if (c.id !== parentSplitId) return c;
|
||||||
|
const sc = (c as any).componentConfig || {};
|
||||||
|
const pk = parentSplitSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
componentConfig: {
|
||||||
|
...sc,
|
||||||
|
[pk]: {
|
||||||
|
...sc[pk],
|
||||||
|
components: (sc[pk]?.components || []).map((pc: any) => {
|
||||||
|
if (pc.id !== parentTabsId) return pc;
|
||||||
|
return {
|
||||||
|
...pc,
|
||||||
|
componentConfig: {
|
||||||
|
...pc.componentConfig,
|
||||||
|
tabs: (pc.componentConfig?.tabs || []).map((tab: any) => {
|
||||||
|
if (tab.id !== parentTabId) return tab;
|
||||||
|
return {
|
||||||
|
...tab,
|
||||||
|
components: (tab.components || []).map((tc: any) =>
|
||||||
|
tc.id === containerId ? updatedSplitPanel : tc,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} else if (parentTabsId && parentTabId) {
|
||||||
|
// 탭 → 분할패널 2중 중첩
|
||||||
|
newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c) => {
|
||||||
|
if (c.id !== parentTabsId) return c;
|
||||||
|
// top-level은 overrides, nested는 componentConfig
|
||||||
|
const configKey = (c as any).componentConfig ? "componentConfig" : "overrides";
|
||||||
|
const tabsConfig = (c as any)[configKey] || {};
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
[configKey]: {
|
||||||
|
...tabsConfig,
|
||||||
|
tabs: (tabsConfig.tabs || []).map((tab: any) => {
|
||||||
|
if (tab.id !== parentTabId) return tab;
|
||||||
|
return {
|
||||||
|
...tab,
|
||||||
|
components: (tab.components || []).map((tc: any) =>
|
||||||
|
tc.id === containerId ? updatedSplitPanel : tc,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 최상위 분할패널
|
||||||
|
newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c) => (c.id === containerId ? updatedSplitPanel : c)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("컬럼이 분할패널에 추가되었습니다");
|
||||||
|
setLayout(newLayout);
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원)
|
||||||
|
if (tabsContainer && !splitPanelFirst && type === "column" && column) {
|
||||||
const containerId = tabsContainer.getAttribute("data-component-id");
|
const containerId = tabsContainer.getAttribute("data-component-id");
|
||||||
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
||||||
if (containerId && activeTabId) {
|
if (containerId && activeTabId) {
|
||||||
|
|
@ -3657,9 +3988,8 @@ export default function ScreenDesigner({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리
|
// 🎯 분할 패널 커스텀 모드 (탭 밖 최상위) 컬럼 드롭 처리
|
||||||
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
if (splitPanelContainer && !splitPanelFirst && type === "column" && column) {
|
||||||
if (splitPanelContainer && type === "column" && column) {
|
|
||||||
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||||
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
|
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
|
||||||
if (containerId && panelSide) {
|
if (containerId && panelSide) {
|
||||||
|
|
@ -3671,12 +4001,11 @@ export default function ScreenDesigner({
|
||||||
const panelConfig = currentConfig[panelKey] || {};
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
const currentComponents = panelConfig.components || [];
|
const currentComponents = panelConfig.components || [];
|
||||||
|
|
||||||
// 드롭 위치 계산
|
|
||||||
const panelRect = splitPanelContainer.getBoundingClientRect();
|
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||||
const dropX = (e.clientX - panelRect.left) / zoomLevel;
|
const cs2 = window.getComputedStyle(splitPanelContainer);
|
||||||
const dropY = (e.clientY - panelRect.top) / zoomLevel;
|
const dropX = (e.clientX - panelRect.left - (parseFloat(cs2.paddingLeft) || 0)) / zoomLevel;
|
||||||
|
const dropY = (e.clientY - panelRect.top - (parseFloat(cs2.paddingTop) || 0)) / zoomLevel;
|
||||||
|
|
||||||
// V2 컴포넌트 매핑 사용
|
|
||||||
const v2Mapping = createV2ConfigFromColumn({
|
const v2Mapping = createV2ConfigFromColumn({
|
||||||
widgetType: column.widgetType,
|
widgetType: column.widgetType,
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
|
|
@ -6424,15 +6753,6 @@ export default function ScreenDesigner({
|
||||||
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
|
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
|
||||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
|
||||||
console.log("🔧 updatePanelComponentProperty 호출:", {
|
|
||||||
componentId,
|
|
||||||
path,
|
|
||||||
value,
|
|
||||||
splitPanelId,
|
|
||||||
panelSide,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🆕 안전한 깊은 경로 업데이트 헬퍼 함수
|
|
||||||
const setNestedValue = (obj: any, pathStr: string, val: any): any => {
|
const setNestedValue = (obj: any, pathStr: string, val: any): any => {
|
||||||
const result = JSON.parse(JSON.stringify(obj));
|
const result = JSON.parse(JSON.stringify(obj));
|
||||||
const parts = pathStr.split(".");
|
const parts = pathStr.split(".");
|
||||||
|
|
@ -6449,9 +6769,27 @@ export default function ScreenDesigner({
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 중첩 구조 포함 분할패널 찾기 헬퍼
|
||||||
|
const findSplitPanelInLayout = (components: any[]): { found: any; path: "top" | "nested"; parentTabId?: string; parentTabTabId?: string } | null => {
|
||||||
|
const direct = components.find((c) => c.id === splitPanelId);
|
||||||
|
if (direct) return { found: direct, path: "top" };
|
||||||
|
for (const comp of components) {
|
||||||
|
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||||
|
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||||
|
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||||
|
for (const tab of (cfg.tabs || [])) {
|
||||||
|
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
|
||||||
|
if (nested) return { found: nested, path: "nested", parentTabId: comp.id, parentTabTabId: tab.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
setLayout((prevLayout) => {
|
setLayout((prevLayout) => {
|
||||||
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
|
const result = findSplitPanelInLayout(prevLayout.components);
|
||||||
if (!splitPanelComponent) return prevLayout;
|
if (!result) return prevLayout;
|
||||||
|
const splitPanelComponent = result.found;
|
||||||
|
|
||||||
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
||||||
const panelConfig = currentConfig[panelKey] || {};
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
|
|
@ -6487,17 +6825,37 @@ export default function ScreenDesigner({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// selectedPanelComponentInfo 업데이트
|
|
||||||
setSelectedPanelComponentInfo((prev) =>
|
setSelectedPanelComponentInfo((prev) =>
|
||||||
prev ? { ...prev, component: updatedComp } : null,
|
prev ? { ...prev, component: updatedComp } : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
// 중첩 구조 반영
|
||||||
...prevLayout,
|
const applyUpdatedSplitPanel = (layout: any, updated: any, info: any) => {
|
||||||
components: prevLayout.components.map((c) =>
|
if (info.path === "top") {
|
||||||
c.id === splitPanelId ? updatedComponent : c,
|
return { ...layout, components: layout.components.map((c: any) => c.id === splitPanelId ? updated : c) };
|
||||||
),
|
}
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c: any) => {
|
||||||
|
if (c.id !== info.parentTabId) return c;
|
||||||
|
const cfgKey = c.componentConfig?.tabs ? "componentConfig" : "overrides";
|
||||||
|
const cfg = c[cfgKey] || {};
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
[cfgKey]: {
|
||||||
|
...cfg,
|
||||||
|
tabs: (cfg.tabs || []).map((t: any) =>
|
||||||
|
t.id === info.parentTabTabId
|
||||||
|
? { ...t, components: (t.components || []).map((tc: any) => tc.id === splitPanelId ? updated : tc) }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return applyUpdatedSplitPanel(prevLayout, updatedComponent, result);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -6507,8 +6865,23 @@ export default function ScreenDesigner({
|
||||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
|
||||||
setLayout((prevLayout) => {
|
setLayout((prevLayout) => {
|
||||||
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
|
const findResult = (() => {
|
||||||
if (!splitPanelComponent) return prevLayout;
|
const direct = prevLayout.components.find((c: any) => c.id === splitPanelId);
|
||||||
|
if (direct) return { found: direct, path: "top" as const };
|
||||||
|
for (const comp of prevLayout.components) {
|
||||||
|
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||||
|
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||||
|
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||||
|
for (const tab of (cfg.tabs || [])) {
|
||||||
|
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
|
||||||
|
if (nested) return { found: nested, path: "nested" as const, parentTabId: comp.id, parentTabTabId: tab.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
if (!findResult) return prevLayout;
|
||||||
|
const splitPanelComponent = findResult.found;
|
||||||
|
|
||||||
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
||||||
const panelConfig = currentConfig[panelKey] || {};
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
|
|
@ -6529,11 +6902,27 @@ export default function ScreenDesigner({
|
||||||
|
|
||||||
setSelectedPanelComponentInfo(null);
|
setSelectedPanelComponentInfo(null);
|
||||||
|
|
||||||
|
if (findResult.path === "top") {
|
||||||
|
return { ...prevLayout, components: prevLayout.components.map((c: any) => c.id === splitPanelId ? updatedComponent : c) };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...prevLayout,
|
...prevLayout,
|
||||||
components: prevLayout.components.map((c) =>
|
components: prevLayout.components.map((c: any) => {
|
||||||
c.id === splitPanelId ? updatedComponent : c,
|
if (c.id !== findResult.parentTabId) return c;
|
||||||
),
|
const cfgKey = c.componentConfig?.tabs ? "componentConfig" : "overrides";
|
||||||
|
const cfg = c[cfgKey] || {};
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
[cfgKey]: {
|
||||||
|
...cfg,
|
||||||
|
tabs: (cfg.tabs || []).map((t: any) =>
|
||||||
|
t.id === findResult.parentTabTabId
|
||||||
|
? { ...t, components: (t.components || []).map((tc: any) => tc.id === splitPanelId ? updatedComponent : tc) }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -7137,6 +7526,7 @@ export default function ScreenDesigner({
|
||||||
onSelectPanelComponent={(panelSide, compId, comp) =>
|
onSelectPanelComponent={(panelSide, compId, comp) =>
|
||||||
handleSelectPanelComponent(component.id, panelSide, compId, comp)
|
handleSelectPanelComponent(component.id, panelSide, compId, comp)
|
||||||
}
|
}
|
||||||
|
onNestedPanelSelect={handleSelectPanelComponent}
|
||||||
selectedPanelComponentId={
|
selectedPanelComponentId={
|
||||||
selectedPanelComponentInfo?.splitPanelId === component.id
|
selectedPanelComponentInfo?.splitPanelId === component.id
|
||||||
? selectedPanelComponentInfo.componentId
|
? selectedPanelComponentInfo.componentId
|
||||||
|
|
|
||||||
|
|
@ -541,8 +541,31 @@ export function DataFilterConfigPanel({
|
||||||
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
|
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
|
||||||
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
|
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
|
||||||
<Select
|
<Select
|
||||||
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
|
value={
|
||||||
onValueChange={(value) => handleFilterChange(filter.id, "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">
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
<SelectValue
|
<SelectValue
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,62 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
selectedComponent.componentConfig?.id ||
|
selectedComponent.componentConfig?.id ||
|
||||||
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
|
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
|
||||||
|
|
||||||
|
// 🆕 V2 컴포넌트 직접 감지 및 설정 패널 렌더링
|
||||||
|
if (componentId?.startsWith("v2-")) {
|
||||||
|
const v2ConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = {
|
||||||
|
"v2-input": require("@/components/v2/config-panels/V2FieldConfigPanel").V2FieldConfigPanel,
|
||||||
|
"v2-select": require("@/components/v2/config-panels/V2FieldConfigPanel").V2FieldConfigPanel,
|
||||||
|
"v2-date": require("@/components/v2/config-panels/V2DateConfigPanel").V2DateConfigPanel,
|
||||||
|
"v2-list": require("@/components/v2/config-panels/V2ListConfigPanel").V2ListConfigPanel,
|
||||||
|
"v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel").V2LayoutConfigPanel,
|
||||||
|
"v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel,
|
||||||
|
"v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel,
|
||||||
|
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
|
||||||
|
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
|
||||||
|
"v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel")
|
||||||
|
.V2BomItemEditorConfigPanel,
|
||||||
|
"v2-bom-tree": require("@/components/v2/config-panels/V2BomTreeConfigPanel").V2BomTreeConfigPanel,
|
||||||
|
};
|
||||||
|
|
||||||
|
const V2ConfigPanel = v2ConfigPanels[componentId];
|
||||||
|
if (V2ConfigPanel) {
|
||||||
|
const currentConfig = selectedComponent.componentConfig || {};
|
||||||
|
const handleV2ConfigChange = (newConfig: any) => {
|
||||||
|
onUpdateProperty(selectedComponent.id, "componentConfig", { ...currentConfig, ...newConfig });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼의 inputType 가져오기 (entity 타입인지 확인용)
|
||||||
|
const inputType = currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType;
|
||||||
|
|
||||||
|
// 현재 화면의 테이블명 가져오기
|
||||||
|
const currentTableName = tables?.[0]?.tableName;
|
||||||
|
|
||||||
|
// 컴포넌트별 추가 props
|
||||||
|
const extraProps: Record<string, any> = {};
|
||||||
|
if (componentId === "v2-select") {
|
||||||
|
extraProps.inputType = inputType;
|
||||||
|
extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||||
|
extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
|
||||||
|
}
|
||||||
|
if (componentId === "v2-list") {
|
||||||
|
extraProps.currentTableName = currentTableName;
|
||||||
|
}
|
||||||
|
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
|
||||||
|
extraProps.currentTableName = currentTableName;
|
||||||
|
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||||
|
}
|
||||||
|
if (componentId === "v2-input") {
|
||||||
|
extraProps.allComponents = allComponents;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={selectedComponent.id} className="space-y-4">
|
||||||
|
<V2ConfigPanel config={currentConfig} onChange={handleV2ConfigChange} {...extraProps} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (componentId) {
|
if (componentId) {
|
||||||
const definition = ComponentRegistry.getComponent(componentId);
|
const definition = ComponentRegistry.getComponent(componentId);
|
||||||
|
|
||||||
|
|
@ -219,7 +275,9 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
allTables={allTables}
|
allTables={allTables}
|
||||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
||||||
tableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
tableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
||||||
columnName={(selectedComponent as any).columnName || currentConfig?.columnName || currentConfig?.fieldName}
|
columnName={
|
||||||
|
(selectedComponent as any).columnName || currentConfig?.columnName || currentConfig?.fieldName
|
||||||
|
}
|
||||||
inputType={(selectedComponent as any).inputType || currentConfig?.inputType}
|
inputType={(selectedComponent as any).inputType || currentConfig?.inputType}
|
||||||
componentType={componentType}
|
componentType={componentType}
|
||||||
tableColumns={currentTable?.columns || []}
|
tableColumns={currentTable?.columns || []}
|
||||||
|
|
@ -334,11 +392,11 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{/* DIMENSIONS 섹션 */}
|
{/* DIMENSIONS 섹션 */}
|
||||||
<div className="border-b border-border/50 pb-3 mb-3">
|
<div className="border-border/50 mb-3 border-b pb-3">
|
||||||
<h4 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground py-2">DIMENSIONS</h4>
|
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">DIMENSIONS</h4>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-[10px] text-muted-foreground">너비</Label>
|
<Label className="text-muted-foreground text-[10px]">너비</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={10}
|
min={10}
|
||||||
|
|
@ -372,7 +430,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-[10px] text-muted-foreground">높이</Label>
|
<Label className="text-muted-foreground text-[10px]">높이</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={localHeight}
|
value={localHeight}
|
||||||
|
|
@ -404,7 +462,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-[10px] text-muted-foreground">Z-Index</Label>
|
<Label className="text-muted-foreground text-[10px]">Z-Index</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="1"
|
step="1"
|
||||||
|
|
@ -418,10 +476,10 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
|
|
||||||
{/* Title (group/area) */}
|
{/* Title (group/area) */}
|
||||||
{(selectedComponent.type === "group" || selectedComponent.type === "area") && (
|
{(selectedComponent.type === "group" || selectedComponent.type === "area") && (
|
||||||
<div className="border-b border-border/50 pb-3 mb-3">
|
<div className="border-border/50 mb-3 border-b pb-3">
|
||||||
<h4 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground py-2">CONTENT</h4>
|
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">CONTENT</h4>
|
||||||
<div className="flex items-center justify-between py-1.5">
|
<div className="flex items-center justify-between py-1.5">
|
||||||
<span className="text-xs text-muted-foreground">제목</span>
|
<span className="text-muted-foreground text-xs">제목</span>
|
||||||
<div className="w-[160px]">
|
<div className="w-[160px]">
|
||||||
<Input
|
<Input
|
||||||
value={group.title || area.title || ""}
|
value={group.title || area.title || ""}
|
||||||
|
|
@ -433,7 +491,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
{selectedComponent.type === "area" && (
|
{selectedComponent.type === "area" && (
|
||||||
<div className="flex items-center justify-between py-1.5">
|
<div className="flex items-center justify-between py-1.5">
|
||||||
<span className="text-xs text-muted-foreground">설명</span>
|
<span className="text-muted-foreground text-xs">설명</span>
|
||||||
<div className="w-[160px]">
|
<div className="w-[160px]">
|
||||||
<Input
|
<Input
|
||||||
value={area.description || ""}
|
value={area.description || ""}
|
||||||
|
|
@ -448,36 +506,46 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* OPTIONS 섹션 */}
|
{/* OPTIONS 섹션 */}
|
||||||
<div className="border-b border-border/50 pb-3 mb-3">
|
<div className="border-border/50 mb-3 border-b pb-3">
|
||||||
<h4 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground py-2">OPTIONS</h4>
|
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">OPTIONS</h4>
|
||||||
{(isInputField || widget.required !== undefined) && (() => {
|
{(isInputField || widget.required !== undefined) &&
|
||||||
const colName = widget.columnName || selectedComponent?.columnName;
|
(() => {
|
||||||
const colMeta = colName ? currentTable?.columns?.find(
|
const colName = widget.columnName || selectedComponent?.columnName;
|
||||||
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase()
|
const colMeta = colName
|
||||||
) : null;
|
? currentTable?.columns?.find(
|
||||||
const isNotNull = colMeta && ((colMeta as any).isNullable === "NO" || (colMeta as any).isNullable === "N" || (colMeta as any).is_nullable === "NO" || (colMeta as any).is_nullable === "N");
|
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase(),
|
||||||
return (
|
)
|
||||||
<div className="flex items-center justify-between py-1.5">
|
: null;
|
||||||
<span className="text-xs text-muted-foreground">
|
const isNotNull =
|
||||||
필수
|
colMeta &&
|
||||||
{isNotNull && <span className="text-muted-foreground/60 ml-1">(NOT NULL)</span>}
|
((colMeta as any).isNullable === "NO" ||
|
||||||
</span>
|
(colMeta as any).isNullable === "N" ||
|
||||||
<Checkbox
|
(colMeta as any).is_nullable === "NO" ||
|
||||||
checked={isNotNull || widget.required === true || selectedComponent.componentConfig?.required === true}
|
(colMeta as any).is_nullable === "N");
|
||||||
onCheckedChange={(checked) => {
|
return (
|
||||||
if (isNotNull) return;
|
<div className="flex items-center justify-between py-1.5">
|
||||||
handleUpdate("required", checked);
|
<span className="text-muted-foreground text-xs">
|
||||||
handleUpdate("componentConfig.required", checked);
|
필수
|
||||||
}}
|
{isNotNull && <span className="text-muted-foreground/60 ml-1">(NOT NULL)</span>}
|
||||||
disabled={!!isNotNull}
|
</span>
|
||||||
className="h-4 w-4"
|
<Checkbox
|
||||||
/>
|
checked={
|
||||||
</div>
|
isNotNull || widget.required === true || selectedComponent.componentConfig?.required === true
|
||||||
);
|
}
|
||||||
})()}
|
onCheckedChange={(checked) => {
|
||||||
|
if (isNotNull) return;
|
||||||
|
handleUpdate("required", checked);
|
||||||
|
handleUpdate("componentConfig.required", checked);
|
||||||
|
}}
|
||||||
|
disabled={!!isNotNull}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{(isInputField || widget.readonly !== undefined) && (
|
{(isInputField || widget.readonly !== undefined) && (
|
||||||
<div className="flex items-center justify-between py-1.5">
|
<div className="flex items-center justify-between py-1.5">
|
||||||
<span className="text-xs text-muted-foreground">읽기전용</span>
|
<span className="text-muted-foreground text-xs">읽기전용</span>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
|
checked={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
|
|
@ -489,7 +557,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between py-1.5">
|
<div className="flex items-center justify-between py-1.5">
|
||||||
<span className="text-xs text-muted-foreground">숨김</span>
|
<span className="text-muted-foreground text-xs">숨김</span>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedComponent.hidden === true || selectedComponent.componentConfig?.hidden === true}
|
checked={selectedComponent.hidden === true || selectedComponent.componentConfig?.hidden === true}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
|
|
@ -505,13 +573,13 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
{isInputField && (
|
{isInputField && (
|
||||||
<Collapsible>
|
<Collapsible>
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 text-left">
|
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 text-left">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">LABEL</span>
|
<span className="text-muted-foreground text-[10px] font-semibold tracking-wider uppercase">LABEL</span>
|
||||||
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground/50" />
|
<ChevronDown className="text-muted-foreground/50 h-3 w-3 shrink-0" />
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="mt-1.5 space-y-1">
|
<CollapsibleContent className="mt-1.5 space-y-1">
|
||||||
{/* 라벨 텍스트 */}
|
{/* 라벨 텍스트 */}
|
||||||
<div className="flex items-center justify-between py-1.5">
|
<div className="flex items-center justify-between py-1.5">
|
||||||
<span className="text-xs text-muted-foreground">텍스트</span>
|
<span className="text-muted-foreground text-xs">텍스트</span>
|
||||||
<div className="w-[160px]">
|
<div className="w-[160px]">
|
||||||
<Input
|
<Input
|
||||||
value={
|
value={
|
||||||
|
|
@ -531,7 +599,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
{/* 위치 + 간격 */}
|
{/* 위치 + 간격 */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-[10px] text-muted-foreground">위치</Label>
|
<Label className="text-muted-foreground text-[10px]">위치</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedComponent.style?.labelPosition || "top"}
|
value={selectedComponent.style?.labelPosition || "top"}
|
||||||
onValueChange={(value) => handleUpdate("style.labelPosition", value)}
|
onValueChange={(value) => handleUpdate("style.labelPosition", value)}
|
||||||
|
|
@ -548,12 +616,13 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-[10px] text-muted-foreground">간격</Label>
|
<Label className="text-muted-foreground text-[10px]">간격</Label>
|
||||||
<Input
|
<Input
|
||||||
value={
|
value={
|
||||||
(selectedComponent.style?.labelPosition === "left" || selectedComponent.style?.labelPosition === "right")
|
selectedComponent.style?.labelPosition === "left" ||
|
||||||
? (selectedComponent.style?.labelGap || "8px")
|
selectedComponent.style?.labelPosition === "right"
|
||||||
: (selectedComponent.style?.labelMarginBottom || "4px")
|
? selectedComponent.style?.labelGap || "8px"
|
||||||
|
: selectedComponent.style?.labelMarginBottom || "4px"
|
||||||
}
|
}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const pos = selectedComponent.style?.labelPosition;
|
const pos = selectedComponent.style?.labelPosition;
|
||||||
|
|
@ -570,7 +639,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
{/* 크기 + 색상 */}
|
{/* 크기 + 색상 */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-[10px] text-muted-foreground">크기</Label>
|
<Label className="text-muted-foreground text-[10px]">크기</Label>
|
||||||
<Input
|
<Input
|
||||||
value={selectedComponent.style?.labelFontSize || "12px"}
|
value={selectedComponent.style?.labelFontSize || "12px"}
|
||||||
onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)}
|
onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)}
|
||||||
|
|
@ -578,7 +647,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-[10px] text-muted-foreground">색상</Label>
|
<Label className="text-muted-foreground text-[10px]">색상</Label>
|
||||||
<ColorPickerWithTransparent
|
<ColorPickerWithTransparent
|
||||||
value={selectedComponent.style?.labelColor}
|
value={selectedComponent.style?.labelColor}
|
||||||
onChange={(value) => handleUpdate("style.labelColor", value)}
|
onChange={(value) => handleUpdate("style.labelColor", value)}
|
||||||
|
|
@ -589,7 +658,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
{/* 굵기 */}
|
{/* 굵기 */}
|
||||||
<div className="flex items-center justify-between py-1.5">
|
<div className="flex items-center justify-between py-1.5">
|
||||||
<span className="text-xs text-muted-foreground">굵기</span>
|
<span className="text-muted-foreground text-xs">굵기</span>
|
||||||
<div className="w-[160px]">
|
<div className="w-[160px]">
|
||||||
<Select
|
<Select
|
||||||
value={selectedComponent.style?.labelFontWeight || "500"}
|
value={selectedComponent.style?.labelFontWeight || "500"}
|
||||||
|
|
@ -609,7 +678,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
{/* 표시 */}
|
{/* 표시 */}
|
||||||
<div className="flex items-center justify-between py-1.5">
|
<div className="flex items-center justify-between py-1.5">
|
||||||
<span className="text-xs text-muted-foreground">표시</span>
|
<span className="text-muted-foreground text-xs">표시</span>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedComponent.style?.labelDisplay === true || selectedComponent.labelDisplay === true}
|
checked={selectedComponent.style?.labelDisplay === true || selectedComponent.labelDisplay === true}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
|
|
@ -965,7 +1034,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
|
|
||||||
{/* 🆕 테이블 데이터 자동 입력 (모든 widget 컴포넌트) */}
|
{/* 🆕 테이블 데이터 자동 입력 (모든 widget 컴포넌트) */}
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-3 border-4 border-destructive bg-amber-100 p-4">
|
<div className="border-destructive space-y-3 border-4 bg-amber-100 p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="text-primary h-4 w-4" />
|
<Database className="text-primary h-4 w-4" />
|
||||||
<h4 className="text-xs font-semibold">테이블 데이터 자동 입력</h4>
|
<h4 className="text-xs font-semibold">테이블 데이터 자동 입력</h4>
|
||||||
|
|
@ -1134,7 +1203,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
<Zap className="text-primary h-3 w-3" />
|
<Zap className="text-primary h-3 w-3" />
|
||||||
<h4 className="text-xs font-semibold">조건부 표시</h4>
|
<h4 className="text-xs font-semibold">조건부 표시</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border border-border p-2">
|
<div className="border-border rounded-md border p-2">
|
||||||
<ConditionalConfigPanel
|
<ConditionalConfigPanel
|
||||||
config={
|
config={
|
||||||
(selectedComponent as any).conditional || {
|
(selectedComponent as any).conditional || {
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
operator: "contains", // 기본 연산자
|
operator: "contains", // 기본 연산자
|
||||||
value: "",
|
value: "",
|
||||||
filterType: cf.filterType,
|
filterType: cf.filterType,
|
||||||
width: cf.width || 200, // 너비 포함 (기본 200px)
|
width: cf.width && cf.width >= 10 && cf.width <= 100 ? cf.width : 25,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// localStorage에 저장 (화면별로 독립적)
|
// localStorage에 저장 (화면별로 독립적)
|
||||||
|
|
@ -334,20 +334,20 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
{/* 너비 입력 */}
|
{/* 너비 입력 */}
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={filter.width || 200}
|
value={filter.width && filter.width >= 10 && filter.width <= 100 ? filter.width : 25}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newWidth = parseInt(e.target.value) || 200;
|
const newWidth = Math.min(100, Math.max(10, parseInt(e.target.value) || 25));
|
||||||
setColumnFilters((prev) =>
|
setColumnFilters((prev) =>
|
||||||
prev.map((f) => (f.columnName === filter.columnName ? { ...f, width: newWidth } : f)),
|
prev.map((f) => (f.columnName === filter.columnName ? { ...f, width: newWidth } : f)),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
disabled={!filter.enabled}
|
disabled={!filter.enabled}
|
||||||
placeholder="너비"
|
placeholder="25"
|
||||||
className="h-8 w-[80px] text-xs sm:h-9 sm:text-sm"
|
className="h-8 w-[80px] text-xs sm:h-9 sm:text-sm"
|
||||||
min={50}
|
min={10}
|
||||||
max={500}
|
max={100}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs">px</span>
|
<span className="text-muted-foreground text-xs">%</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -109,9 +109,8 @@ export const TableOptionsToolbar: React.FC = () => {
|
||||||
onOpenChange={setColumnPanelOpen}
|
onOpenChange={setColumnPanelOpen}
|
||||||
/>
|
/>
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
tableId={selectedTableId}
|
isOpen={filterPanelOpen}
|
||||||
open={filterPanelOpen}
|
onClose={() => setFilterPanelOpen(false)}
|
||||||
onOpenChange={setFilterPanelOpen}
|
|
||||||
/>
|
/>
|
||||||
<GroupingPanel
|
<GroupingPanel
|
||||||
tableId={selectedTableId}
|
tableId={selectedTableId}
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
||||||
inputType,
|
inputType,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
filterType,
|
filterType,
|
||||||
width: 200,
|
width: 25,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -271,7 +271,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
||||||
operator: "contains",
|
operator: "contains",
|
||||||
value: "",
|
value: "",
|
||||||
filterType: f.filterType,
|
filterType: f.filterType,
|
||||||
width: f.width || 200,
|
width: f.width && f.width >= 10 && f.width <= 100 ? f.width : 25,
|
||||||
}));
|
}));
|
||||||
onFiltersApplied?.(activeFilters);
|
onFiltersApplied?.(activeFilters);
|
||||||
|
|
||||||
|
|
@ -498,15 +498,15 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={100}
|
min={10}
|
||||||
max={400}
|
max={100}
|
||||||
value={filter.width || 200}
|
value={filter.width && filter.width >= 10 && filter.width <= 100 ? filter.width : 25}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleFilterWidthChange(filter.columnName, parseInt(e.target.value) || 200)
|
handleFilterWidthChange(filter.columnName, Math.min(100, Math.max(10, parseInt(e.target.value) || 25)))
|
||||||
}
|
}
|
||||||
className="h-7 w-16 text-center text-xs"
|
className="h-7 w-16 text-center text-xs"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs">px</span>
|
<span className="text-muted-foreground text-xs">%</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -430,28 +430,28 @@ export function TabsWidget({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveGridRenderer
|
<ResponsiveGridRenderer
|
||||||
components={componentDataList}
|
components={componentDataList}
|
||||||
canvasWidth={canvasWidth}
|
canvasWidth={canvasWidth}
|
||||||
canvasHeight={canvasHeight}
|
canvasHeight={canvasHeight}
|
||||||
renderComponent={(comp) => (
|
renderComponent={(comp) => (
|
||||||
<DynamicComponentRenderer
|
<DynamicComponentRenderer
|
||||||
{...restProps}
|
{...restProps}
|
||||||
component={comp}
|
component={comp}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
menuObjid={menuObjid}
|
menuObjid={menuObjid}
|
||||||
isDesignMode={false}
|
isDesignMode={false}
|
||||||
isInteractive={true}
|
isInteractive={true}
|
||||||
selectedRowsData={localSelectedRowsData}
|
selectedRowsData={localSelectedRowsData}
|
||||||
onSelectedRowsChange={handleSelectedRowsChange}
|
onSelectedRowsChange={handleSelectedRowsChange}
|
||||||
parentTabId={tab.id}
|
parentTabId={tab.id}
|
||||||
parentTabsComponentId={component.id}
|
parentTabsComponentId={component.id}
|
||||||
{...(screenInfoMap[tab.id]
|
{...(screenInfoMap[tab.id]
|
||||||
? { tableName: screenInfoMap[tab.id].tableName, screenId: screenInfoMap[tab.id].id }
|
? { tableName: screenInfoMap[tab.id].tableName, screenId: screenInfoMap[tab.id].id }
|
||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||||
const [parentValue, setParentValue] = useState<CategoryValue | null>(null);
|
const [parentValue, setParentValue] = useState<CategoryValue | null>(null);
|
||||||
|
const [continuousAdd, setContinuousAdd] = useState(false);
|
||||||
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
|
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
|
||||||
const [deletingValue, setDeletingValue] = 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);
|
const response = await createCategoryValue(input);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
toast.success("카테고리가 추가되었습니다");
|
toast.success("카테고리가 추가되었습니다");
|
||||||
// 폼 초기화 (모달은 닫지 않고 연속 입력)
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
valueCode: "",
|
|
||||||
valueLabel: "",
|
|
||||||
description: "",
|
|
||||||
color: "",
|
|
||||||
}));
|
|
||||||
setTimeout(() => addNameRef.current?.focus(), 50);
|
|
||||||
// 기존 펼침 상태 유지하면서 데이터 새로고침
|
|
||||||
await loadTree(true);
|
await loadTree(true);
|
||||||
// 부모 노드만 펼치기 (하위 추가 시)
|
|
||||||
if (parentValue) {
|
if (parentValue) {
|
||||||
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
|
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 {
|
} else {
|
||||||
toast.error(response.error || "추가 실패");
|
toast.error(response.error || "추가 실패");
|
||||||
}
|
}
|
||||||
|
|
@ -818,6 +822,19 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
추가
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -629,7 +629,7 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>((pro
|
||||||
): SelectOption[] => {
|
): SelectOption[] => {
|
||||||
const result: SelectOption[] = [];
|
const result: SelectOption[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
|
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
|
||||||
result.push({
|
result.push({
|
||||||
value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치)
|
value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치)
|
||||||
label: prefix + item.valueLabel,
|
label: prefix + item.valueLabel,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState }
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { formatNumber as centralFormatNumber } from "@/lib/formatting";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { V2InputProps, V2InputConfig, V2InputFormat } from "@/types/v2-components";
|
import { V2InputProps, V2InputConfig, V2InputFormat } from "@/types/v2-components";
|
||||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||||
|
|
@ -61,11 +62,11 @@ export function validateInputFormat(value: string, format: V2InputFormat): { isV
|
||||||
return { isValid, errorMessage: isValid ? "" : formatConfig.errorMessage };
|
return { isValid, errorMessage: isValid ? "" : formatConfig.errorMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 통화 형식 변환
|
// 통화 형식 변환 (공통 formatNumber 사용)
|
||||||
function formatCurrency(value: string | number): string {
|
function formatCurrency(value: string | number): string {
|
||||||
const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value;
|
const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value;
|
||||||
if (isNaN(num)) return "";
|
if (isNaN(num)) return "";
|
||||||
return num.toLocaleString("ko-KR");
|
return centralFormatNumber(num);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사업자번호 형식 변환
|
// 사업자번호 형식 변환
|
||||||
|
|
@ -234,7 +235,22 @@ const TextInput = forwardRef<
|
||||||
TextInput.displayName = "TextInput";
|
TextInput.displayName = "TextInput";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 숫자 입력 컴포넌트
|
* 숫자를 콤마 포맷 문자열로 변환 (입력 중 실시간 표시용)
|
||||||
|
* 소수점 입력 중인 경우(끝이 "."이거나 ".0" 등)를 보존
|
||||||
|
*/
|
||||||
|
function toCommaDisplay(raw: string): string {
|
||||||
|
if (raw === "" || raw === "-") return raw;
|
||||||
|
const negative = raw.startsWith("-");
|
||||||
|
const abs = negative ? raw.slice(1) : raw;
|
||||||
|
const dotIdx = abs.indexOf(".");
|
||||||
|
const intPart = dotIdx >= 0 ? abs.slice(0, dotIdx) : abs;
|
||||||
|
const decPart = dotIdx >= 0 ? abs.slice(dotIdx) : "";
|
||||||
|
const formatted = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
|
return (negative ? "-" : "") + formatted + decPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 숫자 입력 컴포넌트 - 입력 중에도 실시간 천단위 콤마 표시
|
||||||
*/
|
*/
|
||||||
const NumberInput = forwardRef<
|
const NumberInput = forwardRef<
|
||||||
HTMLInputElement,
|
HTMLInputElement,
|
||||||
|
|
@ -250,40 +266,112 @@ const NumberInput = forwardRef<
|
||||||
className?: string;
|
className?: string;
|
||||||
inputStyle?: React.CSSProperties;
|
inputStyle?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
>(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className, inputStyle }, ref) => {
|
>(({ value, onChange, min, max, placeholder, readonly, disabled, className, inputStyle }, ref) => {
|
||||||
|
const innerRef = useRef<HTMLInputElement>(null);
|
||||||
|
const combinedRef = (node: HTMLInputElement | null) => {
|
||||||
|
(innerRef as React.MutableRefObject<HTMLInputElement | null>).current = node;
|
||||||
|
if (typeof ref === "function") ref(node);
|
||||||
|
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 콤마 포함된 표시 문자열을 내부 상태로 관리
|
||||||
|
const [displayValue, setDisplayValue] = useState(() => {
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
return centralFormatNumber(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 외부 value가 변경되면 표시 값 동기화 (포커스 아닐 때만)
|
||||||
|
const isFocusedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFocusedRef.current) return;
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
setDisplayValue("");
|
||||||
|
} else {
|
||||||
|
setDisplayValue(centralFormatNumber(value));
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const val = e.target.value;
|
const input = e.target;
|
||||||
if (val === "") {
|
const cursorPos = input.selectionStart ?? 0;
|
||||||
|
const oldVal = displayValue;
|
||||||
|
const rawInput = e.target.value;
|
||||||
|
|
||||||
|
// 콤마 제거하여 순수 숫자 문자열 추출
|
||||||
|
const stripped = rawInput.replace(/,/g, "");
|
||||||
|
|
||||||
|
// 빈 값 처리
|
||||||
|
if (stripped === "" || stripped === "-") {
|
||||||
|
setDisplayValue(stripped);
|
||||||
onChange?.(undefined);
|
onChange?.(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let num = parseFloat(val);
|
// 숫자 + 소수점만 허용 (입력 중 "123." 같은 중간 상태도 허용)
|
||||||
|
if (!/^-?\d*\.?\d*$/.test(stripped)) return;
|
||||||
|
|
||||||
|
// 새 콤마 포맷 생성
|
||||||
|
const newDisplay = toCommaDisplay(stripped);
|
||||||
|
setDisplayValue(newDisplay);
|
||||||
|
|
||||||
|
// 콤마 개수 차이로 커서 위치 보정
|
||||||
|
const oldCommas = (oldVal.slice(0, cursorPos).match(/,/g) || []).length;
|
||||||
|
const newCommas = (newDisplay.slice(0, cursorPos).match(/,/g) || []).length;
|
||||||
|
const adjustedCursor = cursorPos + (newCommas - oldCommas);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (innerRef.current) {
|
||||||
|
innerRef.current.setSelectionRange(adjustedCursor, adjustedCursor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 실제 숫자 값 전달 (소수점 입력 중이면 아직 전달하지 않음)
|
||||||
|
if (stripped.endsWith(".") || stripped.endsWith("-")) return;
|
||||||
|
let num = parseFloat(stripped);
|
||||||
|
if (isNaN(num)) return;
|
||||||
|
|
||||||
// 범위 제한
|
|
||||||
if (min !== undefined && num < min) num = min;
|
if (min !== undefined && num < min) num = min;
|
||||||
if (max !== undefined && num > max) num = max;
|
if (max !== undefined && num > max) num = max;
|
||||||
|
|
||||||
onChange?.(num);
|
onChange?.(num);
|
||||||
},
|
},
|
||||||
[min, max, onChange],
|
[min, max, onChange, displayValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
isFocusedRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
isFocusedRef.current = false;
|
||||||
|
// 블러 시 최종 포맷 정리
|
||||||
|
const stripped = displayValue.replace(/,/g, "");
|
||||||
|
if (stripped === "" || stripped === "-" || stripped === ".") {
|
||||||
|
setDisplayValue("");
|
||||||
|
onChange?.(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const num = parseFloat(stripped);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
setDisplayValue(centralFormatNumber(num));
|
||||||
|
}
|
||||||
|
}, [displayValue, onChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
ref={ref}
|
ref={combinedRef}
|
||||||
type="number"
|
type="text"
|
||||||
value={value ?? ""}
|
inputMode="decimal"
|
||||||
|
value={displayValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
min={min}
|
onFocus={handleFocus}
|
||||||
max={max}
|
onBlur={handleBlur}
|
||||||
step={step}
|
|
||||||
placeholder={placeholder || "숫자 입력"}
|
placeholder={placeholder || "숫자 입력"}
|
||||||
readOnly={readonly}
|
readOnly={readonly}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn("h-full w-full", className)}
|
className={cn("h-full w-full", className)}
|
||||||
style={inputStyle}
|
style={{ ...inputStyle, textAlign: "right" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -909,10 +997,10 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
||||||
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
|
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
|
||||||
|
|
||||||
return (
|
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 && (
|
{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}
|
{templatePrefix}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -945,13 +1033,13 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="입력"
|
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}
|
disabled={disabled || isGeneratingNumbering}
|
||||||
style={inputTextStyle}
|
style={{ ...inputTextStyle, outline: 'none' }}
|
||||||
/>
|
/>
|
||||||
{/* 고정 접미어 */}
|
{/* 고정 접미어 */}
|
||||||
{templateSuffix && (
|
{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}
|
{templateSuffix}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -901,7 +901,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
|
||||||
): SelectOption[] => {
|
): SelectOption[] => {
|
||||||
const result: SelectOption[] = [];
|
const result: SelectOption[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
|
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
|
||||||
result.push({
|
result.push({
|
||||||
value: item.valueCode, // 🔧 valueCode를 value로 사용
|
value: item.valueCode, // 🔧 valueCode를 value로 사용
|
||||||
label: prefix + item.valueLabel,
|
label: prefix + item.valueLabel,
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,21 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { Settings, ChevronDown, Loader2, Type, Hash, Lock, AlignLeft, SlidersHorizontal, Palette, ListOrdered } from "lucide-react";
|
import {
|
||||||
|
Settings,
|
||||||
|
ChevronDown,
|
||||||
|
Loader2,
|
||||||
|
Type,
|
||||||
|
Hash,
|
||||||
|
Lock,
|
||||||
|
AlignLeft,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Palette,
|
||||||
|
ListOrdered,
|
||||||
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||||
|
|
@ -22,9 +35,15 @@ interface V2InputConfigPanelProps {
|
||||||
config: Record<string, any>;
|
config: Record<string, any>;
|
||||||
onChange: (config: Record<string, any>) => void;
|
onChange: (config: Record<string, any>) => void;
|
||||||
menuObjid?: number;
|
menuObjid?: number;
|
||||||
|
allComponents?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config, onChange, menuObjid }) => {
|
export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
menuObjid,
|
||||||
|
allComponents = [],
|
||||||
|
}) => {
|
||||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||||
const [loadingRules, setLoadingRules] = useState(false);
|
const [loadingRules, setLoadingRules] = useState(false);
|
||||||
const [parentMenus, setParentMenus] = useState<any[]>([]);
|
const [parentMenus, setParentMenus] = useState<any[]>([]);
|
||||||
|
|
@ -49,7 +68,7 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
const userMenus = allMenus.filter((menu: any) => {
|
const userMenus = allMenus.filter((menu: any) => {
|
||||||
const menuType = menu.menu_type || menu.menuType;
|
const menuType = menu.menu_type || menu.menuType;
|
||||||
const level = menu.level || menu.lev || menu.LEVEL;
|
const level = menu.level || menu.lev || menu.LEVEL;
|
||||||
return menuType === '1' && (level === 2 || level === 3 || level === '2' || level === '3');
|
return menuType === "1" && (level === 2 || level === 3 || level === "2" || level === "3");
|
||||||
});
|
});
|
||||||
setParentMenus(userMenus);
|
setParentMenus(userMenus);
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +87,10 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
const loadRules = async () => {
|
const loadRules = async () => {
|
||||||
const isNumbering = inputType === "numbering" || config.autoGeneration?.type === "numbering_rule";
|
const isNumbering = inputType === "numbering" || config.autoGeneration?.type === "numbering_rule";
|
||||||
if (!isNumbering) return;
|
if (!isNumbering) return;
|
||||||
if (!selectedMenuObjid) { setNumberingRules([]); return; }
|
if (!selectedMenuObjid) {
|
||||||
|
setNumberingRules([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoadingRules(true);
|
setLoadingRules(true);
|
||||||
try {
|
try {
|
||||||
const response = await getAvailableNumberingRules(selectedMenuObjid);
|
const response = await getAvailableNumberingRules(selectedMenuObjid);
|
||||||
|
|
@ -90,10 +112,10 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
{/* ─── 1단계: 입력 타입 선택 (카드 방식) ─── */}
|
{/* ─── 1단계: 입력 타입 선택 (카드 방식) ─── */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Type className="h-4 w-4 text-muted-foreground" />
|
<Type className="text-muted-foreground h-4 w-4" />
|
||||||
<p className="text-sm font-medium">입력 타입</p>
|
<p className="text-sm font-medium">입력 타입</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">입력 필드의 종류를 선택해요</p>
|
<p className="text-muted-foreground text-[11px]">입력 필드의 종류를 선택해요</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
|
@ -130,20 +152,23 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-lg border p-2.5 text-left transition-all",
|
"flex items-center gap-2 rounded-lg border p-2.5 text-left transition-all",
|
||||||
inputType === item.value
|
inputType === item.value
|
||||||
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
? "border-primary bg-primary/5 ring-primary/20 ring-1"
|
||||||
: "border-border hover:border-primary/30 hover:bg-muted/30"
|
: "border-border hover:border-primary/30 hover:bg-muted/30",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className={cn(
|
<item.icon
|
||||||
"h-4 w-4 shrink-0",
|
className={cn("h-4 w-4 shrink-0", inputType === item.value ? "text-primary" : "text-muted-foreground")}
|
||||||
inputType === item.value ? "text-primary" : "text-muted-foreground"
|
/>
|
||||||
)} />
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className={cn(
|
<span
|
||||||
"text-xs font-medium block",
|
className={cn(
|
||||||
inputType === item.value ? "text-primary" : "text-foreground"
|
"block text-xs font-medium",
|
||||||
)}>{item.label}</span>
|
inputType === item.value ? "text-primary" : "text-foreground",
|
||||||
<span className="text-[10px] text-muted-foreground block truncate">{item.desc}</span>
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground block truncate text-[10px]">{item.desc}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
@ -151,34 +176,34 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
|
|
||||||
{/* ─── 채번 타입 전용 설정 ─── */}
|
{/* ─── 채번 타입 전용 설정 ─── */}
|
||||||
{inputType === "numbering" && (
|
{inputType === "numbering" && (
|
||||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
<div className="bg-muted/30 space-y-3 rounded-lg border p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ListOrdered className="h-4 w-4 text-primary" />
|
<ListOrdered className="text-primary h-4 w-4" />
|
||||||
<span className="text-sm font-medium">채번 규칙</span>
|
<span className="text-sm font-medium">채번 규칙</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs text-muted-foreground">적용할 메뉴</p>
|
<p className="text-muted-foreground mb-1.5 text-xs">적용할 메뉴</p>
|
||||||
{menuObjid && selectedMenuObjid === menuObjid ? (
|
{menuObjid && selectedMenuObjid === menuObjid ? (
|
||||||
<div className="rounded-md border bg-background p-2">
|
<div className="bg-background rounded-md border p-2">
|
||||||
<p className="text-xs text-muted-foreground">현재 화면 메뉴 사용 중</p>
|
<p className="text-muted-foreground text-xs">현재 화면 메뉴 사용 중</p>
|
||||||
<div className="mt-1 flex items-center justify-between">
|
<div className="mt-1 flex items-center justify-between">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{parentMenus.find((m: any) => m.objid === menuObjid)?.menu_name_kor
|
{parentMenus.find((m: any) => m.objid === menuObjid)?.menu_name_kor ||
|
||||||
|| parentMenus.find((m: any) => m.objid === menuObjid)?.translated_name
|
parentMenus.find((m: any) => m.objid === menuObjid)?.translated_name ||
|
||||||
|| `메뉴 #${menuObjid}`}
|
`메뉴 #${menuObjid}`}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedMenuObjid(undefined)}
|
onClick={() => setSelectedMenuObjid(undefined)}
|
||||||
className="text-[10px] text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground text-[10px]"
|
||||||
>
|
>
|
||||||
변경
|
변경
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : loadingMenus ? (
|
) : loadingMenus ? (
|
||||||
<div className="text-muted-foreground flex items-center gap-2 text-xs py-1">
|
<div className="text-muted-foreground flex items-center gap-2 py-1 text-xs">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
메뉴 목록 로딩 중...
|
메뉴 목록 로딩 중...
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -214,9 +239,9 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
|
|
||||||
{selectedMenuObjid && (
|
{selectedMenuObjid && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs text-muted-foreground">채번 규칙</p>
|
<p className="text-muted-foreground mb-1.5 text-xs">채번 규칙</p>
|
||||||
{loadingRules ? (
|
{loadingRules ? (
|
||||||
<div className="text-muted-foreground flex items-center gap-2 text-xs py-1">
|
<div className="text-muted-foreground flex items-center gap-2 py-1 text-xs">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
채번 규칙 로딩 중...
|
채번 규칙 로딩 중...
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -241,13 +266,14 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{numberingRules.map((rule) => (
|
{numberingRules.map((rule) => (
|
||||||
<SelectItem key={rule.ruleId} value={String(rule.ruleId)}>
|
<SelectItem key={rule.ruleId} value={String(rule.ruleId)}>
|
||||||
{rule.ruleName} ({rule.separator || "-"}{"{번호}"})
|
{rule.ruleName} ({rule.separator || "-"}
|
||||||
|
{"{번호}"})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">선택한 메뉴에 등록된 채번 규칙이 없어요</p>
|
<p className="text-muted-foreground text-xs">선택한 메뉴에 등록된 채번 규칙이 없어요</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -255,7 +281,7 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm">읽기전용</p>
|
<p className="text-sm">읽기전용</p>
|
||||||
<p className="text-[11px] text-muted-foreground">채번 필드는 자동 생성되므로 읽기전용을 권장해요</p>
|
<p className="text-muted-foreground text-[11px]">채번 필드는 자동 생성되므로 읽기전용을 권장해요</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.readonly !== false}
|
checked={config.readonly !== false}
|
||||||
|
|
@ -269,10 +295,10 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
{inputType !== "numbering" && (
|
{inputType !== "numbering" && (
|
||||||
<>
|
<>
|
||||||
{/* 기본 설정 영역 */}
|
{/* 기본 설정 영역 */}
|
||||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
<div className="bg-muted/30 space-y-3 rounded-lg border p-4">
|
||||||
{/* 안내 텍스트 (placeholder) */}
|
{/* 안내 텍스트 (placeholder) */}
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<span className="text-xs text-muted-foreground">안내 텍스트</span>
|
<span className="text-muted-foreground text-xs">안내 텍스트</span>
|
||||||
<Input
|
<Input
|
||||||
value={config.placeholder || ""}
|
value={config.placeholder || ""}
|
||||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||||
|
|
@ -284,7 +310,7 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
{/* 입력 형식 - 텍스트 타입 전용 */}
|
{/* 입력 형식 - 텍스트 타입 전용 */}
|
||||||
{(inputType === "text" || !config.inputType) && (
|
{(inputType === "text" || !config.inputType) && (
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<span className="text-xs text-muted-foreground">입력 형식</span>
|
<span className="text-muted-foreground text-xs">입력 형식</span>
|
||||||
<Select value={config.format || "none"} onValueChange={(value) => updateConfig("format", value)}>
|
<Select value={config.format || "none"} onValueChange={(value) => updateConfig("format", value)}>
|
||||||
<SelectTrigger className="h-7 w-[160px] text-xs">
|
<SelectTrigger className="h-7 w-[160px] text-xs">
|
||||||
<SelectValue placeholder="형식 선택" />
|
<SelectValue placeholder="형식 선택" />
|
||||||
|
|
@ -304,8 +330,8 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
{/* 입력 마스크 */}
|
{/* 입력 마스크 */}
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs text-muted-foreground">입력 마스크</span>
|
<span className="text-muted-foreground text-xs">입력 마스크</span>
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5"># = 숫자, A = 문자, * = 모두</p>
|
<p className="text-muted-foreground mt-0.5 text-[10px]"># = 숫자, A = 문자, * = 모두</p>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
value={config.mask || ""}
|
value={config.mask || ""}
|
||||||
|
|
@ -318,10 +344,10 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
{/* 숫자/슬라이더: 범위 설정 */}
|
{/* 숫자/슬라이더: 범위 설정 */}
|
||||||
{(inputType === "number" || inputType === "slider") && (
|
{(inputType === "number" || inputType === "slider") && (
|
||||||
<div className="space-y-2 pt-1">
|
<div className="space-y-2 pt-1">
|
||||||
<p className="text-xs text-muted-foreground">값 범위</p>
|
<p className="text-muted-foreground text-xs">값 범위</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-[10px] text-muted-foreground">최소값</Label>
|
<Label className="text-muted-foreground text-[10px]">최소값</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={config.min ?? ""}
|
value={config.min ?? ""}
|
||||||
|
|
@ -331,7 +357,7 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-[10px] text-muted-foreground">최대값</Label>
|
<Label className="text-muted-foreground text-[10px]">최대값</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={config.max ?? ""}
|
value={config.max ?? ""}
|
||||||
|
|
@ -341,7 +367,7 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-[10px] text-muted-foreground">단계</Label>
|
<Label className="text-muted-foreground text-[10px]">단계</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={config.step ?? ""}
|
value={config.step ?? ""}
|
||||||
|
|
@ -357,7 +383,7 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
{/* 여러 줄 텍스트: 줄 수 */}
|
{/* 여러 줄 텍스트: 줄 수 */}
|
||||||
{inputType === "textarea" && (
|
{inputType === "textarea" && (
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<span className="text-xs text-muted-foreground">줄 수</span>
|
<span className="text-muted-foreground text-xs">줄 수</span>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={config.rows || 3}
|
value={config.rows || 3}
|
||||||
|
|
@ -375,27 +401,27 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
className="bg-muted/30 hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-left transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
<Settings className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="text-sm font-medium">고급 설정</span>
|
<span className="text-sm font-medium">고급 설정</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
"text-muted-foreground h-4 w-4 transition-transform duration-200",
|
||||||
advancedOpen && "rotate-180"
|
advancedOpen && "rotate-180",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
<div className="space-y-3 rounded-b-lg border border-t-0 p-4">
|
||||||
{/* 자동 생성 토글 */}
|
{/* 자동 생성 토글 */}
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm">자동 생성</p>
|
<p className="text-sm">자동 생성</p>
|
||||||
<p className="text-[11px] text-muted-foreground">값이 자동으로 채워져요</p>
|
<p className="text-muted-foreground text-[11px]">값이 자동으로 채워져요</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.autoGeneration?.enabled || false}
|
checked={config.autoGeneration?.enabled || false}
|
||||||
|
|
@ -410,10 +436,10 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config.autoGeneration?.enabled && (
|
{config.autoGeneration?.enabled && (
|
||||||
<div className="space-y-3 ml-1 border-l-2 border-primary/20 pl-3">
|
<div className="border-primary/20 ml-1 space-y-3 border-l-2 pl-3">
|
||||||
{/* 자동 생성 타입 */}
|
{/* 자동 생성 타입 */}
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs text-muted-foreground">생성 방식</p>
|
<p className="text-muted-foreground mb-1.5 text-xs">생성 방식</p>
|
||||||
<Select
|
<Select
|
||||||
value={config.autoGeneration?.type || "none"}
|
value={config.autoGeneration?.type || "none"}
|
||||||
onValueChange={(value: AutoGenerationType) => {
|
onValueChange={(value: AutoGenerationType) => {
|
||||||
|
|
@ -443,7 +469,7 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config.autoGeneration?.type && config.autoGeneration.type !== "none" && (
|
{config.autoGeneration?.type && config.autoGeneration.type !== "none" && (
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-muted-foreground text-[11px]">
|
||||||
{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}
|
{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -452,7 +478,7 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
{config.autoGeneration?.type === "numbering_rule" && (
|
{config.autoGeneration?.type === "numbering_rule" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs text-muted-foreground">
|
<p className="text-muted-foreground mb-1.5 text-xs">
|
||||||
대상 메뉴 <span className="text-destructive">*</span>
|
대상 메뉴 <span className="text-destructive">*</span>
|
||||||
</p>
|
</p>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -488,11 +514,11 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
|
|
||||||
{selectedMenuObjid ? (
|
{selectedMenuObjid ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs text-muted-foreground">
|
<p className="text-muted-foreground mb-1.5 text-xs">
|
||||||
채번 규칙 <span className="text-destructive">*</span>
|
채번 규칙 <span className="text-destructive">*</span>
|
||||||
</p>
|
</p>
|
||||||
{loadingRules ? (
|
{loadingRules ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
규칙 로딩 중...
|
규칙 로딩 중...
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -542,7 +568,7 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{["random_string", "random_number"].includes(config.autoGeneration.type) && (
|
{["random_string", "random_number"].includes(config.autoGeneration.type) && (
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<span className="text-xs text-muted-foreground">길이</span>
|
<span className="text-muted-foreground text-xs">길이</span>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
|
|
@ -563,7 +589,7 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<span className="text-xs text-muted-foreground">접두사</span>
|
<span className="text-muted-foreground text-xs">접두사</span>
|
||||||
<Input
|
<Input
|
||||||
value={config.autoGeneration?.options?.prefix || ""}
|
value={config.autoGeneration?.options?.prefix || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
@ -581,7 +607,7 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<span className="text-xs text-muted-foreground">접미사</span>
|
<span className="text-muted-foreground text-xs">접미사</span>
|
||||||
<Input
|
<Input
|
||||||
value={config.autoGeneration?.options?.suffix || ""}
|
value={config.autoGeneration?.options?.suffix || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
@ -598,8 +624,8 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs text-muted-foreground">미리보기</span>
|
<span className="text-muted-foreground text-xs">미리보기</span>
|
||||||
<div className="mt-1 rounded-md border bg-muted p-2 text-xs font-mono">
|
<div className="bg-muted mt-1 rounded-md border p-2 font-mono text-xs">
|
||||||
{AutoGenerationUtils.generatePreviewValue(config.autoGeneration)}
|
{AutoGenerationUtils.generatePreviewValue(config.autoGeneration)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -612,10 +638,195 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 데이터 바인딩 설정 */}
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<DataBindingSection config={config} onChange={onChange} allComponents={allComponents} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
V2InputConfigPanel.displayName = "V2InputConfigPanel";
|
V2InputConfigPanel.displayName = "V2InputConfigPanel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 바인딩 설정 섹션
|
||||||
|
* 같은 화면의 v2-table-list 컴포넌트를 자동 감지하여 드롭다운으로 표시
|
||||||
|
*/
|
||||||
|
function DataBindingSection({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
allComponents,
|
||||||
|
}: {
|
||||||
|
config: Record<string, any>;
|
||||||
|
onChange: (config: Record<string, any>) => void;
|
||||||
|
allComponents: any[];
|
||||||
|
}) {
|
||||||
|
const [tableColumns, setTableColumns] = useState<string[]>([]);
|
||||||
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||||
|
|
||||||
|
// 같은 화면의 v2-table-list 컴포넌트만 필터링
|
||||||
|
const tableListComponents = React.useMemo(() => {
|
||||||
|
return allComponents.filter((comp) => {
|
||||||
|
const type =
|
||||||
|
comp.componentType || comp.widgetType || comp.componentConfig?.type || (comp.url && comp.url.split("/").pop());
|
||||||
|
return type === "v2-table-list";
|
||||||
|
});
|
||||||
|
}, [allComponents]);
|
||||||
|
|
||||||
|
// 선택된 테이블 컴포넌트의 테이블명 추출
|
||||||
|
const selectedTableComponent = React.useMemo(() => {
|
||||||
|
if (!config.dataBinding?.sourceComponentId) return null;
|
||||||
|
return tableListComponents.find((comp) => comp.id === config.dataBinding.sourceComponentId);
|
||||||
|
}, [tableListComponents, config.dataBinding?.sourceComponentId]);
|
||||||
|
|
||||||
|
const selectedTableName = React.useMemo(() => {
|
||||||
|
if (!selectedTableComponent) return null;
|
||||||
|
return selectedTableComponent.componentConfig?.selectedTable || selectedTableComponent.selectedTable || null;
|
||||||
|
}, [selectedTableComponent]);
|
||||||
|
|
||||||
|
// 선택된 테이블의 컬럼 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTableName) {
|
||||||
|
setTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadColumns = async () => {
|
||||||
|
setLoadingColumns(true);
|
||||||
|
try {
|
||||||
|
const { tableTypeApi } = await import("@/lib/api/screen");
|
||||||
|
const response = await tableTypeApi.getTableTypeColumns(selectedTableName);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const cols = response.data.map((col: any) => col.column_name).filter(Boolean);
|
||||||
|
setTableColumns(cols);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 컬럼 정보를 못 가져오면 테이블 컴포넌트의 columns에서 추출
|
||||||
|
const configColumns = selectedTableComponent?.componentConfig?.columns;
|
||||||
|
if (Array.isArray(configColumns)) {
|
||||||
|
setTableColumns(configColumns.map((c: any) => c.columnName).filter(Boolean));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingColumns(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadColumns();
|
||||||
|
}, [selectedTableName, selectedTableComponent]);
|
||||||
|
|
||||||
|
const updateConfig = (field: string, value: any) => {
|
||||||
|
onChange({ ...config, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="dataBindingEnabled"
|
||||||
|
checked={!!config.dataBinding?.sourceComponentId}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
const firstTable = tableListComponents[0];
|
||||||
|
updateConfig("dataBinding", {
|
||||||
|
sourceComponentId: firstTable?.id || "",
|
||||||
|
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-muted-foreground text-[10px]">테이블에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다</p>
|
||||||
|
|
||||||
|
{/* 소스 테이블 컴포넌트 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">소스 테이블</Label>
|
||||||
|
{tableListComponents.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-amber-500">이 화면에 v2-table-list 컴포넌트가 없습니다</p>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={config.dataBinding?.sourceComponentId || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateConfig("dataBinding", {
|
||||||
|
...config.dataBinding,
|
||||||
|
sourceComponentId: value,
|
||||||
|
sourceColumn: "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableListComponents.map((comp) => {
|
||||||
|
const tblName = comp.componentConfig?.selectedTable || comp.selectedTable || "";
|
||||||
|
const label = comp.componentConfig?.label || comp.label || comp.id;
|
||||||
|
return (
|
||||||
|
<SelectItem key={comp.id} value={comp.id}>
|
||||||
|
{label} ({tblName || comp.id})
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 소스 컬럼 선택 */}
|
||||||
|
{config.dataBinding?.sourceComponentId && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">가져올 컬럼</Label>
|
||||||
|
{loadingColumns ? (
|
||||||
|
<p className="text-muted-foreground text-[10px]">컬럼 로딩 중...</p>
|
||||||
|
) : tableColumns.length === 0 ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
value={config.dataBinding?.sourceColumn || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateConfig("dataBinding", {
|
||||||
|
...config.dataBinding,
|
||||||
|
sourceColumn: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="컬럼명 직접 입력"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-[10px]">컬럼 정보를 불러올 수 없어 직접 입력</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={config.dataBinding?.sourceColumn || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateConfig("dataBinding", {
|
||||||
|
...config.dataBinding,
|
||||||
|
sourceColumn: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col} value={col}>
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default V2InputConfigPanel;
|
export default V2InputConfigPanel;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2 품목별 라우팅 설정 패널
|
* V2 품목별 라우팅 설정 패널
|
||||||
* 토스식 단계별 UX: 데이터 소스 -> 모달 연동 -> 공정 컬럼 -> 레이아웃(접힘)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
|
@ -16,10 +15,10 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Settings, ChevronDown, ChevronRight, Plus, Trash2, Check, ChevronsUpDown,
|
Settings, ChevronDown, ChevronRight, Plus, Trash2, Check, ChevronsUpDown,
|
||||||
Database, Monitor, Columns,
|
Database, Monitor, Columns, List, Filter, Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ItemRoutingConfig, ProcessColumnDef } from "@/lib/registry/components/v2-item-routing/types";
|
import type { ItemRoutingConfig, ProcessColumnDef, ColumnDef, ItemFilterCondition } from "@/lib/registry/components/v2-item-routing/types";
|
||||||
import { defaultConfig } from "@/lib/registry/components/v2-item-routing/config";
|
import { defaultConfig } from "@/lib/registry/components/v2-item-routing/config";
|
||||||
|
|
||||||
interface V2ItemRoutingConfigPanelProps {
|
interface V2ItemRoutingConfigPanelProps {
|
||||||
|
|
@ -27,53 +26,21 @@ interface V2ItemRoutingConfigPanelProps {
|
||||||
onChange: (config: Partial<ItemRoutingConfig>) => void;
|
onChange: (config: Partial<ItemRoutingConfig>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TableInfo {
|
interface TableInfo { tableName: string; displayName?: string; }
|
||||||
tableName: string;
|
interface ColumnInfo { columnName: string; displayName?: string; dataType?: string; }
|
||||||
displayName?: string;
|
interface ScreenInfo { screenId: number; screenName: string; screenCode: string; }
|
||||||
}
|
|
||||||
|
|
||||||
interface ColumnInfo {
|
// ─── 공용: 테이블 Combobox ───
|
||||||
columnName: string;
|
function TableCombobox({ value, onChange, tables, loading }: {
|
||||||
displayName?: string;
|
value: string; onChange: (v: string) => void; tables: TableInfo[]; loading: boolean;
|
||||||
dataType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScreenInfo {
|
|
||||||
screenId: number;
|
|
||||||
screenName: string;
|
|
||||||
screenCode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 테이블 Combobox ───
|
|
||||||
function TableCombobox({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
tables,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
tables: TableInfo[];
|
|
||||||
loading: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const selected = tables.find((t) => t.tableName === value);
|
const selected = tables.find((t) => t.tableName === value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading}>
|
||||||
variant="outline"
|
{loading ? "로딩 중..." : selected ? selected.displayName || selected.tableName : "테이블 선택"}
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
className="h-7 w-full justify-between text-xs"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading
|
|
||||||
? "로딩 중..."
|
|
||||||
: selected
|
|
||||||
? selected.displayName || selected.tableName
|
|
||||||
: "테이블 선택"}
|
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -84,12 +51,8 @@ function TableCombobox({
|
||||||
<CommandEmpty className="py-4 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
<CommandEmpty className="py-4 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
{tables.map((t) => (
|
{tables.map((t) => (
|
||||||
<CommandItem
|
<CommandItem key={t.tableName} value={`${t.displayName || ""} ${t.tableName}`}
|
||||||
key={t.tableName}
|
onSelect={() => { onChange(t.tableName); setOpen(false); }} className="text-xs">
|
||||||
value={`${t.displayName || ""} ${t.tableName}`}
|
|
||||||
onSelect={() => { onChange(t.tableName); setOpen(false); }}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Check className={cn("mr-2 h-3 w-3", value === t.tableName ? "opacity-100" : "opacity-0")} />
|
<Check className={cn("mr-2 h-3 w-3", value === t.tableName ? "opacity-100" : "opacity-0")} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{t.displayName || t.tableName}</span>
|
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||||
|
|
@ -105,17 +68,9 @@ function TableCombobox({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 컬럼 Combobox ───
|
// ─── 공용: 컬럼 Combobox ───
|
||||||
function ColumnCombobox({
|
function ColumnCombobox({ value, onChange, tableName, placeholder }: {
|
||||||
value,
|
value: string; onChange: (v: string, displayName?: string) => void; tableName: string; placeholder?: string;
|
||||||
onChange,
|
|
||||||
tableName,
|
|
||||||
placeholder,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
tableName: string;
|
|
||||||
placeholder?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
|
@ -128,26 +83,17 @@ function ColumnCombobox({
|
||||||
try {
|
try {
|
||||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
const res = await tableManagementApi.getColumnList(tableName);
|
const res = await tableManagementApi.getColumnList(tableName);
|
||||||
if (res.success && res.data?.columns) {
|
if (res.success && res.data?.columns) setColumns(res.data.columns);
|
||||||
setColumns(res.data.columns);
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ } finally { setLoading(false); }
|
} catch { /* ignore */ } finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
}, [tableName]);
|
}, [tableName]);
|
||||||
|
|
||||||
const selected = columns.find((c) => c.columnName === value);
|
const selected = columns.find((c) => c.columnName === value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading || !tableName}>
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
className="h-7 w-full justify-between text-xs"
|
|
||||||
disabled={loading || !tableName}
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{loading ? "로딩..." : !tableName ? "테이블 먼저 선택" : selected ? selected.displayName || selected.columnName : placeholder || "컬럼 선택"}
|
{loading ? "로딩..." : !tableName ? "테이블 먼저 선택" : selected ? selected.displayName || selected.columnName : placeholder || "컬럼 선택"}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -161,12 +107,8 @@ function ColumnCombobox({
|
||||||
<CommandEmpty className="py-4 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
<CommandEmpty className="py-4 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
{columns.map((c) => (
|
{columns.map((c) => (
|
||||||
<CommandItem
|
<CommandItem key={c.columnName} value={`${c.displayName || ""} ${c.columnName}`}
|
||||||
key={c.columnName}
|
onSelect={() => { onChange(c.columnName, c.displayName); setOpen(false); }} className="text-xs">
|
||||||
value={`${c.displayName || ""} ${c.columnName}`}
|
|
||||||
onSelect={() => { onChange(c.columnName); setOpen(false); }}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Check className={cn("mr-2 h-3 w-3", value === c.columnName ? "opacity-100" : "opacity-0")} />
|
<Check className={cn("mr-2 h-3 w-3", value === c.columnName ? "opacity-100" : "opacity-0")} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{c.displayName || c.columnName}</span>
|
<span className="font-medium">{c.displayName || c.columnName}</span>
|
||||||
|
|
@ -182,14 +124,8 @@ function ColumnCombobox({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 화면 Combobox ───
|
// ─── 공용: 화면 Combobox ───
|
||||||
function ScreenCombobox({
|
function ScreenCombobox({ value, onChange }: { value?: number; onChange: (v?: number) => void; }) {
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value?: number;
|
|
||||||
onChange: (v?: number) => void;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [screens, setScreens] = useState<ScreenInfo[]>([]);
|
const [screens, setScreens] = useState<ScreenInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -201,13 +137,9 @@ function ScreenCombobox({
|
||||||
const { screenApi } = await import("@/lib/api/screen");
|
const { screenApi } = await import("@/lib/api/screen");
|
||||||
const res = await screenApi.getScreens({ page: 1, size: 1000 });
|
const res = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
setScreens(
|
setScreens(res.data.map((s: any) => ({
|
||||||
res.data.map((s: any) => ({
|
screenId: s.screenId, screenName: s.screenName || `화면 ${s.screenId}`, screenCode: s.screenCode || "",
|
||||||
screenId: s.screenId,
|
})));
|
||||||
screenName: s.screenName || `화면 ${s.screenId}`,
|
|
||||||
screenCode: s.screenCode || "",
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ } finally { setLoading(false); }
|
} catch { /* ignore */ } finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
@ -215,20 +147,11 @@ function ScreenCombobox({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selected = screens.find((s) => s.screenId === value);
|
const selected = screens.find((s) => s.screenId === value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading}>
|
||||||
variant="outline"
|
<span className="truncate">{loading ? "로딩..." : selected ? selected.screenName : "화면 선택"}</span>
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
className="h-7 w-full justify-between text-xs"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{loading ? "로딩..." : selected ? selected.screenName : "화면 선택"}
|
|
||||||
</span>
|
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -239,12 +162,8 @@ function ScreenCombobox({
|
||||||
<CommandEmpty className="py-4 text-center text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
<CommandEmpty className="py-4 text-center text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
{screens.map((s) => (
|
{screens.map((s) => (
|
||||||
<CommandItem
|
<CommandItem key={s.screenId} value={`${s.screenName} ${s.screenCode} ${s.screenId}`}
|
||||||
key={s.screenId}
|
onSelect={() => { onChange(s.screenId); setOpen(false); }} className="text-xs">
|
||||||
value={`${s.screenName} ${s.screenCode} ${s.screenId}`}
|
|
||||||
onSelect={() => { onChange(s.screenId); setOpen(false); }}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Check className={cn("mr-2 h-3 w-3", value === s.screenId ? "opacity-100" : "opacity-0")} />
|
<Check className={cn("mr-2 h-3 w-3", value === s.screenId ? "opacity-100" : "opacity-0")} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{s.screenName}</span>
|
<span className="font-medium">{s.screenName}</span>
|
||||||
|
|
@ -260,17 +179,104 @@ function ScreenCombobox({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 컬럼 편집 카드 (품목/모달/공정 공용) ───
|
||||||
|
function ColumnEditor({ columns, onChange, tableName, title, icon }: {
|
||||||
|
columns: ColumnDef[];
|
||||||
|
onChange: (cols: ColumnDef[]) => void;
|
||||||
|
tableName: string;
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const addColumn = () => onChange([...columns, { name: "", label: "새 컬럼", width: 100, align: "left" }]);
|
||||||
|
const removeColumn = (idx: number) => onChange(columns.filter((_, i) => i !== idx));
|
||||||
|
const updateColumn = (idx: number, field: keyof ColumnDef, value: string | number) => {
|
||||||
|
const next = [...columns];
|
||||||
|
next[idx] = { ...next[idx], [field]: value };
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{icon}
|
||||||
|
<span className="text-sm font-medium">{title}</span>
|
||||||
|
<Badge variant="secondary" className="text-[10px] h-5">{columns.length}개</Badge>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", open && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5">
|
||||||
|
{columns.map((col, idx) => (
|
||||||
|
<Collapsible key={idx}>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button type="button" className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors">
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
|
||||||
|
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span>
|
||||||
|
<span className="text-xs font-medium truncate flex-1 min-w-0">{col.label || col.name || "미설정"}</span>
|
||||||
|
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{col.name || "?"}</Badge>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
onClick={(e) => { e.stopPropagation(); removeColumn(idx); }}
|
||||||
|
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0">
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">컬럼</span>
|
||||||
|
<ColumnCombobox value={col.name} onChange={(v, displayName) => {
|
||||||
|
updateColumn(idx, "name", v);
|
||||||
|
if (!col.label || col.label === "새 컬럼" || col.label === col.name) updateColumn(idx, "label", displayName || v);
|
||||||
|
}} tableName={tableName} placeholder="컬럼 선택" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">표시명</span>
|
||||||
|
<Input value={col.label} onChange={(e) => updateColumn(idx, "label", e.target.value)} className="h-7 text-xs" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">너비</span>
|
||||||
|
<Input type="number" value={col.width || 100} onChange={(e) => updateColumn(idx, "width", parseInt(e.target.value) || 100)} className="h-7 text-xs" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">정렬</span>
|
||||||
|
<Select value={col.align || "left"} onValueChange={(v) => updateColumn(idx, "align", v)}>
|
||||||
|
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="left">좌</SelectItem>
|
||||||
|
<SelectItem value="center">중</SelectItem>
|
||||||
|
<SelectItem value="right">우</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="sm" className="h-7 w-full gap-1 text-xs border-dashed" onClick={addColumn}>
|
||||||
|
<Plus className="h-3 w-3" /> 컬럼 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 메인 컴포넌트 ───
|
// ─── 메인 컴포넌트 ───
|
||||||
export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> = ({
|
export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> = ({ config: configProp, onChange }) => {
|
||||||
config: configProp,
|
|
||||||
onChange,
|
|
||||||
}) => {
|
|
||||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
const [loadingTables, setLoadingTables] = useState(false);
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [columnsOpen, setColumnsOpen] = useState(false);
|
|
||||||
const [dataSourceOpen, setDataSourceOpen] = useState(false);
|
const [dataSourceOpen, setDataSourceOpen] = useState(false);
|
||||||
const [layoutOpen, setLayoutOpen] = useState(false);
|
const [layoutOpen, setLayoutOpen] = useState(false);
|
||||||
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
|
||||||
const config: ItemRoutingConfig = {
|
const config: ItemRoutingConfig = {
|
||||||
...defaultConfig,
|
...defaultConfig,
|
||||||
|
|
@ -278,6 +284,9 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
||||||
modals: { ...defaultConfig.modals, ...configProp?.modals },
|
modals: { ...defaultConfig.modals, ...configProp?.modals },
|
||||||
processColumns: configProp?.processColumns?.length ? configProp.processColumns : defaultConfig.processColumns,
|
processColumns: configProp?.processColumns?.length ? configProp.processColumns : defaultConfig.processColumns,
|
||||||
|
itemDisplayColumns: configProp?.itemDisplayColumns?.length ? configProp.itemDisplayColumns : defaultConfig.itemDisplayColumns,
|
||||||
|
modalDisplayColumns: configProp?.modalDisplayColumns?.length ? configProp.modalDisplayColumns : defaultConfig.modalDisplayColumns,
|
||||||
|
itemFilterConditions: configProp?.itemFilterConditions || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -287,12 +296,7 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
const res = await tableManagementApi.getTableList();
|
const res = await tableManagementApi.getTableList();
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
setTables(
|
setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
|
||||||
res.data.map((t: any) => ({
|
|
||||||
tableName: t.tableName,
|
|
||||||
displayName: t.displayName || t.tableName,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ } finally { setLoadingTables(false); }
|
} catch { /* ignore */ } finally { setLoadingTables(false); }
|
||||||
};
|
};
|
||||||
|
|
@ -301,11 +305,7 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
|
|
||||||
const dispatchConfigEvent = (newConfig: Partial<ItemRoutingConfig>) => {
|
const dispatchConfigEvent = (newConfig: Partial<ItemRoutingConfig>) => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(new CustomEvent("componentConfigChanged", { detail: { config: { ...config, ...newConfig } } }));
|
||||||
new CustomEvent("componentConfigChanged", {
|
|
||||||
detail: { config: { ...config, ...newConfig } },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -316,61 +316,141 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDataSource = (field: string, value: string) => {
|
const updateDataSource = (field: string, value: string) => {
|
||||||
const newDataSource = { ...config.dataSource, [field]: value };
|
const newDS = { ...config.dataSource, [field]: value };
|
||||||
const partial = { dataSource: newDataSource };
|
onChange({ ...configProp, dataSource: newDS });
|
||||||
onChange({ ...configProp, ...partial });
|
dispatchConfigEvent({ dataSource: newDS });
|
||||||
dispatchConfigEvent(partial);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateModals = (field: string, value?: number) => {
|
const updateModals = (field: string, value?: number) => {
|
||||||
const newModals = { ...config.modals, [field]: value };
|
const newM = { ...config.modals, [field]: value };
|
||||||
const partial = { modals: newModals };
|
onChange({ ...configProp, modals: newM });
|
||||||
onChange({ ...configProp, ...partial });
|
dispatchConfigEvent({ modals: newM });
|
||||||
dispatchConfigEvent(partial);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 공정 컬럼 관리
|
// 필터 조건 관리
|
||||||
const addColumn = () => {
|
const filters = config.itemFilterConditions || [];
|
||||||
update({
|
const addFilter = () => update({ itemFilterConditions: [...filters, { column: "", operator: "equals", value: "" }] });
|
||||||
processColumns: [
|
const removeFilter = (idx: number) => update({ itemFilterConditions: filters.filter((_, i) => i !== idx) });
|
||||||
...config.processColumns,
|
const updateFilter = (idx: number, field: keyof ItemFilterCondition, val: string) => {
|
||||||
{ name: "", label: "새 컬럼", width: 100, align: "left" as const },
|
const next = [...filters];
|
||||||
],
|
next[idx] = { ...next[idx], [field]: val };
|
||||||
});
|
update({ itemFilterConditions: next });
|
||||||
};
|
|
||||||
|
|
||||||
const removeColumn = (idx: number) => {
|
|
||||||
update({ processColumns: config.processColumns.filter((_, i) => i !== idx) });
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateColumn = (idx: number, field: keyof ProcessColumnDef, value: string | number) => {
|
|
||||||
const next = [...config.processColumns];
|
|
||||||
next[idx] = { ...next[idx], [field]: value };
|
|
||||||
update({ processColumns: next });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* ─── 1단계: 모달 연동 (Collapsible) ─── */}
|
{/* ─── 품목 목록 모드 ─── */}
|
||||||
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<List className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">품목 목록 모드</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">좌측 품목 목록에 표시할 방식을 선택하세요</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button type="button"
|
||||||
|
className={cn("flex flex-col items-center gap-1 rounded-md border px-3 py-2.5 text-xs transition-colors",
|
||||||
|
(config.itemListMode || "all") === "all" ? "border-primary bg-primary/5 text-primary" : "border-input hover:bg-muted/50")}
|
||||||
|
onClick={() => update({ itemListMode: "all" })}>
|
||||||
|
<span className="font-medium">전체 품목</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">모든 품목 표시</span>
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
className={cn("flex flex-col items-center gap-1 rounded-md border px-3 py-2.5 text-xs transition-colors",
|
||||||
|
config.itemListMode === "registered" ? "border-primary bg-primary/5 text-primary" : "border-input hover:bg-muted/50")}
|
||||||
|
onClick={() => update({ itemListMode: "registered" })}>
|
||||||
|
<span className="font-medium">등록 품목만</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">선택한 품목만 표시</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{config.itemListMode === "registered" && (
|
||||||
|
<p className="text-[10px] text-muted-foreground pt-1">
|
||||||
|
현재 화면 ID를 기준으로 품목 목록이 자동 관리됩니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── 품목 표시 컬럼 ─── */}
|
||||||
|
<ColumnEditor
|
||||||
|
columns={config.itemDisplayColumns || []}
|
||||||
|
onChange={(cols) => update({ itemDisplayColumns: cols })}
|
||||||
|
tableName={config.dataSource.itemTable}
|
||||||
|
title="품목 목록 컬럼"
|
||||||
|
icon={<Eye className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ─── 모달 표시 컬럼 (등록 모드에서만 의미 있지만 항상 설정 가능) ─── */}
|
||||||
|
<ColumnEditor
|
||||||
|
columns={config.modalDisplayColumns || []}
|
||||||
|
onChange={(cols) => update({ modalDisplayColumns: cols })}
|
||||||
|
tableName={config.dataSource.itemTable}
|
||||||
|
title="품목 추가 모달 컬럼"
|
||||||
|
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ─── 품목 필터 조건 ─── */}
|
||||||
|
<Collapsible open={filterOpen} onOpenChange={setFilterOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">품목 필터 조건</span>
|
||||||
|
{filters.length > 0 && <Badge variant="secondary" className="text-[10px] h-5">{filters.length}건</Badge>}
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", filterOpen && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="rounded-b-lg border border-t-0 p-3 space-y-2">
|
||||||
|
<p className="text-[10px] text-muted-foreground">품목 조회 시 자동으로 적용되는 필터 조건입니다</p>
|
||||||
|
{filters.map((f, idx) => (
|
||||||
|
<div key={idx} className="flex items-end gap-1.5 rounded-md border p-2">
|
||||||
|
<div className="flex-1 space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">컬럼</span>
|
||||||
|
<ColumnCombobox value={f.column} onChange={(v) => updateFilter(idx, "column", v)}
|
||||||
|
tableName={config.dataSource.itemTable} placeholder="필터 컬럼" />
|
||||||
|
</div>
|
||||||
|
<div className="w-[90px] space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">조건</span>
|
||||||
|
<Select value={f.operator} onValueChange={(v) => updateFilter(idx, "operator", v)}>
|
||||||
|
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="equals">같음</SelectItem>
|
||||||
|
<SelectItem value="contains">포함</SelectItem>
|
||||||
|
<SelectItem value="not_equals">다름</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">값</span>
|
||||||
|
<Input value={f.value} onChange={(e) => updateFilter(idx, "value", e.target.value)}
|
||||||
|
placeholder="필터값" className="h-7 text-xs" />
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
onClick={() => removeFilter(idx)}
|
||||||
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0">
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="sm" className="h-7 w-full gap-1 text-xs border-dashed" onClick={addFilter}>
|
||||||
|
<Plus className="h-3 w-3" /> 필터 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* ─── 모달 연동 ─── */}
|
||||||
<Collapsible open={modalOpen} onOpenChange={setModalOpen}>
|
<Collapsible open={modalOpen} onOpenChange={setModalOpen}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">모달 연동</span>
|
<span className="text-sm font-medium">모달 연동</span>
|
||||||
<Badge variant="secondary" className="text-[10px] h-5">
|
<Badge variant="secondary" className="text-[10px] h-5">
|
||||||
{[config.modals.versionAddScreenId, config.modals.processAddScreenId, config.modals.processEditScreenId].filter(Boolean).length}개 설정됨
|
{[config.modals.versionAddScreenId, config.modals.processAddScreenId, config.modals.processEditScreenId].filter(Boolean).length}개
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", modalOpen && "rotate-180")} />
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
||||||
modalOpen && "rotate-180",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
|
|
@ -379,291 +459,103 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] text-muted-foreground">버전 추가</span>
|
<span className="text-[10px] text-muted-foreground">버전 추가</span>
|
||||||
<ScreenCombobox
|
<ScreenCombobox value={config.modals.versionAddScreenId} onChange={(v) => updateModals("versionAddScreenId", v)} />
|
||||||
value={config.modals.versionAddScreenId}
|
|
||||||
onChange={(v) => updateModals("versionAddScreenId", v)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] text-muted-foreground">공정 추가</span>
|
<span className="text-[10px] text-muted-foreground">공정 추가</span>
|
||||||
<ScreenCombobox
|
<ScreenCombobox value={config.modals.processAddScreenId} onChange={(v) => updateModals("processAddScreenId", v)} />
|
||||||
value={config.modals.processAddScreenId}
|
|
||||||
onChange={(v) => updateModals("processAddScreenId", v)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] text-muted-foreground">공정 수정</span>
|
<span className="text-[10px] text-muted-foreground">공정 수정</span>
|
||||||
<ScreenCombobox
|
<ScreenCombobox value={config.modals.processEditScreenId} onChange={(v) => updateModals("processEditScreenId", v)} />
|
||||||
value={config.modals.processEditScreenId}
|
|
||||||
onChange={(v) => updateModals("processEditScreenId", v)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{/* ─── 2단계: 공정 테이블 컬럼 (Collapsible + 접이식 카드) ─── */}
|
{/* ─── 공정 테이블 컬럼 ─── */}
|
||||||
<Collapsible open={columnsOpen} onOpenChange={setColumnsOpen}>
|
<ColumnEditor
|
||||||
<CollapsibleTrigger asChild>
|
columns={config.processColumns}
|
||||||
<button
|
onChange={(cols) => update({ processColumns: cols })}
|
||||||
type="button"
|
tableName={config.dataSource.routingDetailTable}
|
||||||
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
title="공정 테이블 컬럼"
|
||||||
>
|
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
|
||||||
<div className="flex items-center gap-2">
|
/>
|
||||||
<Columns className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm font-medium">테이블 컬럼</span>
|
|
||||||
<Badge variant="secondary" className="text-[10px] h-5">
|
|
||||||
{config.processColumns.length}개
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
||||||
columnsOpen && "rotate-180",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5">
|
|
||||||
<p className="text-[10px] text-muted-foreground mb-1">공정 순서 테이블에 표시할 컬럼</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{config.processColumns.map((col, idx) => (
|
|
||||||
<Collapsible key={idx}>
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
|
|
||||||
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span>
|
|
||||||
<span className="text-xs font-medium truncate flex-1 min-w-0">{col.name || "미설정"}</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground truncate max-w-[60px] shrink-0">{col.label}</span>
|
|
||||||
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{col.align || "left"}</Badge>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => { e.stopPropagation(); removeColumn(idx); }}
|
|
||||||
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<span className="text-[10px] text-muted-foreground">컬럼명</span>
|
|
||||||
<Input
|
|
||||||
value={col.name}
|
|
||||||
onChange={(e) => updateColumn(idx, "name", e.target.value)}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
placeholder="컬럼명"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<span className="text-[10px] text-muted-foreground">표시명</span>
|
|
||||||
<Input
|
|
||||||
value={col.label}
|
|
||||||
onChange={(e) => updateColumn(idx, "label", e.target.value)}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
placeholder="표시명"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<span className="text-[10px] text-muted-foreground">너비</span>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={col.width || 100}
|
|
||||||
onChange={(e) => updateColumn(idx, "width", parseInt(e.target.value) || 100)}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
placeholder="100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<span className="text-[10px] text-muted-foreground">정렬</span>
|
|
||||||
<Select
|
|
||||||
value={col.align || "left"}
|
|
||||||
onValueChange={(v) => updateColumn(idx, "align", v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-7 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="left">좌</SelectItem>
|
|
||||||
<SelectItem value="center">중</SelectItem>
|
|
||||||
<SelectItem value="right">우</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</div>
|
|
||||||
</Collapsible>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-full gap-1 text-xs border-dashed"
|
|
||||||
onClick={addColumn}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
컬럼 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
{/* ─── 3단계: 데이터 소스 (Collapsible) ─── */}
|
{/* ─── 데이터 소스 ─── */}
|
||||||
<Collapsible open={dataSourceOpen} onOpenChange={setDataSourceOpen}>
|
<Collapsible open={dataSourceOpen} onOpenChange={setDataSourceOpen}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="h-4 w-4 text-muted-foreground" />
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">데이터 소스 설정</span>
|
<span className="text-sm font-medium">데이터 소스 설정</span>
|
||||||
{config.dataSource.itemTable && (
|
{config.dataSource.itemTable && (
|
||||||
<Badge variant="secondary" className="text-[10px] h-5 truncate max-w-[100px]">
|
<Badge variant="secondary" className="text-[10px] h-5 truncate max-w-[100px]">{config.dataSource.itemTable}</Badge>
|
||||||
{config.dataSource.itemTable}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", dataSourceOpen && "rotate-180")} />
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
||||||
dataSourceOpen && "rotate-180"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">품목 테이블</span>
|
<span className="text-xs text-muted-foreground">품목 테이블</span>
|
||||||
<TableCombobox
|
<TableCombobox value={config.dataSource.itemTable} onChange={(v) => updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
|
||||||
value={config.dataSource.itemTable}
|
|
||||||
onChange={(v) => updateDataSource("itemTable", v)}
|
|
||||||
tables={tables}
|
|
||||||
loading={loadingTables}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">품목명 컬럼</span>
|
<span className="text-xs text-muted-foreground">품목명 컬럼</span>
|
||||||
<ColumnCombobox
|
<ColumnCombobox value={config.dataSource.itemNameColumn} onChange={(v) => updateDataSource("itemNameColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목명" />
|
||||||
value={config.dataSource.itemNameColumn}
|
|
||||||
onChange={(v) => updateDataSource("itemNameColumn", v)}
|
|
||||||
tableName={config.dataSource.itemTable}
|
|
||||||
placeholder="품목명"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">품목코드 컬럼</span>
|
<span className="text-xs text-muted-foreground">품목코드 컬럼</span>
|
||||||
<ColumnCombobox
|
<ColumnCombobox value={config.dataSource.itemCodeColumn} onChange={(v) => updateDataSource("itemCodeColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목코드" />
|
||||||
value={config.dataSource.itemCodeColumn}
|
|
||||||
onChange={(v) => updateDataSource("itemCodeColumn", v)}
|
|
||||||
tableName={config.dataSource.itemTable}
|
|
||||||
placeholder="품목코드"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 pt-2">
|
<div className="space-y-1 pt-2">
|
||||||
<span className="text-xs text-muted-foreground">라우팅 버전 테이블</span>
|
<span className="text-xs text-muted-foreground">라우팅 버전 테이블</span>
|
||||||
<TableCombobox
|
<TableCombobox value={config.dataSource.routingVersionTable} onChange={(v) => updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
|
||||||
value={config.dataSource.routingVersionTable}
|
|
||||||
onChange={(v) => updateDataSource("routingVersionTable", v)}
|
|
||||||
tables={tables}
|
|
||||||
loading={loadingTables}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">품목 FK 컬럼</span>
|
<span className="text-xs text-muted-foreground">품목 FK 컬럼</span>
|
||||||
<ColumnCombobox
|
<ColumnCombobox value={config.dataSource.routingVersionFkColumn} onChange={(v) => updateDataSource("routingVersionFkColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="FK 컬럼" />
|
||||||
value={config.dataSource.routingVersionFkColumn}
|
|
||||||
onChange={(v) => updateDataSource("routingVersionFkColumn", v)}
|
|
||||||
tableName={config.dataSource.routingVersionTable}
|
|
||||||
placeholder="FK 컬럼"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">버전명 컬럼</span>
|
<span className="text-xs text-muted-foreground">버전명 컬럼</span>
|
||||||
<ColumnCombobox
|
<ColumnCombobox value={config.dataSource.routingVersionNameColumn} onChange={(v) => updateDataSource("routingVersionNameColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="버전명" />
|
||||||
value={config.dataSource.routingVersionNameColumn}
|
|
||||||
onChange={(v) => updateDataSource("routingVersionNameColumn", v)}
|
|
||||||
tableName={config.dataSource.routingVersionTable}
|
|
||||||
placeholder="버전명"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 pt-2">
|
<div className="space-y-1 pt-2">
|
||||||
<span className="text-xs text-muted-foreground">라우팅 상세 테이블</span>
|
<span className="text-xs text-muted-foreground">라우팅 상세 테이블</span>
|
||||||
<TableCombobox
|
<TableCombobox value={config.dataSource.routingDetailTable} onChange={(v) => updateDataSource("routingDetailTable", v)} tables={tables} loading={loadingTables} />
|
||||||
value={config.dataSource.routingDetailTable}
|
|
||||||
onChange={(v) => updateDataSource("routingDetailTable", v)}
|
|
||||||
tables={tables}
|
|
||||||
loading={loadingTables}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">버전 FK 컬럼</span>
|
<span className="text-xs text-muted-foreground">버전 FK 컬럼</span>
|
||||||
<ColumnCombobox
|
<ColumnCombobox value={config.dataSource.routingDetailFkColumn} onChange={(v) => updateDataSource("routingDetailFkColumn", v)} tableName={config.dataSource.routingDetailTable} placeholder="FK 컬럼" />
|
||||||
value={config.dataSource.routingDetailFkColumn}
|
|
||||||
onChange={(v) => updateDataSource("routingDetailFkColumn", v)}
|
|
||||||
tableName={config.dataSource.routingDetailTable}
|
|
||||||
placeholder="FK 컬럼"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 pt-2">
|
<div className="space-y-1 pt-2">
|
||||||
<span className="text-xs text-muted-foreground">공정 마스터 테이블</span>
|
<span className="text-xs text-muted-foreground">공정 마스터 테이블</span>
|
||||||
<TableCombobox
|
<TableCombobox value={config.dataSource.processTable} onChange={(v) => updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
|
||||||
value={config.dataSource.processTable}
|
|
||||||
onChange={(v) => updateDataSource("processTable", v)}
|
|
||||||
tables={tables}
|
|
||||||
loading={loadingTables}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">공정명 컬럼</span>
|
<span className="text-xs text-muted-foreground">공정명 컬럼</span>
|
||||||
<ColumnCombobox
|
<ColumnCombobox value={config.dataSource.processNameColumn} onChange={(v) => updateDataSource("processNameColumn", v)} tableName={config.dataSource.processTable} placeholder="공정명" />
|
||||||
value={config.dataSource.processNameColumn}
|
|
||||||
onChange={(v) => updateDataSource("processNameColumn", v)}
|
|
||||||
tableName={config.dataSource.processTable}
|
|
||||||
placeholder="공정명"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">공정코드 컬럼</span>
|
<span className="text-xs text-muted-foreground">공정코드 컬럼</span>
|
||||||
<ColumnCombobox
|
<ColumnCombobox value={config.dataSource.processCodeColumn} onChange={(v) => updateDataSource("processCodeColumn", v)} tableName={config.dataSource.processTable} placeholder="공정코드" />
|
||||||
value={config.dataSource.processCodeColumn}
|
|
||||||
onChange={(v) => updateDataSource("processCodeColumn", v)}
|
|
||||||
tableName={config.dataSource.processTable}
|
|
||||||
placeholder="공정코드"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{/* ─── 4단계: 레이아웃 & 기타 (Collapsible) ─── */}
|
{/* ─── 레이아웃 & 기타 ─── */}
|
||||||
<Collapsible open={layoutOpen} onOpenChange={setLayoutOpen}>
|
<Collapsible open={layoutOpen} onOpenChange={setLayoutOpen}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">레이아웃 & 기타</span>
|
<span className="text-sm font-medium">레이아웃 & 기타</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", layoutOpen && "rotate-180")} />
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
||||||
layoutOpen && "rotate-180"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
|
|
@ -673,76 +565,38 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
<span className="text-xs text-muted-foreground">좌측 패널 비율 (%)</span>
|
<span className="text-xs text-muted-foreground">좌측 패널 비율 (%)</span>
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5">품목 목록 패널의 너비</p>
|
<p className="text-[10px] text-muted-foreground mt-0.5">품목 목록 패널의 너비</p>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input type="number" min={20} max={60} value={config.splitRatio || 40}
|
||||||
type="number"
|
onChange={(e) => update({ splitRatio: parseInt(e.target.value) || 40 })} className="h-7 w-[80px] text-xs" />
|
||||||
min={20}
|
|
||||||
max={60}
|
|
||||||
value={config.splitRatio || 40}
|
|
||||||
onChange={(e) => update({ splitRatio: parseInt(e.target.value) || 40 })}
|
|
||||||
className="h-7 w-[80px] text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<span className="text-xs text-muted-foreground">좌측 패널 제목</span>
|
<span className="text-xs text-muted-foreground">좌측 패널 제목</span>
|
||||||
<Input
|
<Input value={config.leftPanelTitle || ""} onChange={(e) => update({ leftPanelTitle: e.target.value })} placeholder="품목 목록" className="h-7 w-[140px] text-xs" />
|
||||||
value={config.leftPanelTitle || ""}
|
|
||||||
onChange={(e) => update({ leftPanelTitle: e.target.value })}
|
|
||||||
placeholder="품목 목록"
|
|
||||||
className="h-7 w-[140px] text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<span className="text-xs text-muted-foreground">우측 패널 제목</span>
|
<span className="text-xs text-muted-foreground">우측 패널 제목</span>
|
||||||
<Input
|
<Input value={config.rightPanelTitle || ""} onChange={(e) => update({ rightPanelTitle: e.target.value })} placeholder="공정 순서" className="h-7 w-[140px] text-xs" />
|
||||||
value={config.rightPanelTitle || ""}
|
|
||||||
onChange={(e) => update({ rightPanelTitle: e.target.value })}
|
|
||||||
placeholder="공정 순서"
|
|
||||||
className="h-7 w-[140px] text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<span className="text-xs text-muted-foreground">버전 추가 버튼 텍스트</span>
|
<span className="text-xs text-muted-foreground">버전 추가 버튼 텍스트</span>
|
||||||
<Input
|
<Input value={config.versionAddButtonText || ""} onChange={(e) => update({ versionAddButtonText: e.target.value })} placeholder="+ 라우팅 버전 추가" className="h-7 w-[140px] text-xs" />
|
||||||
value={config.versionAddButtonText || ""}
|
|
||||||
onChange={(e) => update({ versionAddButtonText: e.target.value })}
|
|
||||||
placeholder="+ 라우팅 버전 추가"
|
|
||||||
className="h-7 w-[140px] text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<span className="text-xs text-muted-foreground">공정 추가 버튼 텍스트</span>
|
<span className="text-xs text-muted-foreground">공정 추가 버튼 텍스트</span>
|
||||||
<Input
|
<Input value={config.processAddButtonText || ""} onChange={(e) => update({ processAddButtonText: e.target.value })} placeholder="+ 공정 추가" className="h-7 w-[140px] text-xs" />
|
||||||
value={config.processAddButtonText || ""}
|
|
||||||
onChange={(e) => update({ processAddButtonText: e.target.value })}
|
|
||||||
placeholder="+ 공정 추가"
|
|
||||||
className="h-7 w-[140px] text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm">첫 번째 버전 자동 선택</p>
|
<p className="text-sm">첫 번째 버전 자동 선택</p>
|
||||||
<p className="text-[11px] text-muted-foreground">품목 선택 시 첫 버전을 자동으로 선택해요</p>
|
<p className="text-[11px] text-muted-foreground">품목 선택 시 첫 버전을 자동으로 선택해요</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch checked={config.autoSelectFirstVersion !== false} onCheckedChange={(checked) => update({ autoSelectFirstVersion: checked })} />
|
||||||
checked={config.autoSelectFirstVersion !== false}
|
|
||||||
onCheckedChange={(checked) => update({ autoSelectFirstVersion: checked })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm">읽기 전용</p>
|
<p className="text-sm">읽기 전용</p>
|
||||||
<p className="text-[11px] text-muted-foreground">추가/수정/삭제 버튼을 숨겨요</p>
|
<p className="text-[11px] text-muted-foreground">추가/수정/삭제 버튼을 숨겨요</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch checked={config.readonly || false} onCheckedChange={(checked) => update({ readonly: checked })} />
|
||||||
checked={config.readonly || false}
|
|
||||||
onCheckedChange={(checked) => update({ readonly: checked })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
|
|
@ -752,5 +606,4 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
};
|
};
|
||||||
|
|
||||||
V2ItemRoutingConfigPanel.displayName = "V2ItemRoutingConfigPanel";
|
V2ItemRoutingConfigPanel.displayName = "V2ItemRoutingConfigPanel";
|
||||||
|
|
||||||
export default V2ItemRoutingConfigPanel;
|
export default V2ItemRoutingConfigPanel;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -157,10 +157,13 @@ function SortableColumnRow({
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={col.width || ""}
|
value={col.width || ""}
|
||||||
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
|
onChange={(e) => onWidthChange(parseInt(e.target.value) || 20)}
|
||||||
placeholder="너비"
|
placeholder="20"
|
||||||
className="h-6 w-14 shrink-0 text-xs"
|
className="h-6 w-14 shrink-0 text-xs"
|
||||||
|
min={5}
|
||||||
|
max={100}
|
||||||
/>
|
/>
|
||||||
|
<span className="text-muted-foreground shrink-0 text-[10px]">%</span>
|
||||||
{isNumeric && (
|
{isNumeric && (
|
||||||
<label
|
<label
|
||||||
className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]"
|
className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]"
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,9 @@ export async function executeTaskList(
|
||||||
}
|
}
|
||||||
|
|
||||||
case "custom-event":
|
case "custom-event":
|
||||||
if (task.eventName) {
|
if (task.flowId) {
|
||||||
|
await apiClient.post(`/dataflow/node-flows/${task.flowId}/execute`, {});
|
||||||
|
} else if (task.eventName) {
|
||||||
publish(task.eventName, task.eventPayload ?? {});
|
publish(task.eventName, task.eventPayload ?? {});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue