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:
DDD1542 2026-03-13 17:46:06 +09:00
commit b8f5d4be4c
175 changed files with 22560 additions and 4586 deletions

View File

@ -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 파싱 에러 방지).

View File

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

View File

@ -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 컴포넌트로 직접 구현하는 것 금지

4
.gitignore vendored
View File

@ -31,6 +31,10 @@ dist/
build/ build/
build/Release build/Release
# Gradle
.gradle/
**/backend/.gradle/
# Cache # Cache
.npm .npm
.eslintcache .eslintcache

View File

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

View File

@ -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); // 카테고리 값 연쇄관계

View File

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

View File

@ -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: "감사 로그 기록 실패" });
}
};

View File

@ -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,9 +51,7 @@ export class AuthController {
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`); logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
// 사용자의 첫 번째 접근 가능한 메뉴 조회 // 메뉴 조회를 위한 공통 파라미터
let firstMenuPath: string | null = null;
try {
const { AdminService } = await import("../services/adminService"); const { AdminService } = await import("../services/adminService");
const paramMap = { const paramMap = {
userId: loginResult.userInfo.userId, userId: loginResult.userInfo.userId,
@ -61,18 +60,15 @@ export class AuthController {
userLang: "ko", userLang: "ko",
}; };
// 사용자의 첫 번째 접근 가능한 메뉴 조회
let firstMenuPath: string | null = null;
try {
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 {

View File

@ -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: "삭제되었습니다",

View File

@ -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: "코드 삭제 성공",

View File

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

View File

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

View File

@ -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: "테스트 채번 규칙이 삭제되었습니다",

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "데이터 검증 중 오류가 발생했습니다." });
}
}

View File

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

View File

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

View File

@ -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: "플로우가 삭제되었습니다.",

View File

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

View File

@ -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,14 +251,15 @@ 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;
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
columns.push(`"${ag.targetColumn}"`);
values.push(sharedCodes[ag.targetColumn]);
} else if (!ag.shareAcrossItems) {
try { try {
const generatedCode = await numberingRuleService.allocateCode( const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
@ -244,6 +271,20 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); 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(", ");
@ -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,36 +569,43 @@ 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;
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
columns.push(`"${ag.targetColumn}"`);
values.push(sharedCodes[ag.targetColumn]);
} else if (!ag.shareAcrossItems) {
try { try {
const generatedCode = await numberingRuleService.allocateCode( const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId, ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
companyCode,
{ ...fieldValues, ...item },
); );
columns.push(`"${ag.targetColumn}"`); columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode); values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 채번 완료", { logger.info("[pop/execute-action] 채번 완료", {
ruleId: ag.numberingRuleId, ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode,
targetColumn: ag.targetColumn,
generatedCode,
}); });
} catch (err: any) { } catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", { logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
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(", ");
@ -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++;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = '*')`;

View File

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

View File

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

View File

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

View File

@ -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` 필수 (멀티테넌시)

View File

@ -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 (타임라인, 모바일 스타일)로 개별 개발 필요

View File

@ -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 구조만
**별도 설정 필요한 것**: 저장 테이블, 버튼 액션, 조건 처리, 다중 행 처리

View File

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

View File

@ -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행)과 동일

View File

@ -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 불필요

View File

@ -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단계 검증은 수동 테스트 필요 |

View File

@ -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` 두 곳의 동일 패턴을 일관되게 수정

View File

@ -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` — 화면에만 보이는 값, 들여쓰기 포함
- 데이터 무결성에 영향 없음

View File

@ -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단계 문서 정리 완료 |

View File

@ -0,0 +1,374 @@
# [계획서] 렉 구조 위치코드/위치명 포맷 사용자 설정
> 관련 문서: [맥락노트](./LFC[맥락]-위치포맷-사용자설정.md) | [체크리스트](./LFC[체크]-위치포맷-사용자설정.md)
## 개요
물류관리 > 창고정보 관리 > 렉 구조 등록 모달에서 생성되는 **위치코드(`location_code`)와 위치명(`location_name`)의 포맷을 관리자가 화면 디자이너에서 자유롭게 설정**할 수 있도록 합니다.
현재 위치코드/위치명 생성 로직은 하드코딩되어 있어, 구분자("-"), 세그먼트 순서(창고코드-층-구역-열-단), 한글 접미사("구역", "열", "단") 등을 변경할 수 없습니다.
---
## 현재 동작
### 1. 타입/설정에 패턴 필드가 정의되어 있지만 사용하지 않음
`types.ts`(57~58행)에 `codePattern`/`namePattern`이 정의되어 있고, `config.ts`(14~15행)에 기본값도 있으나, 실제 컴포넌트에서는 **전혀 참조하지 않음**:
```typescript
// types.ts:57~58 - 정의만 있음
codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}")
namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단")
// config.ts:14~15 - 기본값만 있음
codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}",
namePattern: "{zone}구역-{row:02d}열-{level}단",
```
### 2. 위치 코드 생성 하드코딩 (RackStructureComponent.tsx:494~510)
```tsx
const generateLocationCode = useCallback(
(row: number, level: number): { code: string; name: string } => {
const warehouseCode = context?.warehouseCode || "WH001";
const floor = context?.floor;
const zone = context?.zone || "A";
const floorPrefix = floor ? `${floor}` : "";
const code = `${warehouseCode}-${floorPrefix}${zone}-${row.toString().padStart(2, "0")}-${level}`;
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
const floorNamePrefix = floor ? `${floor}-` : "";
const name = `${floorNamePrefix}${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`;
return { code, name };
},
[context],
);
```
### 3. ConfigPanel에 포맷 관련 설정 UI 없음
`RackStructureConfigPanel.tsx`에는 필드 매핑, 제한 설정, UI 설정만 있고, `codePattern`/`namePattern`을 편집하는 UI가 없음.
---
## 변경 후 동작
### 1. ConfigPanel에 "포맷 설정" 섹션 추가
화면 디자이너 좌측 속성 패널의 v2-rack-structure ConfigPanel에 새 섹션이 추가됨:
- 위치코드/위치명 각각의 세그먼트 목록
- 최상단에 컬럼 헤더(`라벨` / `구분` / `자릿수`) 표시
- 세그먼트별로 **드래그 순서변경**, **체크박스로 한글 라벨 표시/숨김**, **라벨 텍스트 입력**, **구분자 입력**, **자릿수 입력**
- 자릿수 필드는 숫자 타입(열, 단)만 활성화, 나머지(창고코드, 층, 구역)는 비활성화
- 변경 시 실시간 미리보기로 결과 확인
### 2. 컴포넌트에서 config 기반 코드 생성
`RackStructureComponent``generateLocationCode`가 하드코딩 대신 `config.formatConfig`의 세그먼트 배열을 순회하며 동적으로 코드/이름 생성.
### 3. 기본값은 현재 하드코딩과 동일
`formatConfig`가 설정되지 않으면 기본 세그먼트가 적용되어 현재와 완전히 동일한 결과 생성 (하위 호환).
---
## 시각적 예시
### ConfigPanel UI (화면 디자이너 좌측 속성 패널)
```
┌─ 포맷 설정 ──────────────────────────────────────────────┐
│ │
│ 위치코드 포맷 │
│ 라벨 구분 자릿수 │
│ ┌──────────────────────────────────────────────────┐ │
│ │ ☰ 창고코드 [✓] [ ] [ - ] [ 0 ] (비활성) │ │
│ │ ☰ 층 [✓] [ 층 ] [ ] [ 0 ] (비활성) │ │
│ │ ☰ 구역 [✓] [구역 ] [ - ] [ 0 ] (비활성) │ │
│ │ ☰ 열 [✓] [ ] [ - ] [ 2 ] │ │
│ │ ☰ 단 [✓] [ ] [ ] [ 0 ] │ │
│ └──────────────────────────────────────────────────┘ │
│ 미리보기: WH001-1층A구역-01-1 │
│ │
│ 위치명 포맷 │
│ 라벨 구분 자릿수 │
│ ┌──────────────────────────────────────────────────┐ │
│ │ ☰ 구역 [✓] [구역 ] [ - ] [ 0 ] (비활성) │ │
│ │ ☰ 열 [✓] [ 열 ] [ - ] [ 2 ] │ │
│ │ ☰ 단 [✓] [ 단 ] [ ] [ 0 ] │ │
│ └──────────────────────────────────────────────────┘ │
│ 미리보기: A구역-01열-1단 │
│ │
└───────────────────────────────────────────────────────────┘
```
### 사용자 커스터마이징 예시
| 설정 변경 | 위치코드 결과 | 위치명 결과 |
|-----------|-------------|------------|
| 기본값 (변경 없음) | `WH001-1층A구역-01-1` | `A구역-01열-1단` |
| 구분자를 "/" 로 변경 | `WH001/1층A구역/01/1` | `A구역/01열/1단` |
| 층 라벨 해제 | `WH001-1A구역-01-1` | `A구역-01열-1단` |
| 구역+열 라벨 해제 | `WH001-1층A-01-1` | `A-01-1단` |
| 순서를 구역→층→열→단 으로 변경 | `WH001-A구역1층-01-1` | `A구역-1층-01열-1단` |
| 한글 라벨 모두 해제 | `WH001-1A-01-1` | `A-01-1` |
---
## 아키텍처
### 데이터 흐름
```mermaid
flowchart TD
A["관리자: 화면 디자이너 열기"] --> B["RackStructureConfigPanel\n포맷 세그먼트 편집"]
B --> C["componentConfig.formatConfig\n에 세그먼트 배열 저장"]
C --> D["screen_layouts_v2.layout_data\nDB JSONB에 영구 저장"]
D --> E["엔드유저: 렉 구조 모달 열기"]
E --> F["RackStructureComponent\nconfig.formatConfig 읽기"]
F --> G["generateLocationCode\n세그먼트 배열 순회하며 동적 생성"]
G --> H["미리보기 테이블에 표시\nlocation_code / location_name"]
```
### 컴포넌트 관계
```mermaid
graph LR
subgraph designer ["화면 디자이너 (관리자)"]
CP["RackStructureConfigPanel"]
FE["FormatSegmentEditor\n(신규 서브컴포넌트)"]
CP --> FE
end
subgraph runtime ["렉 구조 모달 (엔드유저)"]
RC["RackStructureComponent"]
GL["generateLocationCode\n(세그먼트 기반으로 교체)"]
RC --> GL
end
subgraph storage ["저장소"]
DB["screen_layouts_v2\nlayout_data.overrides.formatConfig"]
end
FE -->|"onChange → componentConfig"| DB
DB -->|"config prop 전달"| RC
```
> 노란색 영역은 없음. 기존 설정-저장-전달 파이프라인을 그대로 활용.
---
## 변경 대상 파일
| 파일 | 수정 내용 | 수정 규모 |
|------|----------|----------|
| `frontend/lib/registry/components/v2-rack-structure/types.ts` | `FormatSegment`, `LocationFormatConfig` 타입 추가, `RackStructureComponentConfig``formatConfig` 필드 추가 | ~25줄 |
| `frontend/lib/registry/components/v2-rack-structure/config.ts` | 기본 코드/이름 세그먼트 상수 정의 | ~40줄 |
| `frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx` | **신규** - grid 레이아웃 + 컬럼 헤더 + 드래그 순서변경 + showLabel 체크박스 + 라벨/구분/자릿수 고정 필드 + 자릿수 비숫자 타입 비활성화 + 미리보기 | ~200줄 |
| `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | "포맷 설정" 섹션 추가, FormatSegmentEditor 배치 | ~30줄 |
| `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | `generateLocationCode`를 세그먼트 기반으로 교체 | ~20줄 |
### 변경하지 않는 파일
- `buttonActions.ts` - 생성된 `location_code`/`location_name`을 그대로 저장하므로 변경 불필요
- 백엔드 전체 - 포맷은 프론트엔드에서만 처리
- DB 스키마 - `screen_layouts_v2.layout_data` JSONB에 자동 포함
---
## 코드 설계
### 1. 타입 추가 (types.ts)
```typescript
// 포맷 세그먼트 (위치코드/위치명의 각 구성요소)
export interface FormatSegment {
type: 'warehouseCode' | 'floor' | 'zone' | 'row' | 'level';
enabled: boolean; // 이 세그먼트를 포함할지 여부
showLabel: boolean; // 한글 라벨 표시 여부 (false면 값에서 라벨 제거)
label: string; // 한글 라벨 (예: "층", "구역", "열", "단")
separatorAfter: string; // 이 세그먼트 뒤의 구분자 (예: "-", "/", "")
pad: number; // 최소 자릿수 (0 = 그대로, 2 = "01"처럼 2자리 맞춤)
}
// 위치코드 + 위치명 포맷 설정
export interface LocationFormatConfig {
codeSegments: FormatSegment[];
nameSegments: FormatSegment[];
}
```
`RackStructureComponentConfig`에 필드 추가:
```typescript
export interface RackStructureComponentConfig {
// ... 기존 필드 유지 ...
codePattern?: string; // (기존, 하위 호환용 유지)
namePattern?: string; // (기존, 하위 호환용 유지)
formatConfig?: LocationFormatConfig; // 신규: 구조화된 포맷 설정
}
```
### 2. 기본 세그먼트 상수 (config.ts)
```typescript
import { FormatSegment, LocationFormatConfig } from "./types";
// 위치코드 기본 세그먼트 (현재 하드코딩과 동일한 결과)
export const defaultCodeSegments: FormatSegment[] = [
{ type: "warehouseCode", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 0 },
{ type: "floor", enabled: true, showLabel: true, label: "층", separatorAfter: "", pad: 0 },
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
{ type: "row", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 2 },
{ type: "level", enabled: true, showLabel: false, label: "", separatorAfter: "", pad: 0 },
];
// 위치명 기본 세그먼트 (현재 하드코딩과 동일한 결과)
export const defaultNameSegments: FormatSegment[] = [
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
{ type: "row", enabled: true, showLabel: true, label: "열", separatorAfter: "-", pad: 2 },
{ type: "level", enabled: true, showLabel: true, label: "단", separatorAfter: "", pad: 0 },
];
export const defaultFormatConfig: LocationFormatConfig = {
codeSegments: defaultCodeSegments,
nameSegments: defaultNameSegments,
};
```
### 3. 세그먼트 기반 문자열 생성 함수 (config.ts)
```typescript
// context 값에 포함된 한글 접미사 ("1층", "A구역")
const KNOWN_SUFFIXES: Partial<Record<FormatSegmentType, string>> = {
floor: "층",
zone: "구역",
};
function stripKnownSuffix(type: FormatSegmentType, val: string): string {
const suffix = KNOWN_SUFFIXES[type];
if (suffix && val.endsWith(suffix)) {
return val.slice(0, -suffix.length);
}
return val;
}
export function buildFormattedString(
segments: FormatSegment[],
values: Record<string, string>,
): string {
const activeSegments = segments.filter(
(seg) => seg.enabled && values[seg.type],
);
return activeSegments
.map((seg, idx) => {
// 1) 원본 값에서 한글 접미사를 먼저 벗겨냄 ("A구역" → "A", "1층" → "1")
let val = stripKnownSuffix(seg.type, values[seg.type]);
// 2) showLabel이 켜져 있고 label이 있으면 붙임
if (seg.showLabel && seg.label) {
val += seg.label;
}
if (seg.pad > 0 && !isNaN(Number(val))) {
val = val.padStart(seg.pad, "0");
}
if (idx < activeSegments.length - 1) {
val += seg.separatorAfter;
}
return val;
})
.join("");
}
```
### 4. generateLocationCode 교체 (RackStructureComponent.tsx:494~510)
```typescript
// 변경 전 (하드코딩)
const generateLocationCode = useCallback(
(row: number, level: number): { code: string; name: string } => {
const warehouseCode = context?.warehouseCode || "WH001";
const floor = context?.floor;
const zone = context?.zone || "A";
const floorPrefix = floor ? `${floor}` : "";
const code = `${warehouseCode}-${floorPrefix}${zone}-...`;
// ...
},
[context],
);
// 변경 후 (세그먼트 기반)
const formatConfig = config.formatConfig || defaultFormatConfig;
const generateLocationCode = useCallback(
(row: number, level: number): { code: string; name: string } => {
const values: Record<string, string> = {
warehouseCode: context?.warehouseCode || "WH001",
floor: context?.floor || "",
zone: context?.zone || "A",
row: row.toString(),
level: level.toString(),
};
const code = buildFormattedString(formatConfig.codeSegments, values);
const name = buildFormattedString(formatConfig.nameSegments, values);
return { code, name };
},
[context, formatConfig],
);
```
### 5. ConfigPanel에 포맷 설정 섹션 추가 (RackStructureConfigPanel.tsx:284행 위)
```tsx
{/* 포맷 설정 - UI 설정 섹션 아래에 추가 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-gray-700">포맷 설정</div>
<p className="text-xs text-gray-500">
위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고,
구분자/라벨을 편집할 수 있습니다
</p>
<FormatSegmentEditor
label="위치코드 포맷"
segments={formatConfig.codeSegments}
onChange={(segs) => handleFormatChange("codeSegments", segs)}
sampleValues={sampleValues}
/>
<FormatSegmentEditor
label="위치명 포맷"
segments={formatConfig.nameSegments}
onChange={(segs) => handleFormatChange("nameSegments", segs)}
sampleValues={sampleValues}
/>
</div>
```
### 6. FormatSegmentEditor 서브컴포넌트 (신규 파일)
- `@dnd-kit/core` + `@dnd-kit/sortable`로 드래그 순서변경
- 프로젝트 표준 패턴: `useSortable`, `DndContext`, `SortableContext` 사용
- **grid 레이아웃** (`grid-cols-[16px_56px_18px_1fr_1fr_1fr]`): 드래그핸들 / 타입명 / 체크박스 / 라벨 / 구분 / 자릿수
- 최상단에 **컬럼 헤더** (`라벨` / `구분` / `자릿수`) 표시 — 각 행에서 텍스트 라벨 제거하여 공간 절약
- 라벨/구분/자릿수 3개 필드는 **항상 고정 표시** (빈 값이어도 입력 필드가 사라지지 않음)
- 자릿수 필드는 숫자 타입(열, 단)만 활성화, 비숫자 타입은 `disabled` + 회색 배경
- 하단에 `buildFormattedString`으로 실시간 미리보기 표시
---
## 설계 원칙
- `formatConfig` 미설정 시 `defaultFormatConfig` 적용으로 **기존 동작 100% 유지** (하위 호환)
- 포맷 설정은 **화면 디자이너 ConfigPanel에서만** 편집 (프로젝트의 설정-사용 분리 관행 준수)
- `componentConfig``screen_layouts_v2.layout_data` 저장 파이프라인을 **그대로 활용** (추가 인프라 불필요)
- 기존 `codePattern`/`namePattern` 문자열 필드는 삭제하지 않고 유지 (하위 호환)
- v2-pivot-grid의 `format` 설정 패턴과 동일한 구조: ConfigPanel에서 설정 → 런타임에서 읽어 사용
- `@dnd-kit` 드래그 구현은 `SortableCodeItem.tsx`, `useDragAndDrop.ts`의 기존 패턴 재사용
- 백엔드 변경 없음, DB 스키마 변경 없음

View File

@ -0,0 +1,123 @@
# [맥락노트] 렉 구조 위치코드/위치명 포맷 사용자 설정
> 관련 문서: [계획서](./LFC[계획]-위치포맷-사용자설정.md) | [체크리스트](./LFC[체크]-위치포맷-사용자설정.md)
---
## 왜 이 작업을 하는가
- 위치코드(`WH001-1층A구역-01-1`)와 위치명(`A구역-01열-1단`)의 포맷이 하드코딩되어 있음
- 회사마다 구분자("-" vs "/"), 세그먼트 순서, 한글 라벨 유무 등 요구사항이 다름
- 현재는 코드를 직접 수정하지 않으면 포맷 변경 불가 → 관리자가 화면 디자이너에서 설정할 수 있어야 함
---
## 핵심 결정 사항과 근거
### 1. 엔드유저 모달이 아닌 화면 디자이너 ConfigPanel에 설정 UI 배치
- **결정**: 포맷 편집 UI를 렉 구조 등록 모달이 아닌 화면 디자이너 좌측 속성 패널(ConfigPanel)에 배치
- **근거**: 프로젝트의 설정-사용 분리 패턴 준수. 모든 v2 컴포넌트가 ConfigPanel에서 설정하고 런타임에서 읽기만 하는 구조를 따름
- **대안 검토**: 모달 안에 포맷 편집 UI 배치(방법 B) → 기각 (프로젝트 관행에 맞지 않음, 매번 설정해야 함, 설정이 휘발됨)
### 2. 패턴 문자열이 아닌 구조화된 세그먼트 배열 사용
- **결정**: `"{warehouseCode}-{floor}{zone}-{row:02d}-{level}"` 같은 문자열 대신 `FormatSegment[]` 배열로 포맷 정의
- **근거**: 관리자가 패턴 문법을 알 필요 없이 드래그/토글/Input으로 직관적 편집 가능
- **대안 검토**: 기존 `codePattern`/`namePattern` 문자열 활용 → 기각 (관리자가 패턴 문법을 모를 수 있고, 오타 가능성 높음)
### 2-1. 체크박스는 한글 라벨 표시/숨김 제어 (showLabel)
- **결정**: 세그먼트의 체크박스는 `showLabel` 속성을 토글하며, 세그먼트 자체를 제거하지 않음
- **근거**: "A구역-01열-1단"에서 "구역", "열" 체크 해제 시 → "A-01-1단"이 되어야 함 (값은 유지, 한글만 제거)
- **주의**: `enabled`는 세그먼트 자체의 포함 여부, `showLabel`은 한글 라벨만 표시/숨김. 혼동하지 않도록 분리
### 2-2. 라벨/구분/자릿수 3개 필드 항상 고정 표시
- **결정**: 라벨 필드를 비워도 입력 필드가 사라지지 않고, 3개 필드(라벨, 구분, 자릿수)가 모든 세그먼트에 항상 표시
- **근거**: 라벨을 지웠을 때 "라벨 없음"이 뜨면서 입력 필드가 사라지면 다시 라벨을 추가할 수 없는 문제 발생
- **UI 개선**: 컬럼 헤더를 최상단에 배치하고, 각 행에서는 "구분", "자릿수" 텍스트를 제거하여 공간 확보
### 2-3. stripKnownSuffix로 원본 값의 한글 접미사를 먼저 벗긴 뒤 라벨 붙임
- **결정**: `buildFormattedString`에서 값을 처리할 때, 먼저 `KNOWN_SUFFIXES`(층, 구역)를 벗겨내고 순수 값만 남긴 뒤, `showLabel && label`일 때만 라벨을 붙이는 구조
- **근거**: context 값이 "1층", "A구역"처럼 한글이 이미 포함된 상태로 들어옴. 이전 방식(`if (seg.label)`)은 라벨 필드가 빈 문자열이면 조건을 건너뛰어서 한글이 제거되지 않는 버그 발생
- **핵심 흐름**: 원본 값 → `stripKnownSuffix` → 순수 값 → `showLabel && label`이면 라벨 붙임
### 2-4. 자릿수 필드는 숫자 타입만 활성화
- **결정**: 자릿수(pad) 필드는 열(row), 단(level)만 편집 가능, 나머지(창고코드, 층, 구역)는 disabled + 회색 배경
- **근거**: 자릿수(zero-padding)는 숫자 값에만 의미가 있음. 비숫자 타입에 자릿수를 설정하면 혼란을 줄 수 있음
### 3. 기존 codePattern/namePattern 필드는 삭제하지 않고 유지
- **결정**: `types.ts``codePattern`, `namePattern` 필드를 삭제하지 않음
- **근거**: 하위 호환. 기존에 이 필드를 참조하는 코드가 없지만, 향후 다른 용도로 활용될 수 있음
### 4. formatConfig 미설정 시 기본값으로 현재 동작 유지
- **결정**: `config.formatConfig`가 없으면 `defaultFormatConfig` 사용
- **근거**: 기존 화면 설정을 수정하지 않아도 현재와 동일한 위치코드/위치명이 생성됨 (무중단 배포 가능)
### 5. UI 라벨에서 "패딩" 대신 "자릿수" 사용
- **결정**: ConfigPanel UI에서 숫자 제로패딩 설정을 "자릿수"로 표시
- **근거**: 관리자급 사용자가 "패딩"이라는 개발 용어를 모를 수 있음. "자릿수: 2 → 01, 02, ... 99"가 직관적
- **코드 내부**: 변수명은 `pad` 유지 (개발자 영역)
### 6. @dnd-kit으로 드래그 구현
- **결정**: `@dnd-kit/core` + `@dnd-kit/sortable` 사용
- **근거**: 프로젝트에 이미 설치되어 있고(`package.json`), `SortableCodeItem.tsx`, `useDragAndDrop.ts` 등 표준 패턴이 확립되어 있음
- **대안 검토**: 위/아래 화살표 버튼으로 순서 변경 → 기각 (프로젝트에 이미 DnD 패턴이 있으므로 일관성 유지)
### 7. v2-pivot-grid의 format 설정 패턴을 참고
- **결정**: ConfigPanel에서 설정 → componentConfig에 저장 → 런타임에서 읽어 사용하는 흐름
- **근거**: v2-pivot-grid가 필드별 `format`(type, precision, thousandSeparator 등)을 동일한 패턴으로 구현하고 있음. 가장 유사한 선례
---
## 관련 파일 위치
| 구분 | 파일 경로 | 설명 |
|------|----------|------|
| 타입 정의 | `frontend/lib/registry/components/v2-rack-structure/types.ts` | FormatSegment, LocationFormatConfig 타입 |
| 기본 설정 | `frontend/lib/registry/components/v2-rack-structure/config.ts` | 기본 세그먼트 상수, buildFormattedString 함수 |
| 신규 컴포넌트 | `frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx` | 포맷 편집 UI 서브컴포넌트 |
| 설정 패널 | `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | FormatSegmentEditor 배치 |
| 런타임 컴포넌트 | `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | generateLocationCode 세그먼트 기반 교체 |
| DnD 참고 | `frontend/hooks/useDragAndDrop.ts` | 프로젝트 표준 DnD 패턴 |
| DnD 참고 | `frontend/components/admin/SortableCodeItem.tsx` | useSortable 사용 예시 |
| 선례 참고 | `frontend/lib/registry/components/v2-pivot-grid/` | ConfigPanel에서 format 설정하는 패턴 |
---
## 기술 참고
### 세그먼트 기반 문자열 생성 흐름
```
FormatSegment[] → filter(enabled && 값 있음) → map(stripKnownSuffix → showLabel && label이면 라벨 붙임 → 자릿수 → 구분자) → join("") → 최종 문자열
```
### componentConfig 저장/로드 흐름
```
ConfigPanel onChange
→ V2PropertiesPanel.onUpdateProperty("componentConfig", mergedConfig)
→ layout.components[i].componentConfig.formatConfig
→ convertLegacyToV2 → screen_layouts_v2.layout_data.overrides.formatConfig (DB)
→ convertV2ToLegacy → componentConfig.formatConfig (런타임)
→ RackStructureComponent config.formatConfig (prop)
```
### context 값 참고
```
context.warehouseCode = "WH001" (창고 코드)
context.floor = "1층" (층 라벨 - 값 자체에 "층" 포함)
context.zone = "A구역" 또는 "A" (구역 라벨 - "구역" 포함 여부 불확실)
row = 1, 2, 3, ... (열 번호 - 숫자)
level = 1, 2, 3, ... (단 번호 - 숫자)
```

View File

@ -0,0 +1,84 @@
# [체크리스트] 렉 구조 위치코드/위치명 포맷 사용자 설정
> 관련 문서: [계획서](./LFC[계획]-위치포맷-사용자설정.md) | [맥락노트](./LFC[맥락]-위치포맷-사용자설정.md)
---
## 공정 상태
- 전체 진행률: **100%** (완료)
- 현재 단계: 완료
---
## 구현 체크리스트
### 1단계: 타입 및 기본값 정의
- [x] `types.ts``FormatSegment` 인터페이스 추가
- [x] `types.ts``LocationFormatConfig` 인터페이스 추가
- [x] `types.ts``RackStructureComponentConfig``formatConfig?: LocationFormatConfig` 필드 추가
- [x] `config.ts``defaultCodeSegments` 상수 정의 (현재 하드코딩과 동일한 결과)
- [x] `config.ts``defaultNameSegments` 상수 정의 (현재 하드코딩과 동일한 결과)
- [x] `config.ts``defaultFormatConfig` 상수 정의
- [x] `config.ts``buildFormattedString()` 함수 구현 (stripKnownSuffix 방식)
### 2단계: FormatSegmentEditor 서브컴포넌트 생성
- [x] `FormatSegmentEditor.tsx` 신규 파일 생성
- [x] `@dnd-kit/sortable` 기반 드래그 순서변경 구현
- [x] 세그먼트별 체크박스로 한글 라벨 표시/숨김 토글 (showLabel)
- [x] 라벨/구분/자릿수 3개 필드 항상 고정 표시 (빈 값이어도 입력 필드 유지)
- [x] 최상단 컬럼 헤더 추가 (라벨 / 구분 / 자릿수), 각 행에서 텍스트 라벨 제거
- [x] grid 레이아웃으로 정렬 (`grid-cols-[16px_56px_18px_1fr_1fr_1fr]`)
- [x] 자릿수 필드: 숫자 타입(열, 단)만 활성화, 비숫자 타입은 disabled + 회색 배경
- [x] `buildFormattedString`으로 실시간 미리보기 표시
### 3단계: ConfigPanel에 포맷 설정 섹션 추가
- [x] `RackStructureConfigPanel.tsx`에 FormatSegmentEditor import
- [x] UI 설정 섹션 아래에 "포맷 설정" 섹션 추가
- [x] 위치코드 포맷용 FormatSegmentEditor 배치
- [x] 위치명 포맷용 FormatSegmentEditor 배치
- [x] `onChange``formatConfig` 업데이트 연결
### 4단계: 컴포넌트에서 세그먼트 기반 코드 생성
- [x] `RackStructureComponent.tsx`에서 `defaultFormatConfig` import
- [x] `generateLocationCode` 함수를 세그먼트 기반으로 교체
- [x] `config.formatConfig || defaultFormatConfig` 폴백 적용
### 5단계: 검증
- [x] formatConfig 미설정 시: 기존과 동일한 위치코드/위치명 생성 확인
- [x] ConfigPanel에서 구분자 변경: 미리보기에 즉시 반영 확인
- [x] ConfigPanel에서 라벨 체크 해제: 한글만 사라지고 값은 유지 확인 (예: "A구역" → "A")
- [x] ConfigPanel에서 순서 드래그 변경: 미리보기에 반영 확인
- [x] ConfigPanel에서 라벨 텍스트 변경: 미리보기에 반영 확인
- [x] 설정 저장 후 화면 재로드: 설정 유지 확인
- [x] 렉 구조 모달에서 미리보기 생성: 설정된 포맷으로 생성 확인
- [x] 렉 구조 저장: DB에 설정된 포맷의 코드/이름 저장 확인
### 6단계: 정리
- [x] 린트 에러 없음 확인
- [x] 미사용 import 제거 (FormatSegmentEditor.tsx: useState)
- [x] 파일 끝 불필요한 빈 줄 제거 (types.ts, config.ts)
- [x] 계획서/맥락노트/체크리스트 최종 반영
- [x] 이 체크리스트 완료 표시 업데이트
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-10 | 계획서, 맥락노트, 체크리스트 작성 완료 |
| 2026-03-10 | 1~4단계 구현 완료 (types, config, FormatSegmentEditor, ConfigPanel, Component) |
| 2026-03-10 | showLabel 로직 수정: 체크박스가 세그먼트 제거가 아닌 한글 라벨만 표시/숨김 처리 |
| 2026-03-10 | 계획서, 맥락노트, 체크리스트에 showLabel 변경사항 반영 |
| 2026-03-10 | UI 개선: 3필드 고정표시 + 컬럼 헤더 + grid 레이아웃 + 자릿수 비숫자 비활성화 |
| 2026-03-10 | 계획서, 맥락노트, 체크리스트에 UI 개선사항 반영 |
| 2026-03-10 | 라벨 필드 비움 시 한글 미제거 버그 수정 (stripKnownSuffix 도입) |
| 2026-03-10 | 코드 정리 (미사용 import, 빈 줄) + 문서 최종 반영 |
| 2026-03-10 | 5단계 검증 완료, 전체 작업 완료 |

View File

@ -0,0 +1,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`를 호출하여 부모/백엔드 동기화

View File

@ -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 호출
```

View File

@ -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 | 문서 최신화: 버그 수정 내역 반영, 코드 설계 섹션 제거 (구현 완료) |

View File

@ -0,0 +1,350 @@
# [계획서] 렉 구조 등록 - 층(floor) 필수 입력 해제
> 관련 문서: [맥락노트](./RFO[맥락]-렉구조-층필수해제.md) | [체크리스트](./RFO[체크]-렉구조-층필수해제.md)
## 개요
탑씰 회사의 물류관리 > 창고정보 관리 > 렉 구조 등록 모달에서, "층" 필드를 필수 입력에서 선택 입력으로 변경합니다. 현재 "창고 코드 / 층 / 구역" 3개가 모두 필수로 하드코딩되어 있어, 층을 선택하지 않으면 미리보기 생성과 저장이 불가능합니다.
---
## 현재 동작
### 1. 필수 필드 경고 (RackStructureComponent.tsx:291~298)
층을 선택하지 않으면 빨간 경고가 표시됨:
```tsx
const missingFields = useMemo(() => {
const missing: string[] = [];
if (!context.warehouseCode) missing.push("창고 코드");
if (!context.floor) missing.push("층"); // ← 하드코딩 필수
if (!context.zone) missing.push("구역");
return missing;
}, [context]);
```
> "다음 필드를 먼저 입력해주세요: **층**"
### 2. 미리보기 생성 차단 (RackStructureComponent.tsx:517~521)
`missingFields`에 "층"이 포함되어 있으면 `generatePreview()` 실행이 차단됨:
```tsx
if (missingFields.length > 0) {
alert(`다음 필드를 먼저 입력해주세요: ${missingFields.join(", ")}`);
return;
}
```
### 3. 위치 코드 생성 (RackStructureComponent.tsx:497~513)
floor가 없으면 기본값 `"1"`을 사용하여 위치 코드를 생성:
```tsx
const floor = context?.floor || "1";
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
// 예: WH001-1층A구역-01-1
```
### 4. 기존 데이터 조회 (RackStructureComponent.tsx:378~432)
floor가 비어있으면 기존 데이터 조회 자체를 건너뜀 → 중복 체크 불가:
```tsx
if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) {
setExistingLocations([]);
return;
}
```
### 5. 렉 구조 화면 감지 (buttonActions.ts:692~698)
floor가 비어있으면 렉 구조 화면으로 인식하지 않음 → 일반 저장으로 빠짐:
```tsx
const isRackStructureScreen =
context.tableName === "warehouse_location" &&
context.formData?.floor && // ← floor 없으면 false
context.formData?.zone &&
!rackStructureLocations;
```
### 6. 저장 전 중복 체크 (buttonActions.ts:2085~2131)
floor가 없으면 중복 체크 전체를 건너뜀:
```tsx
if (warehouseCode && floor && zone) {
// 중복 체크 로직
}
```
---
## 변경 후 동작
### 1. 필수 필드에서 "층" 제거
- "창고 코드"와 "구역"만 필수
- 층을 선택하지 않아도 경고가 뜨지 않음
### 2. 미리보기 생성 정상 동작
- 층 없이도 미리보기 생성 가능
- 위치 코드에서 층 부분을 생략하여 깔끔하게 생성
### 3. 위치 코드 생성 규칙 변경
- 층 있을 때: `WH001-1층A구역-01-1` (기존과 동일)
- 층 없을 때: `WH001-A구역-01-1` (층 부분 생략)
### 4. 기존 데이터 조회 (중복 체크)
- 층 있을 때: `warehouse_code + floor + zone`으로 조회 (기존과 동일)
- 층 없을 때: `warehouse_code + zone`으로 조회 (floor 조건 제외)
### 5. 렉 구조 화면 감지
- floor 유무와 관계없이 `warehouse_location` 테이블 + zone 필드가 있으면 렉 구조 화면으로 인식
### 6. 저장 시 floor 값
- 층 선택함: `floor = "1층"` 등 선택한 값 저장
- 층 미선택: `floor = NULL`로 저장
---
## 시각적 예시
| 상태 | 경고 메시지 | 미리보기 | 위치 코드 | DB floor 값 |
|------|------------|---------|-----------|------------|
| 창고+층+구역 모두 선택 | 없음 | 생성 가능 | `WH001-1층A구역-01-1` | `"1층"` |
| 창고+구역만 선택 (층 미선택) | 없음 | 생성 가능 | `WH001-A구역-01-1` | `NULL` |
| 창고만 선택 | "구역을 먼저 입력해주세요" | 차단 | - | - |
| 아무것도 미선택 | "창고 코드, 구역을 먼저 입력해주세요" | 차단 | - | - |
---
## 아키텍처
### 데이터 흐름 (변경 전)
```mermaid
flowchart TD
A[사용자: 창고/층/구역 입력] --> B{필수 필드 검증}
B -->|층 없음| C[경고: 층을 입력하세요]
B -->|3개 다 있음| D[기존 데이터 조회<br/>warehouse_code + floor + zone]
D --> E[미리보기 생성]
E --> F{저장 버튼}
F --> G[렉 구조 화면 감지<br/>floor && zone 필수]
G --> H[중복 체크<br/>warehouse_code + floor + zone]
H --> I[일괄 INSERT<br/>floor = 선택값]
```
### 데이터 흐름 (변경 후)
```mermaid
flowchart TD
A[사용자: 창고/구역 입력<br/>층은 선택사항] --> B{필수 필드 검증}
B -->|창고 or 구역 없음| C[경고: 해당 필드를 입력하세요]
B -->|창고+구역 있음| D{floor 값 존재?}
D -->|있음| E1[기존 데이터 조회<br/>warehouse_code + floor + zone]
D -->|없음| E2[기존 데이터 조회<br/>warehouse_code + zone]
E1 --> F[미리보기 생성]
E2 --> F
F --> G{저장 버튼}
G --> H[렉 구조 화면 감지<br/>zone만 필수]
H --> I{floor 값 존재?}
I -->|있음| J1[중복 체크<br/>warehouse_code + floor + zone]
I -->|없음| J2[중복 체크<br/>warehouse_code + zone]
J1 --> K[일괄 INSERT<br/>floor = 선택값]
J2 --> K2[일괄 INSERT<br/>floor = NULL]
```
### 컴포넌트 관계
```mermaid
graph LR
subgraph 프론트엔드
A[폼 필드<br/>창고/층/구역] -->|formData| B[RackStructureComponent<br/>필수 검증 + 미리보기]
B -->|locations 배열| C[buttonActions.ts<br/>화면 감지 + 중복 체크 + 저장]
end
subgraph 백엔드
C -->|POST /dynamic-form/save| D[DynamicFormApi<br/>데이터 저장]
D --> E[(warehouse_location<br/>floor: nullable)]
end
style B fill:#fff3cd,stroke:#ffc107
style C fill:#fff3cd,stroke:#ffc107
```
> 노란색 = 이번에 수정하는 부분
---
## 변경 대상 파일
| 파일 | 수정 내용 | 수정 규모 |
|------|----------|----------|
| `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | 필수 검증에서 floor 제거, 위치 코드 생성 로직 수정, 기존 데이터 조회 로직 수정 | ~20줄 |
| `frontend/lib/utils/buttonActions.ts` | 렉 구조 화면 감지 조건 수정, 중복 체크 조건 수정 | ~10줄 |
### 사전 확인 필요
| 확인 항목 | 내용 |
|----------|------|
| DB 스키마 | `warehouse_location.floor` 컬럼이 `NULL` 허용인지 확인. NOT NULL이면 `ALTER TABLE` 필요 |
---
## 코드 설계
### 1. 필수 필드 검증 수정 (RackStructureComponent.tsx:291~298)
```tsx
// 변경 전
const missingFields = useMemo(() => {
const missing: string[] = [];
if (!context.warehouseCode) missing.push("창고 코드");
if (!context.floor) missing.push("층");
if (!context.zone) missing.push("구역");
return missing;
}, [context]);
// 변경 후
const missingFields = useMemo(() => {
const missing: string[] = [];
if (!context.warehouseCode) missing.push("창고 코드");
if (!context.zone) missing.push("구역");
return missing;
}, [context]);
```
### 2. 위치 코드 생성 수정 (RackStructureComponent.tsx:497~513)
```tsx
// 변경 전
const floor = context?.floor || "1";
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
// 변경 후
const floor = context?.floor;
const floorPrefix = floor ? `${floor}` : "";
const code = `${warehouseCode}-${floorPrefix}${zone}-${row.toString().padStart(2, "0")}-${level}`;
// 층 있을 때: WH001-1층A구역-01-1
// 층 없을 때: WH001-A구역-01-1
```
### 3. 기존 데이터 조회 수정 (RackStructureComponent.tsx:378~432)
```tsx
// 변경 전
if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) {
setExistingLocations([]);
return;
}
const searchParams = {
warehouse_code: { value: warehouseCodeForQuery, operator: "equals" },
floor: { value: floorForQuery, operator: "equals" },
zone: { value: zoneForQuery, operator: "equals" },
};
// 변경 후
if (!warehouseCodeForQuery || !zoneForQuery) {
setExistingLocations([]);
return;
}
const searchParams: Record<string, any> = {
warehouse_code: { value: warehouseCodeForQuery, operator: "equals" },
zone: { value: zoneForQuery, operator: "equals" },
};
if (floorForQuery) {
searchParams.floor = { value: floorForQuery, operator: "equals" };
}
```
### 4. 렉 구조 화면 감지 수정 (buttonActions.ts:692~698)
```tsx
// 변경 전
const isRackStructureScreen =
context.tableName === "warehouse_location" &&
context.formData?.floor &&
context.formData?.zone &&
!rackStructureLocations;
// 변경 후
const isRackStructureScreen =
context.tableName === "warehouse_location" &&
context.formData?.zone &&
!rackStructureLocations;
```
### 5. 저장 전 중복 체크 수정 (buttonActions.ts:2085~2131)
```tsx
// 변경 전
if (warehouseCode && floor && zone) {
const existingResponse = await DynamicFormApi.getTableData(tableName, {
search: {
warehouse_code: { value: warehouseCode, operator: "equals" },
floor: { value: floor, operator: "equals" },
zone: { value: zone, operator: "equals" },
},
// ...
});
}
// 변경 후
if (warehouseCode && zone) {
const searchParams: Record<string, any> = {
warehouse_code: { value: warehouseCode, operator: "equals" },
zone: { value: zone, operator: "equals" },
};
if (floor) {
searchParams.floor = { value: floor, operator: "equals" };
}
const existingResponse = await DynamicFormApi.getTableData(tableName, {
search: searchParams,
// ...
});
}
```
---
## 적용 범위 및 영향도
### 이번 변경은 전역 설정
방법 B는 렉 구조 컴포넌트 코드에서 직접 "층 필수"를 제거하는 방식이므로, 이 컴포넌트를 사용하는 **모든 회사**에 동일하게 적용됩니다.
| 회사 | 변경 후 |
|------|--------|
| 탑씰 | 층 안 골라도 됨 (요청 사항) |
| 다른 회사 | 층 안 골라도 됨 (동일하게 적용) |
### 기존 사용자에 대한 영향
- 층을 안 골라도 **되는** 것이지, 안 골라야 **하는** 것이 아님
- 기존처럼 층을 선택하면 **완전히 동일하게** 동작함 (하위 호환 보장)
- 즉, 기존 사용 패턴을 유지하는 회사에는 아무런 차이가 없음
### 회사별 독립 제어가 필요한 경우
만약 특정 회사는 층을 필수로 유지하고, 다른 회사는 선택으로 해야 하는 상황이 발생하면, 방법 A(설정 기능 추가)로 업그레이드가 필요합니다. 이번 방법 B의 변경은 향후 방법 A로 전환할 때 충돌 없이 확장 가능합니다.
---
## 설계 원칙
- "창고 코드"와 "구역"의 필수 검증은 기존과 동일하게 유지
- 층을 선택한 경우의 동작은 기존과 완전히 동일 (하위 호환)
- 층 미선택 시 위치 코드에서 층 부분을 깔끔하게 생략 (폴백값 "1" 사용하지 않음)
- 중복 체크는 가용한 필드 기준으로 수행 (floor 없으면 warehouse_code + zone 기준)
- DB에는 NULL로 저장하여 "미입력"을 정확하게 표현 (프로젝트 표준 패턴)
- 특수 문자열("상관없음" 등) 사용하지 않음 (프로젝트 관행에 맞지 않으므로)

View File

@ -0,0 +1,92 @@
# [맥락노트] 렉 구조 등록 - 층(floor) 필수 입력 해제
> 관련 문서: [계획서](./RFO[계획]-렉구조-층필수해제.md) | [체크리스트](./RFO[체크]-렉구조-층필수해제.md)
---
## 왜 이 작업을 하는가
- 탑씰 회사에서 창고 렉 구조 등록 시 "층"을 선택하지 않아도 되게 해달라는 요청
- 현재 코드에 창고 코드 / 층 / 구역 3개가 필수로 하드코딩되어 있어, 층 미선택 시 미리보기 생성과 저장이 모두 차단됨
- 층 필수 검증이 6곳에 분산되어 있어 한 곳만 고치면 다른 곳에서 오류 발생
---
## 핵심 결정 사항과 근거
### 1. 방법 B(하드코딩 제거) 채택, 방법 A(설정 기능) 미채택
- **결정**: 코드에서 floor 필수 조건을 직접 제거
- **근거**: 이 프로젝트의 다른 모달/컴포넌트들은 모두 코드에서 직접 "필수/선택"을 정해놓는 방식을 사용. 설정으로 필수 여부를 바꿀 수 있게 만든 패턴은 기존에 없음
- **대안 검토**:
- 방법 A(ConfigPanel에 requiredFields 설정 추가): 유연하지만 4파일 수정 + 프로젝트에 없던 새 패턴 도입 → 기각
- "상관없음" 값 추가 후 null 변환: 프로젝트 어디에서도 magic value → null 변환 패턴을 쓰지 않음 → 기각
- "상관없음" 값만 추가 (코드 무변경): DB에 "상관없음" 텍스트가 저장되어 데이터가 지저분함 → 기각
- **향후**: 회사별 독립 제어가 필요해지면 방법 A로 확장 가능 (충돌 없음)
### 2. 전역 적용 (회사별 독립 설정 아님)
- **결정**: 렉 구조 컴포넌트를 사용하는 모든 회사에 동일 적용
- **근거**: 방법 B는 코드 직접 수정이므로 회사별 분기 불가. 단, 기존처럼 층을 선택하면 완전히 동일하게 동작하므로 다른 회사에 실질적 영향 없음 (선택 안 해도 "되는" 것이지, 안 해야 "하는" 것이 아님)
### 3. floor 미선택 시 NULL 저장 (특수값 아님)
- **결정**: floor를 선택하지 않으면 DB에 `NULL` 저장
- **근거**: 프로젝트 표준 패턴. `UserFormModal``email: formData.email || null`, `EnhancedFormService`의 빈 문자열 → null 자동 변환 등과 동일한 방식
- **대안 검토**: "상관없음" 저장 후 null 변환 → 프로젝트에서 미사용 패턴이므로 기각
### 4. 위치 코드에서 층 부분 생략 (폴백값 "1" 사용 안 함)
- **결정**: floor 없을 때 위치 코드에서 층 부분을 아예 빼버림
- **근거**: 기존 코드는 `context?.floor || "1"`로 폴백하여 1층을 선택한 것처럼 위장됨. 이는 잘못된 데이터를 만들 수 있음
- **결과**:
- 층 있을 때: `WH001-1층A구역-01-1` (기존과 동일)
- 층 없을 때: `WH001-A구역-01-1` (층 부분 없이 깔끔)
### 5. 중복 체크는 가용 필드 기준으로 수행
- **결정**: floor 없으면 `warehouse_code + zone`으로 중복 체크, floor 있으면 `warehouse_code + floor + zone`으로 중복 체크
- **근거**: 기존 코드는 floor 없으면 중복 체크 전체를 건너뜀 → 중복 데이터 발생 위험. 가용 필드 기준으로 체크하면 floor 유무와 관계없이 안전
### 6. 렉 구조 화면 감지에서 floor 조건 제거
- **결정**: `buttonActions.ts``isRackStructureScreen` 조건에서 `context.formData?.floor` 제거
- **근거**: floor 없으면 렉 구조 화면으로 인식되지 않아 일반 단건 저장으로 빠짐 → 예기치 않은 동작. zone만으로 감지해야 floor 미선택 시에도 렉 구조 일괄 저장이 정상 동작
---
## 관련 파일 위치
| 구분 | 파일 경로 | 설명 |
|------|----------|------|
| 수정 대상 | `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | 필수 검증, 위치 코드 생성, 기존 데이터 조회 |
| 수정 대상 | `frontend/lib/utils/buttonActions.ts` | 화면 감지, 중복 체크 |
| 타입 정의 | `frontend/lib/registry/components/v2-rack-structure/types.ts` | RackStructureContext, FieldMapping 등 |
| 설정 패널 | `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | 필드 매핑 설정 (이번에 수정 안 함) |
| 저장 모달 | `frontend/components/screen/SaveModal.tsx` | 필수 검증 (DB NOT NULL 기반, 별도 확인 필요) |
| 사전 확인 | DB `warehouse_location.floor` 컬럼 | NULL 허용 여부 확인, NOT NULL이면 ALTER TABLE 필요 |
---
## 기술 참고
### 수정 포인트 6곳 요약
| # | 파일 | 행 | 내용 | 수정 방향 |
|---|------|-----|------|----------|
| 1 | RackStructureComponent.tsx | 291~298 | missingFields에서 floor 체크 | floor 체크 제거 |
| 2 | RackStructureComponent.tsx | 517~521 | 미리보기 생성 차단 | 1번 수정으로 자동 해결 |
| 3 | RackStructureComponent.tsx | 497~513 | 위치 코드 생성 `floor \|\| "1"` | 폴백값 제거, 없으면 생략 |
| 4 | RackStructureComponent.tsx | 378~432 | 기존 데이터 조회 조건 | floor 없어도 조회 가능하게 |
| 5 | buttonActions.ts | 692~698 | 렉 구조 화면 감지 | floor 조건 제거 |
| 6 | buttonActions.ts | 2085~2131 | 저장 전 중복 체크 | floor 조건부로 포함 |
### 프로젝트 표준 optional 필드 처리 패턴
```
빈 값 → null 변환: value || null (UserFormModal)
nullable 자동 변환: value === "" && isNullable === "Y" → null (EnhancedFormService)
Select placeholder: "__none__" → "" 또는 undefined (여러 ConfigPanel)
```
이번 변경은 위 패턴들과 일관성을 유지합니다.

View File

@ -0,0 +1,57 @@
# [체크리스트] 렉 구조 등록 - 층(floor) 필수 입력 해제
> 관련 문서: [계획서](./RFO[계획]-렉구조-층필수해제.md) | [맥락노트](./RFO[맥락]-렉구조-층필수해제.md)
---
## 공정 상태
- 전체 진행률: **100%** (완료)
- 현재 단계: 전체 완료
---
## 구현 체크리스트
### 0단계: 사전 확인
- [x] DB `warehouse_location.floor` 컬럼 nullable 여부 확인 → 이미 NULL 허용 상태, 변경 불필요
### 1단계: RackStructureComponent.tsx 수정
- [x] `missingFields`에서 `if (!context.floor) missing.push("층")` 제거 (291~298행)
- [x] `generateLocationCode`에서 `context?.floor || "1"` 폴백 제거, floor 없으면 위치 코드에서 생략 (497~513행)
- [x] `loadExistingLocations`에서 floor 없어도 조회 가능하도록 조건 수정 (378~432행)
- [x] `searchParams`에 floor를 조건부로 포함하도록 변경
### 2단계: buttonActions.ts 수정
- [x] `isRackStructureScreen` 조건에서 `context.formData?.floor` 제거 (692~698행)
- [x] `handleRackStructureBatchSave` 중복 체크에서 floor를 조건부로 포함 (2085~2131행)
### 3단계: 검증
- [x] 층 선택 + 구역 선택: 기존과 동일하게 동작 확인
- [x] 층 미선택 + 구역 선택: 경고 없이 미리보기 생성 가능 확인
- [x] 층 미선택 시 위치 코드에 층 부분이 빠져있는지 확인
- [x] 층 미선택 시 저장 정상 동작 확인
- [x] 층 미선택 시 기존 데이터 중복 체크 정상 동작 확인
- [x] 창고 코드 미입력 시 여전히 경고 표시되는지 확인
- [x] 구역 미입력 시 여전히 경고 표시되는지 확인
### 4단계: 정리
- [x] 린트 에러 없음 확인 (기존 WARNING 1개만 존재, 이번 변경과 무관)
- [x] 이 체크리스트 완료 표시 업데이트
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-10 | 계획서, 맥락노트, 체크리스트 작성 완료 |
| 2026-03-10 | 1단계 코드 수정 완료 (RackStructureComponent.tsx) |
| 2026-03-10 | 2단계 코드 수정 완료 (buttonActions.ts) |
| 2026-03-10 | 린트 에러 확인 완료 |
| 2026-03-10 | 사용자 검증 완료, 전체 작업 완료 |

View File

@ -123,15 +123,49 @@
- [ ] 비활성 탭: 캐시에서 복원 - [ ] 비활성 탭: 캐시에서 복원
- [ ] 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제 - [ ] 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제
### 6-3. 캐시 키 관리 (clearTabStateCache) ### 6-3. 캐시 키 관리 (clearTabCache)
탭 닫기/새로고침 시 관련 sessionStorage 키 일괄 제거: 탭 닫기/새로고침 시 관련 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: 컬럼 순서
--- ---

View File

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

View File

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

View File

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

View File

@ -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,15 +285,24 @@ function PopScreenViewPage() {
</div> </div>
)} )}
{/* POP 화면 컨텐츠 */} {/* 일반 모드 네비게이션 바 */}
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
{/* 현재 모드 표시 (일반 모드) */}
{!isPreviewMode && ( {!isPreviewMode && (
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded"> <div className="sticky top-0 z-50 flex h-10 items-center justify-between border-b bg-white/80 px-3 backdrop-blur">
{currentModeKey.replace("_", " ")} <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> </div>
)} )}
{/* POP 화면 컨텐츠 */}
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
<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"}`}
style={isPreviewMode ? { style={isPreviewMode ? {

View File

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

View File

@ -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 if (type === "pop") {
setSelectedScreen(null);
if (selectedPopScreen) {
const actualScreenId = selectedPopScreen.screenId || selectedPopScreen.id;
setFormData((prev) => ({
...prev,
menuUrl: `/pop/screens/${actualScreenId}`,
})); }));
} else { } else {
// 화면 할당 모드로 변경 시 setFormData((prev) => ({
// 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지 ...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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(() => {
// URL에서 쿼리스트링/해시 제거 후 매칭
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); 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])} />;
} }
// 화면 할당: /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 />; 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} />;
}

View File

@ -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 || "메뉴"; const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem("currentMenuName", menuName); localStorage.setItem("currentMenuName", menuName);
} }
try { const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0");
const menuObjid = menu.objid || menu.id; const isAdminMenu = menu.menuType === "0";
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
if (assignedScreens.length > 0) { console.log("[handleMenuClick] 메뉴 클릭:", {
const firstScreen = assignedScreens[0]; menuName,
openTab({ menuObjid,
type: "screen", menuType: menu.menuType,
title: menuName, isAdminMenu,
screenId: firstScreen.screenId, screenId: menu.screenId,
menuObjid: parseInt(menuObjid), 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); if (isMobile) setSidebarOpen(false);
return; return;
} }
} catch {
console.warn("할당된 화면 조회 실패"); // 2) screen_menu_assignments 테이블 조회
if (menuObjid) {
try {
console.log("[handleMenuClick] → screen_menu_assignments 조회 시도, menuObjid:", menuObjid);
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
console.log("[handleMenuClick] → 조회 결과:", assignedScreens);
if (assignedScreens.length > 0) {
console.log("[handleMenuClick] → screen 탭 (assignments):", assignedScreens[0].screenId);
openTab({ type: "screen", title: menuName, screenId: assignedScreens[0].screenId, menuObjid });
if (isMobile) setSidebarOpen(false);
return;
}
} catch (err) {
console.error("[handleMenuClick] 할당된 화면 조회 실패:", err);
}
} }
if (menu.url && menu.url !== "#") { // 3) 대시보드 할당 (/dashboard/xxx) → admin 탭으로 렌더링 (AdminPageRenderer가 처리)
openTab({ if (menu.url && menu.url.startsWith("/dashboard/")) {
type: "admin", console.log("[handleMenuClick] → 대시보드 탭:", menu.url);
title: menuName, openTab({ type: "admin", title: menuName, adminUrl: menu.url });
adminUrl: menu.url,
});
if (isMobile) setSidebarOpen(false); if (isMobile) setSidebarOpen(false);
} else { return;
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
}
} }
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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 전환 / 로그아웃",
},
]; ];
// 드래그 가능한 컴포넌트 아이템 // 드래그 가능한 컴포넌트 아이템

View File

@ -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,20 +130,6 @@ function SendSection({
{outgoing.map((conn) => ( {outgoing.map((conn) => (
<div key={conn.id}> <div key={conn.id}>
{editingId === conn.id ? ( {editingId === conn.id ? (
isFilterSource ? (
<FilterConnectionForm
component={component}
meta={meta}
allComponents={allComponents}
initial={conn}
onSubmit={(data) => {
onUpdateConnection?.(conn.id, data);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
submitLabel="수정"
/>
) : (
<SimpleConnectionForm <SimpleConnectionForm
component={component} component={component}
allComponents={allComponents} allComponents={allComponents}
@ -205,9 +141,9 @@ function SendSection({
onCancel={() => setEditingId(null)} onCancel={() => setEditingId(null)}
submitLabel="수정" 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>
@ -226,26 +162,32 @@ function SendSection({
</button> </button>
)} )}
</div> </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>
))} ))}
{isFilterSource ? (
<FilterConnectionForm
component={component}
meta={meta}
allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가"
/>
) : (
<SimpleConnectionForm <SimpleConnectionForm
component={component} component={component}
allComponents={allComponents} allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)} onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가" 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,117 +345,70 @@ function FilterConnectionForm({
</Select> </Select>
</div> </div>
{targetMeta && ( {isFilterConnection && selectedTargetId && subTableName && (
<div className="space-y-2 rounded bg-muted/50 p-2">
<div className="flex items-center gap-2">
<Checkbox
id={`isSubTable_${component.id}`}
checked={isSubTable}
onCheckedChange={(v) => {
setIsSubTable(v === true);
if (!v) setTargetColumn("");
}}
/>
<label htmlFor={`isSubTable_${component.id}`} className="text-[10px] text-muted-foreground cursor-pointer">
({subTableName})
</label>
</div>
{isSubTable && (
<div className="space-y-2 pl-5">
<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={selectedTargetInput} onValueChange={setSelectedTargetInput}> {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"> <SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" /> <SelectValue placeholder="컬럼 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{targetMeta.receivable.map((r) => ( {subColumns.filter(Boolean).map((col) => (
<SelectItem key={r.key} value={r.key} className="text-xs"> <SelectItem key={col} value={col} className="text-xs">
{r.label} {col}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </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> </div>
) : (
<Input
value={filterColumns[0] || ""}
onChange={(e) => setFilterColumns(e.target.value ? [e.target.value] : [])}
placeholder="컬럼명 입력"
className="h-7 text-xs"
/>
)}
{filterColumns.length > 0 && (
<p className="text-[10px] text-primary">
{filterColumns.length}
</p>
)}
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[10px] text-muted-foreground"> </p> <span className="text-[10px] text-muted-foreground"> </span>
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}> <Select value={filterMode} onValueChange={setFilterMode}>
<SelectTrigger className="h-7 text-xs"> <SelectTrigger className="h-7 text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="contains" className="text-xs"></SelectItem> <SelectItem value="equals" className="text-xs"> (equals)</SelectItem>
<SelectItem value="equals" className="text-xs"></SelectItem> <SelectItem value="contains" className="text-xs"> (contains)</SelectItem>
<SelectItem value="starts_with" className="text-xs"></SelectItem> <SelectItem value="starts_with" className="text-xs"> (starts_with)</SelectItem>
<SelectItem value="range" className="text-xs"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
)} )}
</div>
)}
<Button <Button
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}`;
}

View File

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

View File

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

View File

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

View File

@ -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 () => {

View File

@ -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("삭제되었습니다.");

View File

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

View File

@ -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 (
<div key={`row-${rowIndex}`} style={{ marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}>
<FullWidthOverlayRow <FullWidthOverlayRow
key={`row-${rowIndex}`}
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)}

View File

@ -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,
); );
// 중첩 구조 반영
const applyUpdatedSplitPanel = (layout: any, updated: any, info: any) => {
if (info.path === "top") {
return { ...layout, components: layout.components.map((c: any) => c.id === splitPanelId ? updated : c) };
}
return { return {
...prevLayout, ...layout,
components: prevLayout.components.map((c) => components: layout.components.map((c: any) => {
c.id === splitPanelId ? updatedComponent : c, 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

View File

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

View File

@ -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,22 +506,32 @@ 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 colName = widget.columnName || selectedComponent?.columnName;
const colMeta = colName ? currentTable?.columns?.find( const colMeta = colName
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase() ? currentTable?.columns?.find(
) : null; (c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase(),
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"); )
: null;
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");
return ( return (
<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 className="text-muted-foreground text-xs">
{isNotNull && <span className="text-muted-foreground/60 ml-1">(NOT NULL)</span>} {isNotNull && <span className="text-muted-foreground/60 ml-1">(NOT NULL)</span>}
</span> </span>
<Checkbox <Checkbox
checked={isNotNull || widget.required === true || selectedComponent.componentConfig?.required === true} checked={
isNotNull || widget.required === true || selectedComponent.componentConfig?.required === true
}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (isNotNull) return; if (isNotNull) return;
handleUpdate("required", checked); handleUpdate("required", checked);
@ -477,7 +545,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
})()} })()}
{(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 || {

View File

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

View File

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

View File

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

View File

@ -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,7 +513,12 @@ 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("카테고리가 추가되었습니다");
// 폼 초기화 (모달은 닫지 않고 연속 입력) await loadTree(true);
if (parentValue) {
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
}
if (continuousAdd) {
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
valueCode: "", valueCode: "",
@ -521,11 +527,9 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
color: "", color: "",
})); }));
setTimeout(() => addNameRef.current?.focus(), 50); setTimeout(() => addNameRef.current?.focus(), 50);
// 기존 펼침 상태 유지하면서 데이터 새로고침 } else {
await loadTree(true); setFormData({ valueCode: "", valueLabel: "", description: "", color: "", isActive: true });
// 부모 노드만 펼치기 (하위 추가 시) setIsAddModalOpen(false);
if (parentValue) {
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
} }
} 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>

View File

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

View File

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

View File

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

View File

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

View File

@ -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,195 +179,50 @@ function ScreenCombobox({
); );
} }
// ─── 메인 컴포넌트 ─── // ─── 컬럼 편집 카드 (품목/모달/공정 공용) ───
export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> = ({ function ColumnEditor({ columns, onChange, tableName, title, icon }: {
config: configProp, columns: ColumnDef[];
onChange, onChange: (cols: ColumnDef[]) => void;
}) => { tableName: string;
const [tables, setTables] = useState<TableInfo[]>([]); title: string;
const [loadingTables, setLoadingTables] = useState(false); icon: React.ReactNode;
const [modalOpen, setModalOpen] = useState(false); }) {
const [columnsOpen, setColumnsOpen] = useState(false); const [open, setOpen] = useState(false);
const [dataSourceOpen, setDataSourceOpen] = useState(false);
const [layoutOpen, setLayoutOpen] = useState(false);
const config: ItemRoutingConfig = { const addColumn = () => onChange([...columns, { name: "", label: "새 컬럼", width: 100, align: "left" }]);
...defaultConfig, const removeColumn = (idx: number) => onChange(columns.filter((_, i) => i !== idx));
...configProp, const updateColumn = (idx: number, field: keyof ColumnDef, value: string | number) => {
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource }, const next = [...columns];
modals: { ...defaultConfig.modals, ...configProp?.modals },
processColumns: configProp?.processColumns?.length ? configProp.processColumns : defaultConfig.processColumns,
};
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const res = await tableManagementApi.getTableList();
if (res.success && res.data) {
setTables(
res.data.map((t: any) => ({
tableName: t.tableName,
displayName: t.displayName || t.tableName,
}))
);
}
} catch { /* ignore */ } finally { setLoadingTables(false); }
};
loadTables();
}, []);
const dispatchConfigEvent = (newConfig: Partial<ItemRoutingConfig>) => {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: { ...config, ...newConfig } },
})
);
}
};
const update = (partial: Partial<ItemRoutingConfig>) => {
const merged = { ...configProp, ...partial };
onChange(merged);
dispatchConfigEvent(partial);
};
const updateDataSource = (field: string, value: string) => {
const newDataSource = { ...config.dataSource, [field]: value };
const partial = { dataSource: newDataSource };
onChange({ ...configProp, ...partial });
dispatchConfigEvent(partial);
};
const updateModals = (field: string, value?: number) => {
const newModals = { ...config.modals, [field]: value };
const partial = { modals: newModals };
onChange({ ...configProp, ...partial });
dispatchConfigEvent(partial);
};
// 공정 컬럼 관리
const addColumn = () => {
update({
processColumns: [
...config.processColumns,
{ name: "", label: "새 컬럼", width: 100, align: "left" as const },
],
});
};
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 }; next[idx] = { ...next[idx], [field]: value };
update({ processColumns: next }); onChange(next);
}; };
return ( return (
<div className="space-y-4"> <Collapsible open={open} onOpenChange={setOpen}>
{/* ─── 1단계: 모달 연동 (Collapsible) ─── */}
<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" /> {icon}
<span className="text-sm font-medium"> </span> <span className="text-sm font-medium">{title}</span>
<Badge variant="secondary" className="text-[10px] h-5"> <Badge variant="secondary" className="text-[10px] h-5">{columns.length}</Badge>
{[config.modals.versionAddScreenId, config.modals.processAddScreenId, config.modals.processEditScreenId].filter(Boolean).length}
</Badge>
</div> </div>
<ChevronDown <ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", open && "rotate-180")} />
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
modalOpen && "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>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<ScreenCombobox
value={config.modals.versionAddScreenId}
onChange={(v) => updateModals("versionAddScreenId", v)}
/>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<ScreenCombobox
value={config.modals.processAddScreenId}
onChange={(v) => updateModals("processAddScreenId", v)}
/>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<ScreenCombobox
value={config.modals.processEditScreenId}
onChange={(v) => updateModals("processEditScreenId", v)}
/>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 2단계: 공정 테이블 컬럼 (Collapsible + 접이식 카드) ─── */}
<Collapsible open={columnsOpen} onOpenChange={setColumnsOpen}>
<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">
<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> </button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5"> <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> {columns.map((col, idx) => (
<div className="space-y-1">
{config.processColumns.map((col, idx) => (
<Collapsible key={idx}> <Collapsible key={idx}>
<div className="rounded-md border"> <div className="rounded-md border">
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <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">
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" /> <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-[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-xs font-medium truncate flex-1 min-w-0">{col.label || 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.name || "?"}</Badge>
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{col.align || "left"}</Badge> <Button type="button" variant="ghost" size="sm"
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); removeColumn(idx); }} onClick={(e) => { e.stopPropagation(); removeColumn(idx); }}
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0" className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0">
>
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
</button> </button>
@ -456,42 +230,24 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
<CollapsibleContent> <CollapsibleContent>
<div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2"> <div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2">
<div className="space-y-0.5"> <div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span> <span className="text-[10px] text-muted-foreground"></span>
<Input <ColumnCombobox value={col.name} onChange={(v, displayName) => {
value={col.name} updateColumn(idx, "name", v);
onChange={(e) => updateColumn(idx, "name", e.target.value)} if (!col.label || col.label === "새 컬럼" || col.label === col.name) updateColumn(idx, "label", displayName || v);
className="h-7 text-xs" }} tableName={tableName} placeholder="컬럼 선택" />
placeholder="컬럼명"
/>
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span> <span className="text-[10px] text-muted-foreground"></span>
<Input <Input value={col.label} onChange={(e) => updateColumn(idx, "label", e.target.value)} className="h-7 text-xs" />
value={col.label}
onChange={(e) => updateColumn(idx, "label", e.target.value)}
className="h-7 text-xs"
placeholder="표시명"
/>
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span> <span className="text-[10px] text-muted-foreground"></span>
<Input <Input type="number" value={col.width || 100} onChange={(e) => updateColumn(idx, "width", parseInt(e.target.value) || 100)} className="h-7 text-xs" />
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>
<div className="space-y-0.5"> <div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span> <span className="text-[10px] text-muted-foreground"></span>
<Select <Select value={col.align || "left"} onValueChange={(v) => updateColumn(idx, "align", v)}>
value={col.align || "left"} <SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
onValueChange={(v) => updateColumn(idx, "align", v)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="left"></SelectItem> <SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem> <SelectItem value="center"></SelectItem>
@ -504,166 +260,302 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
</div> </div>
</Collapsible> </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> </div>
<Button </CollapsibleContent>
variant="outline" </Collapsible>
size="sm" );
className="h-7 w-full gap-1 text-xs border-dashed" }
onClick={addColumn}
> // ─── 메인 컴포넌트 ───
<Plus className="h-3 w-3" /> export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> = ({ config: configProp, onChange }) => {
const [tables, setTables] = useState<TableInfo[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [dataSourceOpen, setDataSourceOpen] = useState(false);
const [layoutOpen, setLayoutOpen] = useState(false);
const [filterOpen, setFilterOpen] = useState(false);
const config: ItemRoutingConfig = {
...defaultConfig,
...configProp,
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
modals: { ...defaultConfig.modals, ...configProp?.modals },
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(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const res = await tableManagementApi.getTableList();
if (res.success && res.data) {
setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
}
} catch { /* ignore */ } finally { setLoadingTables(false); }
};
loadTables();
}, []);
const dispatchConfigEvent = (newConfig: Partial<ItemRoutingConfig>) => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("componentConfigChanged", { detail: { config: { ...config, ...newConfig } } }));
}
};
const update = (partial: Partial<ItemRoutingConfig>) => {
const merged = { ...configProp, ...partial };
onChange(merged);
dispatchConfigEvent(partial);
};
const updateDataSource = (field: string, value: string) => {
const newDS = { ...config.dataSource, [field]: value };
onChange({ ...configProp, dataSource: newDS });
dispatchConfigEvent({ dataSource: newDS });
};
const updateModals = (field: string, value?: number) => {
const newM = { ...config.modals, [field]: value };
onChange({ ...configProp, modals: newM });
dispatchConfigEvent({ modals: newM });
};
// 필터 조건 관리
const filters = config.itemFilterConditions || [];
const addFilter = () => update({ itemFilterConditions: [...filters, { column: "", operator: "equals", value: "" }] });
const removeFilter = (idx: number) => update({ itemFilterConditions: filters.filter((_, i) => i !== idx) });
const updateFilter = (idx: number, field: keyof ItemFilterCondition, val: string) => {
const next = [...filters];
next[idx] = { ...next[idx], [field]: val };
update({ itemFilterConditions: next });
};
return (
<div className="space-y-4">
{/* ─── 품목 목록 모드 ─── */}
<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> </Button>
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
{/* ─── 3단계: 데이터 소스 (Collapsible) ─── */} {/* ─── 모달 연동 ─── */}
<Collapsible open={modalOpen} onOpenChange={setModalOpen}>
<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">
<Monitor className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{[config.modals.versionAddScreenId, config.modals.processAddScreenId, config.modals.processEditScreenId].filter(Boolean).length}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", modalOpen && "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>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<ScreenCombobox value={config.modals.versionAddScreenId} onChange={(v) => updateModals("versionAddScreenId", v)} />
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<ScreenCombobox value={config.modals.processAddScreenId} onChange={(v) => updateModals("processAddScreenId", v)} />
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<ScreenCombobox value={config.modals.processEditScreenId} onChange={(v) => updateModals("processEditScreenId", v)} />
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 공정 테이블 컬럼 ─── */}
<ColumnEditor
columns={config.processColumns}
onChange={(cols) => update({ processColumns: cols })}
tableName={config.dataSource.routingDetailTable}
title="공정 테이블 컬럼"
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
/>
{/* ─── 데이터 소스 ─── */}
<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;

View File

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

View File

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

View File

@ -20,7 +20,6 @@ import { usePopEvent } from "./usePopEvent";
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout"; import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
import { import {
PopComponentRegistry, PopComponentRegistry,
type ConnectionMetaItem,
} from "@/lib/registry/PopComponentRegistry"; } from "@/lib/registry/PopComponentRegistry";
interface UseConnectionResolverOptions { interface UseConnectionResolverOptions {
@ -29,14 +28,21 @@ interface UseConnectionResolverOptions {
componentTypes?: Map<string, string>; componentTypes?: Map<string, string>;
} }
interface AutoMatchPair {
sourceKey: string;
targetKey: string;
isFilter: boolean;
}
/** /**
* / connectionMeta에서 . * / connectionMeta에서 .
* 규칙: category="event" key가 * 1: category="event" key가 ( )
* 2: 소스 type="filter_value" + type="filter_value" ( )
*/ */
function getAutoMatchPairs( function getAutoMatchPairs(
sourceType: string, sourceType: string,
targetType: string targetType: string
): { sourceKey: string; targetKey: string }[] { ): AutoMatchPair[] {
const sourceDef = PopComponentRegistry.getComponent(sourceType); const sourceDef = PopComponentRegistry.getComponent(sourceType);
const targetDef = PopComponentRegistry.getComponent(targetType); const targetDef = PopComponentRegistry.getComponent(targetType);
@ -44,14 +50,18 @@ function getAutoMatchPairs(
return []; return [];
} }
const pairs: { sourceKey: string; targetKey: string }[] = []; const pairs: AutoMatchPair[] = [];
for (const s of sourceDef.connectionMeta.sendable) { for (const s of sourceDef.connectionMeta.sendable) {
if (s.category !== "event") continue;
for (const r of targetDef.connectionMeta.receivable) { for (const r of targetDef.connectionMeta.receivable) {
if (r.category !== "event") continue; if (s.category === "event" && r.category === "event" && s.key === r.key) {
if (s.key === r.key) { pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false });
pairs.push({ sourceKey: s.key, targetKey: r.key }); }
if (s.type === "filter_value" && r.type === "filter_value") {
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: true });
}
if (s.type === "all_rows" && r.type === "all_rows") {
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false });
} }
} }
} }
@ -93,10 +103,30 @@ export function useConnectionResolver({
const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`; const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`;
const unsub = subscribe(sourceEvent, (payload: unknown) => { const unsub = subscribe(sourceEvent, (payload: unknown) => {
if (pair.isFilter) {
const data = payload as Record<string, unknown> | null;
const fieldName = data?.fieldName as string | undefined;
const filterColumns = data?.filterColumns as string[] | undefined;
const filterMode = (data?.filterMode as string) || "contains";
// conn.filterConfig에 targetColumn이 명시되어 있으면 우선 사용
const effectiveColumn = conn.filterConfig?.targetColumn || fieldName;
const effectiveMode = conn.filterConfig?.filterMode || filterMode;
const baseFilterConfig = effectiveColumn
? { targetColumn: effectiveColumn, targetColumns: conn.filterConfig?.targetColumns || (filterColumns?.length ? filterColumns : [effectiveColumn]), filterMode: effectiveMode }
: conn.filterConfig;
publish(targetEvent, {
value: payload,
filterConfig: conn.filterConfig?.isSubTable
? { ...baseFilterConfig, isSubTable: true }
: baseFilterConfig,
_connectionId: conn.id,
});
} else {
publish(targetEvent, { publish(targetEvent, {
value: payload, value: payload,
_connectionId: conn.id, _connectionId: conn.id,
}); });
}
}); });
unsubscribers.push(unsub); unsubscribers.push(unsub);
} }
@ -121,13 +151,22 @@ export function useConnectionResolver({
const unsub = subscribe(sourceEvent, (payload: unknown) => { const unsub = subscribe(sourceEvent, (payload: unknown) => {
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`; const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
const enrichedPayload = { let resolvedFilterConfig = conn.filterConfig;
value: payload, if (!resolvedFilterConfig) {
filterConfig: conn.filterConfig, const data = payload as Record<string, unknown> | null;
_connectionId: conn.id, const fieldName = data?.fieldName as string | undefined;
}; const filterColumns = data?.filterColumns as string[] | undefined;
if (fieldName) {
const filterMode = (data?.filterMode as string) || "contains";
resolvedFilterConfig = { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode: filterMode as "equals" | "contains" | "starts_with" | "range" };
}
}
publish(targetEvent, enrichedPayload); publish(targetEvent, {
value: payload,
filterConfig: resolvedFilterConfig,
_connectionId: conn.id,
});
}); });
unsubscribers.push(unsub); unsubscribers.push(unsub);
} }

Some files were not shown because too many files have changed in this diff Show More