diff --git a/.cursor/rules/document-sync-rule.mdc b/.cursor/rules/document-sync-rule.mdc new file mode 100644 index 00000000..a2b7e13a --- /dev/null +++ b/.cursor/rules/document-sync-rule.mdc @@ -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 파싱 에러 방지). diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc new file mode 100644 index 00000000..2f34326f --- /dev/null +++ b/.cursor/rules/project-conventions.mdc @@ -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 { + 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(sql, params)` — 다건 조회 (배열 반환) +- `queryOne(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` — 표준 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 ( +
+
+ {/* 페이지 헤더 */} +
+

페이지 제목

+

페이지 설명

+
+ + {/* 툴바 + 테이블 + 모달 등 */} + + +
+
+ ); +} +``` + +**핵심 규칙:** +- 모든 페이지: `"use client"` + `export default function` +- 비즈니스 로직은 커스텀 훅으로 분리 +- 페이지는 훅 + UI 컴포넌트 조합에 집중 + +### 3.3 컴포넌트 패턴 + +```tsx +// frontend/components/xxx/XxxToolbar.tsx + +interface XxxToolbarProps { + searchFilter: SearchFilter; + totalCount: number; + onSearchChange: (filter: Partial) => void; + onCreateClick: () => void; +} + +export function XxxToolbar({ + searchFilter, + totalCount, + onSearchChange, + onCreateClick, +}: XxxToolbarProps) { + return ( +
+ {/* ... */} +
+ ); +} +``` + +**핵심 규칙:** +- `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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 { + success: boolean; + data?: T; + message?: string; +} + +export async function getXxxList(params?: Record) { + 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` 라이브러리 직접 사용 +- 루트 레이아웃에 `` 설정됨 + +### 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 ( + + + + 모달 제목 + 설명 + + {/* 컨텐츠 */} + + + + + + + ); +} +``` + +### 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. **항상 한글로 답변** diff --git a/.cursorrules b/.cursorrules index 77180695..0019badc 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1510,3 +1510,69 @@ const query = ` **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 컴포넌트로 직접 구현하는 것 금지 + diff --git a/.gitignore b/.gitignore index 08276481..5e66bd12 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ dist/ build/ build/Release +# Gradle +.gradle/ +**/backend/.gradle/ + # Cache .npm .eslintcache diff --git a/ai-assistant/package-lock.json b/ai-assistant/package-lock.json index 30eef7bc..5cc0f755 100644 --- a/ai-assistant/package-lock.json +++ b/ai-assistant/package-lock.json @@ -947,6 +947,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -2184,6 +2185,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 914f608c..f45a88cd 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -131,6 +131,7 @@ import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관 import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리 import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리 import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리 +import packagingRoutes from "./routes/packagingRoutes"; // 포장/적재정보 관리 import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리 import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리 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-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리 app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리 +app.use("/api/packaging", packagingRoutes); // 포장/적재정보 관리 app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리 app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리 app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 9dea63b8..dc8cf064 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -108,6 +108,46 @@ export async function getUserMenus( } } +/** + * POP 메뉴 목록 조회 + * [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환 + */ +export async function getPopMenus( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const userCompanyCode = req.user?.companyCode || "ILSHIN"; + const userType = req.user?.userType; + + const result = await AdminService.getPopMenuList({ + userCompanyCode, + userType, + }); + + const response: ApiResponse = { + success: true, + message: "POP 메뉴 목록 조회 성공", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("POP 메뉴 목록 조회 중 오류 발생:", error); + + const response: ApiResponse = { + 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( - `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)] ); @@ -3574,7 +3614,7 @@ export async function getTableSchema( ic.character_maximum_length, ic.numeric_precision, 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.is_nullable, ttc_common.is_nullable) AS ttc_is_nullable, COALESCE(ttc_company.is_unique, ttc_common.is_unique) AS ttc_is_unique diff --git a/backend-node/src/controllers/auditLogController.ts b/backend-node/src/controllers/auditLogController.ts index 828529bd..cd59a435 100644 --- a/backend-node/src/controllers/auditLogController.ts +++ b/backend-node/src/controllers/auditLogController.ts @@ -1,6 +1,6 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../middleware/authMiddleware"; -import { auditLogService } from "../services/auditLogService"; +import { auditLogService, getClientIp, AuditAction, AuditResourceType } from "../services/auditLogService"; import { query } from "../database/db"; import logger from "../utils/logger"; @@ -137,3 +137,40 @@ export const getAuditLogUsers = async ( }); } }; + +/** + * 프론트엔드에서 직접 감사 로그 기록 (그룹 복제 등 프론트 오케스트레이션 작업용) + */ +export const createAuditLog = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + 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: "감사 로그 기록 실패" }); + } +}; diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index ebf3e8f5..809513b6 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -6,6 +6,7 @@ import { AuthService } from "../services/authService"; import { JwtUtils } from "../utils/jwtUtils"; import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth"; import { logger } from "../utils/logger"; +import { sendSmartFactoryLog } from "../utils/smartFactoryLog"; export class AuthController { /** @@ -50,29 +51,24 @@ export class AuthController { logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`); + // 메뉴 조회를 위한 공통 파라미터 + const { AdminService } = await import("../services/adminService"); + const paramMap = { + userId: loginResult.userInfo.userId, + userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN", + userType: loginResult.userInfo.userType, + userLang: "ko", + }; + // 사용자의 첫 번째 접근 가능한 메뉴 조회 let firstMenuPath: string | null = null; try { - const { AdminService } = await import("../services/adminService"); - const paramMap = { - userId: loginResult.userInfo.userId, - userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN", - userType: loginResult.userInfo.userType, - userLang: "ko", - }; - const menuList = await AdminService.getUserMenuList(paramMap); logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); - // 접근 가능한 첫 번째 메뉴 찾기 - // 조건: - // 1. LEV (레벨)이 2 이상 (최상위 폴더 제외) - // 2. MENU_URL이 있고 비어있지 않음 - // 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴 const firstMenu = menuList.find((menu: any) => { const level = menu.lev || menu.level; const url = menu.menu_url || menu.url; - return level >= 2 && url && url.trim() !== "" && url !== "#"; }); @@ -86,13 +82,37 @@ export class AuthController { 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({ success: true, message: "로그인 성공", data: { userInfo, token: loginResult.token, - firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가 + firstMenuPath, + popLandingPath, }, }); } else { diff --git a/backend-node/src/controllers/categoryTreeController.ts b/backend-node/src/controllers/categoryTreeController.ts index 54b93ee4..98d74fa4 100644 --- a/backend-node/src/controllers/categoryTreeController.ts +++ b/backend-node/src/controllers/categoryTreeController.ts @@ -6,6 +6,7 @@ import { Router, Request, Response } from "express"; import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService"; import { logger } from "../utils/logger"; import { authenticateToken } from "../middleware/authMiddleware"; +import { auditLogService, getClientIp } from "../services/auditLogService"; const router = Router(); @@ -16,6 +17,7 @@ router.use(authenticateToken); interface AuthenticatedRequest extends Request { user?: { userId: string; + userName: string; companyCode: string; }; } @@ -157,6 +159,21 @@ router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => { 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({ success: true, data: value, @@ -182,6 +199,7 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon const companyCode = req.user?.companyCode || "*"; const updatedBy = req.user?.userId; + const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId)); const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy); 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({ success: true, data: value, @@ -239,6 +275,7 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res const { valueId } = req.params; const companyCode = req.user?.companyCode || "*"; + const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId)); const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId)); 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({ success: true, message: "삭제되었습니다", diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index a9bd0755..a67ba44e 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -396,6 +396,20 @@ export class CommonCodeController { 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({ success: true, data: code, @@ -440,6 +454,19 @@ export class CommonCodeController { 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({ success: true, message: "코드 삭제 성공", diff --git a/backend-node/src/controllers/ddlController.ts b/backend-node/src/controllers/ddlController.ts index 631b6360..00baf75d 100644 --- a/backend-node/src/controllers/ddlController.ts +++ b/backend-node/src/controllers/ddlController.ts @@ -438,6 +438,19 @@ export class DDLController { ); 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({ success: true, message: result.message, diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index c0c4c36d..fa70de66 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -266,7 +266,6 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re logger.info("컬럼 DISTINCT 값 조회 성공", { tableName, columnName, - columnInputType: columnInputType || "none", labelColumn: effectiveLabelColumn, companyCode, hasFilters: !!filtersParam, diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index a3887ab8..9d05a1b7 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -193,6 +193,7 @@ router.post( auditLogService.log({ companyCode, userId, + userName: req.user?.userName, action: "CREATE", resourceType: "NUMBERING_RULE", resourceId: String(newRule.ruleId), @@ -243,6 +244,7 @@ router.put( auditLogService.log({ companyCode, userId: req.user?.userId || "", + userName: req.user?.userName, action: "UPDATE", resourceType: "NUMBERING_RULE", resourceId: ruleId, @@ -285,6 +287,7 @@ router.delete( auditLogService.log({ companyCode, userId: req.user?.userId || "", + userName: req.user?.userName, action: "DELETE", resourceType: "NUMBERING_RULE", resourceId: ruleId, @@ -521,6 +524,56 @@ router.post( companyCode, userId ); + + const isUpdate = !!ruleConfig.ruleId; + + const resetPeriodLabel: Record = { + none: "초기화 안함", daily: "일별", monthly: "월별", yearly: "연별", + }; + const partTypeLabel: Record = { + 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 }); } catch (error: any) { logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message }); @@ -535,10 +588,25 @@ router.delete( authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; + const userId = req.user!.userId; const { ruleId } = req.params; try { 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({ success: true, message: "테스트 채번 규칙이 삭제되었습니다", diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts new file mode 100644 index 00000000..c804963f --- /dev/null +++ b/backend-node/src/controllers/packagingController.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }); + } +} diff --git a/backend-node/src/controllers/processWorkStandardController.ts b/backend-node/src/controllers/processWorkStandardController.ts index e72f6b9f..c3eeb736 100644 --- a/backend-node/src/controllers/processWorkStandardController.ts +++ b/backend-node/src/controllers/processWorkStandardController.ts @@ -30,26 +30,68 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon routingTable = "item_routing_version", routingFkColumn = "item_code", search = "", + extraColumns = "", + filterConditions = "", } = req.query as Record; - const searchCondition = search - ? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)` - : ""; 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 = ` SELECT i.id, i.${nameColumn} AS item_name, - i.${codeColumn} AS item_code, + i.${codeColumn} AS item_code + ${extraSelect ? ", " + extraSelect : ""}, COUNT(rv.id) AS routing_count FROM ${tableName} i LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} AND rv.company_code = i.company_code WHERE i.company_code = $1 ${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 `; @@ -711,3 +753,184 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) { 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; + + 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 }); + } +} diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index cb6df7c4..a232c03d 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -614,20 +614,6 @@ export const copyScreenWithModals = async ( 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({ success: true, 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({ success: true, data: copiedScreen, diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index 60a0af08..49fe6e72 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -62,24 +62,31 @@ export const getAllCategoryColumns = async (req: AuthenticatedRequest, res: Resp */ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = req.user!.companyCode; + const userCompanyCode = req.user!.companyCode; const { tableName, columnName } = req.params; const includeInactive = req.query.includeInactive === "true"; 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("카테고리 값 조회 요청", { tableName, columnName, menuObjid, - companyCode, + companyCode: effectiveCompanyCode, + filterCompanyCode, }); const values = await tableCategoryValueService.getCategoryValues( tableName, columnName, - companyCode, + effectiveCompanyCode, includeInactive, - menuObjid // ← menuObjid 전달 + menuObjid ); return res.json({ diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 5087a1c9..5c53094f 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -963,6 +963,15 @@ export async function addTableData( logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`); + const systemFields = new Set([ + "id", "created_date", "updated_date", "writer", "company_code", + "createdDate", "updatedDate", "companyCode", + ]); + const auditData: Record = {}; + for (const [k, v] of Object.entries(data)) { + if (!systemFields.has(k)) auditData[k] = v; + } + auditLogService.log({ companyCode: req.user?.companyCode || "", userId: req.user?.userId || "", @@ -973,7 +982,7 @@ export async function addTableData( resourceName: tableName, tableName, summary: `${tableName} 데이터 추가`, - changes: { after: data }, + changes: { after: auditData }, ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -1096,10 +1105,14 @@ export async function editTableData( return; } - // 변경된 필드만 추출 + const systemFieldsForEdit = new Set([ + "id", "created_date", "updated_date", "writer", "company_code", + "createdDate", "updatedDate", "companyCode", + ]); const changedBefore: Record = {}; const changedAfter: Record = {}; for (const key of Object.keys(updatedData)) { + if (systemFieldsForEdit.has(key)) continue; if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) { changedBefore[key] = originalData[key]; changedAfter[key] = updatedData[key]; @@ -3105,3 +3118,153 @@ export async function getNumberingColumnsByCompany( }); } } + +/** + * 엑셀 업로드 전 데이터 검증 + * POST /api/table-management/validate-excel + * Body: { tableName, data: Record[] } + */ +export async function validateExcelData( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, data } = req.body as { + tableName: string; + data: Record[]; + }; + 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(); + 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>(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: "데이터 검증 중 오류가 발생했습니다." }); + } +} diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index b9964962..a0779d50 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { getAdminMenus, getUserMenus, + getPopMenus, getMenuInfo, saveMenu, // 메뉴 추가 updateMenu, // 메뉴 수정 @@ -40,6 +41,7 @@ router.use(authenticateToken); // 메뉴 관련 API router.get("/menus", getAdminMenus); router.get("/user-menus", getUserMenus); +router.get("/pop-menus", getPopMenus); router.get("/menus/:menuId", getMenuInfo); router.post("/menus", saveMenu); // 메뉴 추가 router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!) diff --git a/backend-node/src/routes/auditLogRoutes.ts b/backend-node/src/routes/auditLogRoutes.ts index 0d219018..4c6392a8 100644 --- a/backend-node/src/routes/auditLogRoutes.ts +++ b/backend-node/src/routes/auditLogRoutes.ts @@ -1,11 +1,12 @@ import { Router } from "express"; import { authenticateToken } from "../middleware/authMiddleware"; -import { getAuditLogs, getAuditLogStats, getAuditLogUsers } from "../controllers/auditLogController"; +import { getAuditLogs, getAuditLogStats, getAuditLogUsers, createAuditLog } from "../controllers/auditLogController"; const router = Router(); router.get("/", authenticateToken, getAuditLogs); router.get("/stats", authenticateToken, getAuditLogStats); router.get("/users", authenticateToken, getAuditLogUsers); +router.post("/", authenticateToken, createAuditLog); export default router; diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index 177b4304..30fffd7b 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -8,6 +8,7 @@ import { logger } from "../../utils/logger"; import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService"; import { AuthenticatedRequest } from "../../types/auth"; import { authenticateToken } from "../../middleware/authMiddleware"; +import { auditLogService, getClientIp } from "../../services/auditLogService"; const router = Router(); @@ -124,6 +125,21 @@ router.post("/", async (req: AuthenticatedRequest, res: Response) => { `플로우 저장 성공: ${result.flowId} (회사: ${userCompanyCode})` ); + auditLogService.log({ + companyCode: userCompanyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "CREATE", + resourceType: "NODE_FLOW", + resourceId: String(result.flowId), + resourceName: flowName, + tableName: "node_flows", + summary: `노드 플로우 "${flowName}" 생성`, + changes: { after: { flowName, flowDescription } }, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "플로우가 저장되었습니다.", @@ -143,7 +159,7 @@ router.post("/", async (req: AuthenticatedRequest, res: Response) => { /** * 플로우 수정 */ -router.put("/", async (req: Request, res: Response) => { +router.put("/", async (req: AuthenticatedRequest, res: Response) => { try { const { flowId, flowName, flowDescription, flowData } = req.body; @@ -154,6 +170,11 @@ router.put("/", async (req: Request, res: Response) => { }); } + const oldFlow = await queryOne( + `SELECT flow_name, flow_description FROM node_flows WHERE flow_id = $1`, + [flowId] + ); + await query( ` UPDATE node_flows @@ -168,6 +189,25 @@ router.put("/", async (req: Request, res: Response) => { logger.info(`플로우 수정 성공: ${flowId}`); + const userCompanyCode = req.user?.companyCode || "*"; + auditLogService.log({ + companyCode: userCompanyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "UPDATE", + resourceType: "NODE_FLOW", + resourceId: String(flowId), + resourceName: flowName, + tableName: "node_flows", + summary: `노드 플로우 "${flowName}" 수정`, + changes: { + before: oldFlow ? { flowName: (oldFlow as any).flow_name, flowDescription: (oldFlow as any).flow_description } : undefined, + after: { flowName, flowDescription }, + }, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "플로우가 수정되었습니다.", @@ -187,10 +227,15 @@ router.put("/", async (req: Request, res: Response) => { /** * 플로우 삭제 */ -router.delete("/:flowId", async (req: Request, res: Response) => { +router.delete("/:flowId", async (req: AuthenticatedRequest, res: Response) => { try { const { flowId } = req.params; + const oldFlow = await queryOne( + `SELECT flow_name, flow_description, company_code FROM node_flows WHERE flow_id = $1`, + [flowId] + ); + await query( ` DELETE FROM node_flows @@ -201,6 +246,25 @@ router.delete("/:flowId", async (req: Request, res: Response) => { logger.info(`플로우 삭제 성공: ${flowId}`); + const userCompanyCode = req.user?.companyCode || "*"; + const flowName = (oldFlow as any)?.flow_name || `ID:${flowId}`; + auditLogService.log({ + companyCode: userCompanyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "DELETE", + resourceType: "NODE_FLOW", + resourceId: String(flowId), + resourceName: flowName, + tableName: "node_flows", + summary: `노드 플로우 "${flowName}" 삭제`, + changes: { + before: oldFlow ? { flowName: (oldFlow as any).flow_name, flowDescription: (oldFlow as any).flow_description } : undefined, + }, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "플로우가 삭제되었습니다.", diff --git a/backend-node/src/routes/packagingRoutes.ts b/backend-node/src/routes/packagingRoutes.ts new file mode 100644 index 00000000..db921caa --- /dev/null +++ b/backend-node/src/routes/packagingRoutes.ts @@ -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; diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index 730572d8..d25c6bdc 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -17,6 +17,7 @@ interface AutoGenMappingInfo { numberingRuleId: string; targetColumn: string; showResultModal?: boolean; + shareAcrossItems?: boolean; } interface HiddenMappingInfo { @@ -182,6 +183,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); } + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + + // 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번 + const sharedCodes: Record = {}; + 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) { const columns: string[] = ["company_code"]; const values: unknown[] = [companyCode]; @@ -225,26 +251,41 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(value); } - const allAutoGen = [ - ...(fieldMapping?.autoGenMappings ?? []), - ...(cardMapping?.autoGenMappings ?? []), - ]; for (const ag of allAutoGen) { if (!ag.numberingRuleId || !ag.targetColumn) continue; if (!isSafeIdentifier(ag.targetColumn)) continue; if (columns.includes(`"${ag.targetColumn}"`)) continue; - try { - const generatedCode = await numberingRuleService.allocateCode( - ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, - ); + + if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) { columns.push(`"${ag.targetColumn}"`); - values.push(generatedCode); - generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); - } catch (err: any) { - logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + values.push(sharedCodes[ag.targetColumn]); + } else if (!ag.shareAcrossItems) { + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } } } + if (!columns.includes('"created_date"')) { + columns.push('"created_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"updated_date"')) { + columns.push('"updated_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"writer"') && userId) { + columns.push('"writer"'); + values.push(userId); + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); await client.query( @@ -292,8 +333,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp for (let i = 0; i < lookupValues.length; i++) { const item = items[i] ?? {}; const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item); + const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; 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]], ); 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 autoUpdatedDb = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", "); 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], ); processedCount += lookupValues.length; @@ -325,7 +368,14 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp if (valSource === "linked") { value = item[task.sourceField ?? ""] ?? null; } 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; @@ -341,8 +391,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp setSql = `"${task.targetColumn}" = $1`; } + const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; 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]], ); processedCount++; @@ -448,6 +499,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); } + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + + // 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번 + const sharedCodes: Record = {}; + 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) { const columns: string[] = ["company_code"]; const values: unknown[] = [companyCode]; @@ -467,7 +543,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } - // 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼) const allHidden = [ ...(fieldMapping?.hiddenMappings ?? []), ...(cardMapping?.hiddenMappings ?? []), @@ -494,37 +569,44 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(value); } - // 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급 - const allAutoGen = [ - ...(fieldMapping?.autoGenMappings ?? []), - ...(cardMapping?.autoGenMappings ?? []), - ]; for (const ag of allAutoGen) { if (!ag.numberingRuleId || !ag.targetColumn) continue; if (!isSafeIdentifier(ag.targetColumn)) continue; if (columns.includes(`"${ag.targetColumn}"`)) continue; - try { - const generatedCode = await numberingRuleService.allocateCode( - ag.numberingRuleId, - companyCode, - { ...fieldValues, ...item }, - ); + + if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) { columns.push(`"${ag.targetColumn}"`); - values.push(generatedCode); - generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); - logger.info("[pop/execute-action] 채번 완료", { - ruleId: ag.numberingRuleId, - targetColumn: ag.targetColumn, - generatedCode, - }); - } catch (err: any) { - logger.error("[pop/execute-action] 채번 실패", { - ruleId: ag.numberingRuleId, - error: err.message, - }); + values.push(sharedCodes[ag.targetColumn]); + } else if (!ag.shareAcrossItems) { + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } } } + if (!columns.includes('"created_date"')) { + columns.push('"created_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"updated_date"')) { + columns.push('"updated_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"writer"') && userId) { + columns.push('"writer"'); + values.push(userId); + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; @@ -558,6 +640,19 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(fieldValues[sourceField] ?? null); } + 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) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); 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") { + const autoUpd = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; 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]); processedCount += lookupValues.length; } else { for (let i = 0; i < lookupValues.length; i++) { const item = items[i] ?? {}; const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item); + const autoUpd2 = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; 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]] ); processedCount++; diff --git a/backend-node/src/routes/processWorkStandardRoutes.ts b/backend-node/src/routes/processWorkStandardRoutes.ts index 7630b359..c613d55f 100644 --- a/backend-node/src/routes/processWorkStandardRoutes.ts +++ b/backend-node/src/routes/processWorkStandardRoutes.ts @@ -33,4 +33,10 @@ router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail); // 전체 저장 (일괄) 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; diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 92449cf6..6a4a8ce8 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -27,6 +27,7 @@ import { getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회 getNumberingColumnsByCompany, // 채번 타입 컬럼 조회 multiTableSave, // 🆕 범용 다중 테이블 저장 + validateExcelData, // 엑셀 업로드 전 데이터 검증 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회 getTableConstraints, // 🆕 PK/인덱스 상태 조회 @@ -280,4 +281,9 @@ router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu); */ router.post("/multi-table-save", multiTableSave); +/** + * 엑셀 업로드 전 데이터 검증 + */ +router.post("/validate-excel", validateExcelData); + export default router; diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index e5d0c1a0..a27fcc77 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -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( + `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( + `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; + } + } + /** * 메뉴 정보 조회 */ diff --git a/backend-node/src/services/auditLogService.ts b/backend-node/src/services/auditLogService.ts index bc77be49..c86a71fd 100644 --- a/backend-node/src/services/auditLogService.ts +++ b/backend-node/src/services/auditLogService.ts @@ -41,7 +41,8 @@ export type AuditResourceType = | "DATA" | "TABLE" | "NUMBERING_RULE" - | "BATCH"; + | "BATCH" + | "NODE_FLOW"; export interface AuditLogParams { companyCode: string; @@ -65,6 +66,7 @@ export interface AuditLogParams { export interface AuditLogEntry { id: number; company_code: string; + company_name: string | null; user_id: string; user_name: string | null; action: string; @@ -106,6 +108,7 @@ class AuditLogService { */ async log(params: AuditLogParams): Promise { try { + logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`); await query( `INSERT INTO system_audit_log (company_code, user_id, user_name, action, resource_type, @@ -127,8 +130,9 @@ class AuditLogService { params.requestPath || null, ] ); - } catch (error) { - logger.error("감사 로그 기록 실패 (무시됨)", { error, params }); + logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`); + } catch (error: any) { + logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params }); } } @@ -185,40 +189,40 @@ class AuditLogService { let paramIndex = 1; if (!isSuperAdmin && filters.companyCode) { - conditions.push(`company_code = $${paramIndex++}`); + conditions.push(`sal.company_code = $${paramIndex++}`); params.push(filters.companyCode); } else if (isSuperAdmin && filters.companyCode) { - conditions.push(`company_code = $${paramIndex++}`); + conditions.push(`sal.company_code = $${paramIndex++}`); params.push(filters.companyCode); } if (filters.userId) { - conditions.push(`user_id = $${paramIndex++}`); + conditions.push(`sal.user_id = $${paramIndex++}`); params.push(filters.userId); } if (filters.resourceType) { - conditions.push(`resource_type = $${paramIndex++}`); + conditions.push(`sal.resource_type = $${paramIndex++}`); params.push(filters.resourceType); } if (filters.action) { - conditions.push(`action = $${paramIndex++}`); + conditions.push(`sal.action = $${paramIndex++}`); params.push(filters.action); } if (filters.tableName) { - conditions.push(`table_name = $${paramIndex++}`); + conditions.push(`sal.table_name = $${paramIndex++}`); params.push(filters.tableName); } if (filters.dateFrom) { - conditions.push(`created_at >= $${paramIndex++}::timestamptz`); + conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`); params.push(filters.dateFrom); } if (filters.dateTo) { - conditions.push(`created_at <= $${paramIndex++}::timestamptz`); + conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`); params.push(filters.dateTo); } if (filters.search) { 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}%`); paramIndex++; @@ -232,14 +236,17 @@ class AuditLogService { const offset = (page - 1) * limit; 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 ); const total = parseInt(countResult[0].count, 10); const data = await query( - `SELECT * FROM system_audit_log ${whereClause} - ORDER BY created_at DESC + `SELECT sal.*, ci.company_name + 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++}`, [...params, limit, offset] ); diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 1b183074..604405c3 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1715,8 +1715,8 @@ export class DynamicFormService { `SELECT component_id, properties FROM screen_layouts WHERE screen_id = $1 - AND component_type = $2`, - [screenId, "component"] + AND component_type IN ('component', 'v2-button-primary')`, + [screenId] ); console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length); @@ -1747,8 +1747,12 @@ export class DynamicFormService { (triggerType === "delete" && buttonActionType === "delete") || ((triggerType === "insert" || triggerType === "update") && buttonActionType === "save"); + const isButtonComponent = + properties?.componentType === "button-primary" || + properties?.componentType === "v2-button-primary"; + if ( - properties?.componentType === "button-primary" && + isButtonComponent && isMatchingAction && properties?.webTypeConfig?.enableDataflowControl === true ) { @@ -1877,7 +1881,7 @@ export class DynamicFormService { { sourceData: [savedData], dataSourceType: "formData", - buttonId: "save-button", + buttonId: `${triggerType}-button`, screenId: screenId, userId: userId, companyCode: companyCode, diff --git a/backend-node/src/services/multiTableExcelService.ts b/backend-node/src/services/multiTableExcelService.ts index 7f9de79d..03e5db4c 100644 --- a/backend-node/src/services/multiTableExcelService.ts +++ b/backend-node/src/services/multiTableExcelService.ts @@ -972,7 +972,7 @@ class MultiTableExcelService { c.column_name, c.is_nullable AS db_is_nullable, 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.is_nullable, cl.is_nullable) AS ttc_is_nullable FROM information_schema.columns c diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 4c5bdc57..9d5d56a5 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2346,19 +2346,24 @@ export class ScreenManagementService { } /** - * 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료) + * 메뉴별 화면 목록 조회 + * company_code 매칭: 본인 회사 할당 + SUPER_ADMIN 글로벌 할당('*') 모두 조회 + * 본인 회사 할당이 우선, 없으면 글로벌 할당 사용 */ async getScreensByMenu( menuObjid: number, companyCode: string, ): Promise { const screens = await query( - `SELECT sd.* FROM screen_menu_assignments sma + `SELECT sd.* + FROM screen_menu_assignments sma INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id WHERE sma.menu_objid = $1 - AND sma.company_code = $2 + AND (sma.company_code = $2 OR sma.company_code = '*') AND sma.is_active = 'Y' - ORDER BY sma.display_order ASC`, + ORDER BY + CASE WHEN sma.company_code = $2 THEN 0 ELSE 1 END, + sma.display_order ASC`, [menuObjid, companyCode], ); diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 96efdfbb..a8b12605 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -217,12 +217,12 @@ class TableCategoryValueService { AND column_name = $2 `; - // category_values 테이블 사용 (menu_objid 없음) + // company_code 기반 필터링 if (companyCode === "*") { - // 최고 관리자: 모든 값 조회 - query = baseSelect; + // 최고 관리자: 공통(*) 카테고리만 조회 (모든 회사 카테고리 혼합 방지) + query = baseSelect + ` AND company_code = '*'`; params = [tableName, columnName]; - logger.info("최고 관리자 전체 카테고리 값 조회 (category_values)"); + logger.info("최고 관리자: 공통 카테고리만 조회 (category_values)"); } else { // 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회 query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 6d994f93..2ddae736 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -190,7 +190,7 @@ export class TableManagementService { ? await query( `SELECT 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 "dbType", COALESCE(ttc.input_type, cl.input_type, 'text') as "webType", @@ -3367,22 +3367,26 @@ export class TableManagementService { `${safeColumn} != '${String(value).replace(/'/g, "''")}'` ); break; - case "in": - if (Array.isArray(value) && value.length > 0) { - const values = value + case "in": { + const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; + if (inArr.length > 0) { + const values = inArr .map((v) => `'${String(v).replace(/'/g, "''")}'`) .join(", "); filterConditions.push(`${safeColumn} IN (${values})`); } break; - case "not_in": - if (Array.isArray(value) && value.length > 0) { - const values = value + } + case "not_in": { + const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; + if (notInArr.length > 0) { + const values = notInArr .map((v) => `'${String(v).replace(/'/g, "''")}'`) .join(", "); filterConditions.push(`${safeColumn} NOT IN (${values})`); } break; + } case "contains": filterConditions.push( `${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'` @@ -4500,26 +4504,30 @@ export class TableManagementService { const rawColumns = await query( `SELECT - column_name as "columnName", - column_name as "displayName", - data_type as "dataType", - udt_name as "dbType", - is_nullable as "isNullable", - column_default as "defaultValue", - character_maximum_length as "maxLength", - numeric_precision as "numericPrecision", - numeric_scale as "numericScale", + c.column_name as "columnName", + c.column_name as "displayName", + c.data_type as "dataType", + c.udt_name as "dbType", + c.is_nullable as "isNullable", + c.column_default as "defaultValue", + c.character_maximum_length as "maxLength", + c.numeric_precision as "numericPrecision", + c.numeric_scale as "numericScale", CASE - WHEN column_name IN ( - SELECT column_name FROM information_schema.key_column_usage - WHERE table_name = $1 AND constraint_name LIKE '%_pkey' + WHEN c.column_name IN ( + SELECT kcu.column_name FROM information_schema.key_column_usage kcu + WHERE kcu.table_name = $1 AND kcu.constraint_name LIKE '%_pkey' ) THEN true ELSE false - END as "isPrimaryKey" - FROM information_schema.columns - WHERE table_name = $1 - AND table_schema = 'public' - ORDER BY ordinal_position`, + END as "isPrimaryKey", + col_description( + (SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')), + c.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] ); @@ -4529,10 +4537,10 @@ export class TableManagementService { displayName: col.displayName, dataType: col.dataType, dbType: col.dbType, - webType: "text", // 기본값 + webType: "text", inputType: "direct", detailSettings: "{}", - description: "", // 필수 필드 추가 + description: col.columnComment || "", isNullable: col.isNullable, isPrimaryKey: col.isPrimaryKey, defaultValue: col.defaultValue, @@ -4543,6 +4551,7 @@ export class TableManagementService { numericScale: col.numericScale ? Number(col.numericScale) : undefined, displayOrder: 0, isVisible: true, + columnComment: col.columnComment || "", })); logger.info( diff --git a/backend-node/src/utils/dataFilterUtil.ts b/backend-node/src/utils/dataFilterUtil.ts index a4e81fd6..0f472331 100644 --- a/backend-node/src/utils/dataFilterUtil.ts +++ b/backend-node/src/utils/dataFilterUtil.ts @@ -98,23 +98,27 @@ export function buildDataFilterWhereClause( paramIndex++; break; - case "in": - if (Array.isArray(value) && value.length > 0) { - const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + case "in": { + const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; + if (inArr.length > 0) { + const placeholders = inArr.map((_, idx) => `$${paramIndex + idx}`).join(", "); conditions.push(`${columnRef} IN (${placeholders})`); - params.push(...value); - paramIndex += value.length; + params.push(...inArr); + paramIndex += inArr.length; } break; + } - case "not_in": - if (Array.isArray(value) && value.length > 0) { - const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + case "not_in": { + const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; + if (notInArr.length > 0) { + const placeholders = notInArr.map((_, idx) => `$${paramIndex + idx}`).join(", "); conditions.push(`${columnRef} NOT IN (${placeholders})`); - params.push(...value); - paramIndex += value.length; + params.push(...notInArr); + paramIndex += notInArr.length; } break; + } case "contains": conditions.push(`${columnRef} LIKE $${paramIndex}`); diff --git a/backend-node/src/utils/smartFactoryLog.ts b/backend-node/src/utils/smartFactoryLog.ts new file mode 100644 index 00000000..ea8d9aec --- /dev/null +++ b/backend-node/src/utils/smartFactoryLog.ts @@ -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 { + 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}`; +} diff --git a/docs/POP_작업진행_설계서.md b/docs/POP_작업진행_설계서.md new file mode 100644 index 00000000..3b77ddc5 --- /dev/null +++ b/docs/POP_작업진행_설계서.md @@ -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` 필수 (멀티테넌시) diff --git a/docs/screen-implementation-guide/00_analysis/full-screen-analysis.md b/docs/screen-implementation-guide/00_analysis/full-screen-analysis.md deleted file mode 100644 index 9b4a9908..00000000 --- a/docs/screen-implementation-guide/00_analysis/full-screen-analysis.md +++ /dev/null @@ -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 (타임라인, 모바일 스타일)로 개별 개발 필요 diff --git a/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md b/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md deleted file mode 100644 index b37abf5e..00000000 --- a/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md +++ /dev/null @@ -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 구조만 -**별도 설정 필요한 것**: 저장 테이블, 버튼 액션, 조건 처리, 다중 행 처리 diff --git a/docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md b/docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md new file mode 100644 index 00000000..1ba0da01 --- /dev/null +++ b/docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md @@ -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" } }` diff --git a/docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md b/docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md new file mode 100644 index 00000000..14182a91 --- /dev/null +++ b/docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md @@ -0,0 +1,1146 @@ +# WACE 화면 구현 실행 가이드 (챗봇/AI 에이전트 전용) + +> **최종 업데이트**: 2026-03-13 +> **용도**: 사용자가 "수주관리 화면 만들어줘"라고 요청하면, 이 문서를 참조하여 SQL을 직접 생성하고 화면을 구현하는 AI 챗봇용 실행 가이드 +> **핵심**: 이 문서의 SQL 템플릿을 따라 INSERT하면 화면이 자동으로 생성된다 + +--- + +## 0. 절대 규칙 + +1. 사용자 업무 화면(수주, 생산, 품질 등)은 **React 코드(.tsx) 작성 금지** → DB INSERT로만 구현 +2. 모든 DB 컬럼은 **VARCHAR(500)** (날짜 컬럼만 TIMESTAMP) +3. 모든 테이블에 **기본 5개 컬럼** 필수: id, created_date, updated_date, writer, company_code +4. 모든 INSERT에 **ON CONFLICT** 절 필수 (중복 방지) +5. 컴포넌트는 반드시 **v2-** 접두사 사용 +6. **[최우선] 비즈니스 테이블 CREATE TABLE 시 NOT NULL / UNIQUE 제약조건 절대 금지!** + +> **왜 DB 레벨 제약조건을 걸면 안 되는가?** +> +> 이 시스템은 **멀티테넌시(Multi-Tenancy)** 환경이다. +> 각 회사(tenant)마다 같은 테이블을 공유하되, **필수값/유니크 규칙이 회사별로 다를 수 있다.** +> +> 따라서 제약조건은 DB에 직접 거는 것이 아니라, **관리자 메뉴에서 회사별 메타데이터**로 논리적으로 제어한다: +> - **필수값**: `table_type_columns.is_nullable = 'N'` → 애플리케이션 레벨에서 검증 +> - **유니크**: `table_type_columns.is_unique = 'Y'` → 애플리케이션 레벨에서 검증 +> +> DB 레벨에서 NOT NULL이나 UNIQUE를 걸면, **특정 회사에만 적용해야 할 규칙이 모든 회사에 강제되어** 멀티테넌시가 깨진다. +> +> **허용**: 기본 5개 컬럼의 `id` PRIMARY KEY, `DEFAULT` 값만 DB 레벨에서 설정 +> **금지**: 비즈니스 컬럼에 `NOT NULL`, `UNIQUE`, `CHECK`, `FOREIGN KEY` 등 DB 제약조건 직접 적용 + +--- + +## 1. 화면 생성 전체 파이프라인 + +사용자가 화면을 요청하면 아래 7단계를 순서대로 실행한다. + +``` +Step 1: 비즈니스 테이블 CREATE TABLE +Step 2: table_labels INSERT (테이블 라벨) +Step 3: table_type_columns INSERT (컬럼 타입 정의, company_code='*') +Step 4: column_labels INSERT (컬럼 한글 라벨) +Step 5: screen_definitions INSERT → screen_id 획득 +Step 6: screen_layouts_v2 INSERT (레이아웃 JSON) +Step 7: menu_info INSERT (메뉴 등록) +``` + +**선택적 추가 단계**: +- 채번 규칙이 필요하면: numbering_rules + numbering_rule_parts INSERT +- 카테고리가 필요하면: table_column_category_values INSERT +- 비즈니스 로직(버튼 액션)이 필요하면: dataflow_diagrams INSERT + +--- + +## 2. Step 1: 비즈니스 테이블 생성 (CREATE TABLE) + +### 템플릿 + +> **[최우선] 비즈니스 컬럼에 NOT NULL / UNIQUE / CHECK / FOREIGN KEY 제약조건 절대 금지!** +> 멀티테넌시 환경에서 회사별로 규칙이 다르므로, `table_type_columns`의 `is_nullable`, `is_unique` 메타데이터로 논리적 제어한다. + +```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), + + "{비즈니스_컬럼1}" varchar(500), + "{비즈니스_컬럼2}" varchar(500), + "{비즈니스_컬럼3}" varchar(500) + -- NOT NULL, UNIQUE, CHECK, FOREIGN KEY 금지! +); +``` + +### 마스터-디테일인 경우 (2개 테이블) + +```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), + "{컬럼1}" varchar(500), + "{컬럼2}" varchar(500) + -- NOT NULL, UNIQUE, FOREIGN KEY 금지! +); + +-- 디테일 테이블 +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), + "{마스터_FK}" varchar(500), -- 마스터 테이블 id 참조 (FOREIGN KEY 제약조건은 걸지 않는다!) + "{컬럼1}" varchar(500), + "{컬럼2}" varchar(500) + -- NOT NULL, UNIQUE, FOREIGN KEY 금지! +); +``` + +**금지 사항**: +- INTEGER, NUMERIC, BOOLEAN, TEXT, DATE 등 DB 타입 직접 사용 금지. 반드시 VARCHAR(500). +- 비즈니스 컬럼에 NOT NULL, UNIQUE, CHECK, FOREIGN KEY 등 DB 레벨 제약조건 금지. + +--- + +## 3. Step 2: table_labels INSERT + +```sql +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) +VALUES ('{테이블명}', '{한글_라벨}', '{설명}', now(), now()) +ON CONFLICT (table_name) +DO UPDATE SET table_label = EXCLUDED.table_label, description = EXCLUDED.description, updated_date = now(); +``` + +**예시**: +```sql +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) +VALUES ('order_master', '수주 마스터', '수주 헤더 정보 관리', now(), now()) +ON CONFLICT (table_name) +DO UPDATE SET table_label = EXCLUDED.table_label, description = EXCLUDED.description, updated_date = now(); + +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) +VALUES ('order_detail', '수주 상세', '수주 품목별 상세 정보', now(), now()) +ON CONFLICT (table_name) +DO UPDATE SET table_label = EXCLUDED.table_label, description = EXCLUDED.description, updated_date = now(); +``` + +--- + +## 4. Step 3: table_type_columns INSERT + +> `company_code = '*'` 로 등록한다 (전체 공통 설정). + +### 기본 5개 컬럼 (모든 테이블 공통) + +```sql +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, is_unique, display_order, column_label, description, is_visible, + created_date, updated_date +) +VALUES + ('{테이블명}', 'id', '*', 'text', '{}', 'N', 'Y', -5, 'ID', '기본키 (자동생성)', false, now(), now()), + ('{테이블명}', 'created_date', '*', 'date', '{}', 'Y', 'N', -4, '생성일시', '레코드 생성일시', false, now(), now()), + ('{테이블명}', 'updated_date', '*', 'date', '{}', 'Y', 'N', -3, '수정일시', '레코드 수정일시', false, now(), now()), + ('{테이블명}', 'writer', '*', 'text', '{}', 'Y', 'N', -2, '작성자', '레코드 작성자', false, now(), now()), + ('{테이블명}', 'company_code', '*', 'text', '{}', 'Y', 'N', -1, '회사코드', '회사 구분 코드', false, now(), now()) +ON CONFLICT (table_name, column_name, company_code) +DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, + is_nullable = EXCLUDED.is_nullable, is_unique = EXCLUDED.is_unique, + display_order = EXCLUDED.display_order, column_label = EXCLUDED.column_label, + description = EXCLUDED.description, is_visible = EXCLUDED.is_visible, updated_date = now(); +``` + +### 비즈니스 컬럼 (display_order 0부터) + +```sql +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, is_unique, display_order, column_label, description, is_visible, + created_date, updated_date +) +VALUES + ('{테이블명}', '{컬럼명}', '*', '{input_type}', '{detail_settings_json}', 'Y', 'N', {순서}, '{한글라벨}', '{설명}', true, now(), now()) +ON CONFLICT (table_name, column_name, company_code) +DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, + is_nullable = EXCLUDED.is_nullable, is_unique = EXCLUDED.is_unique, + display_order = EXCLUDED.display_order, column_label = EXCLUDED.column_label, + description = EXCLUDED.description, is_visible = EXCLUDED.is_visible, updated_date = now(); +``` + +### input_type 선택 기준 + +| 데이터 성격 | input_type | detail_settings 예시 | +|------------|-----------|---------------------| +| 일반 텍스트 | `text` | `'{}'` | +| 숫자 (수량, 금액) | `number` | `'{}'` | +| 날짜 | `date` | `'{}'` | +| 여러 줄 텍스트 (비고) | `textarea` | `'{}'` | +| 공통코드 선택 (상태 등) | `code` | `'{"codeCategory":"STATUS_CODE"}'` | +| 다른 테이블 참조 (거래처 등) | `entity` | `'{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}'` | +| 정적 옵션 선택 | `select` | `'{"options":[{"label":"옵션1","value":"v1"},{"label":"옵션2","value":"v2"}]}'` | +| 체크박스 | `checkbox` | `'{}'` | +| 라디오 | `radio` | `'{}'` | +| 카테고리 | `category` | `'{"categoryRef":"CAT_ID"}'` | +| 자동 채번 | `numbering` | `'{"numberingRuleId":"rule_id"}'` | + +--- + +## 5. Step 4: column_labels INSERT + +> 레거시 호환용이지만 **필수 등록**이다. table_type_columns와 동일한 값을 넣되, `column_label`(한글명)을 추가. +> +> **주의**: `column_labels` 테이블의 UNIQUE 제약조건은 `(table_name, column_name, company_code)` 3개 컬럼이다. 반드시 `company_code`를 포함해야 한다. + +```sql +-- 기본 5개 컬럼 +INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, company_code, created_date, updated_date) +VALUES + ('{테이블명}', 'id', 'ID', 'text', '{}', '기본키 (자동생성)', -5, true, '*', now(), now()), + ('{테이블명}', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, '*', now(), now()), + ('{테이블명}', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, '*', now(), now()), + ('{테이블명}', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, '*', now(), now()), + ('{테이블명}', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, '*', now(), now()) +ON CONFLICT (table_name, column_name, company_code) +DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, + detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, + display_order = EXCLUDED.display_order, is_visible = EXCLUDED.is_visible, updated_date = now(); + +-- 비즈니스 컬럼 +INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, company_code, created_date, updated_date) +VALUES + ('{테이블명}', '{컬럼명}', '{한글라벨}', '{input_type}', '{detail_settings}', '{설명}', {순서}, true, '*', now(), now()) +ON CONFLICT (table_name, column_name, company_code) +DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, + detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, + display_order = EXCLUDED.display_order, is_visible = EXCLUDED.is_visible, updated_date = now(); +``` + +--- + +## 6. Step 5: screen_definitions INSERT + +```sql +INSERT INTO screen_definitions ( + screen_name, screen_code, table_name, company_code, description, is_active, + db_source_type, data_source_type, created_date +) +VALUES ( + '{화면명}', -- 예: '수주관리' + '{screen_code}', -- 예: 'COMPANY_A_ORDER_MNG' (회사코드_식별자) + '{메인_테이블명}', -- 예: 'order_master' + '{company_code}', -- 예: 'COMPANY_A' + '{설명}', + 'Y', + 'internal', + 'database', + now() +) +RETURNING screen_id; +``` + +**screen_code 규칙**: `{company_code}_{영문식별자}` (예: `ILSHIN_ORDER_MNG`, `COMPANY_19_ITEM_INFO`) + +**중요**: Step 6, 7에서 `screen_id`가 필요하다. 서브쿼리로 참조하면 하드코딩 실수를 방지할 수 있다: +```sql +(SELECT screen_id FROM screen_definitions WHERE screen_code = '{screen_code}') +``` + +> **screen_code 조건부 UNIQUE 규칙**: +> `screen_code`는 단순 UNIQUE가 아니라 **`WHERE is_active <> 'D'`** 조건부 UNIQUE이다. +> - 삭제된 화면(`is_active = 'D'`)과 동일한 코드로 새 화면을 만들 수 있다. +> - 활성 상태(`'Y'` 또는 `'N'`)에서는 같은 `screen_code`가 중복되면 에러가 발생한다. +> - 화면 삭제 시 `DELETE`가 아닌 `UPDATE SET is_active = 'D'`로 소프트 삭제하므로, 이전 코드의 재사용이 가능하다. + +--- + +## 7. Step 6: screen_layouts_v2 INSERT (핵심) + +### 기본 구조 + +```sql +INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at) +VALUES ( + (SELECT screen_id FROM screen_definitions WHERE screen_code = '{screen_code}'), + '{company_code}', + 1, -- 기본 레이어 + '기본 레이어', + '{layout_data_json}'::jsonb, + now(), + now() +) +ON CONFLICT (screen_id, company_code, layer_id) +DO UPDATE SET layout_data = EXCLUDED.layout_data, updated_at = now(); +``` + +### layout_data JSON 뼈대 + +```json +{ + "version": "2.0", + "components": [ + { + "id": "{고유ID}", + "url": "@/lib/registry/components/{컴포넌트타입}", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1920, "height": 800 }, + "displayOrder": 0, + "overrides": { /* 컴포넌트별 설정 */ } + } + ], + "gridSettings": { "columns": 12, "gap": 16, "padding": 16 }, + "screenResolution": { "width": 1920, "height": 1080 } +} +``` + +### 컴포넌트 url 매핑표 + +| 컴포넌트 | url 값 | +|----------|--------| +| v2-table-list | `@/lib/registry/components/v2-table-list` | +| v2-table-search-widget | `@/lib/registry/components/v2-table-search-widget` | +| v2-split-panel-layout | `@/lib/registry/components/v2-split-panel-layout` | +| v2-table-grouped | `@/lib/registry/components/v2-table-grouped` | +| v2-tabs-widget | `@/lib/registry/components/v2-tabs-widget` | +| v2-button-primary | `@/lib/registry/components/v2-button-primary` | +| v2-input | `@/lib/registry/components/v2-input` | +| v2-select | `@/lib/registry/components/v2-select` | +| v2-date | `@/lib/registry/components/v2-date` | +| v2-card-display | `@/lib/registry/components/v2-card-display` | +| v2-pivot-grid | `@/lib/registry/components/v2-pivot-grid` | +| v2-timeline-scheduler | `@/lib/registry/components/v2-timeline-scheduler` | +| v2-text-display | `@/lib/registry/components/v2-text-display` | +| v2-aggregation-widget | `@/lib/registry/components/v2-aggregation-widget` | +| v2-numbering-rule | `@/lib/registry/components/v2-numbering-rule` | +| v2-file-upload | `@/lib/registry/components/v2-file-upload` | +| v2-section-card | `@/lib/registry/components/v2-section-card` | +| v2-divider-line | `@/lib/registry/components/v2-divider-line` | +| v2-bom-tree | `@/lib/registry/components/v2-bom-tree` | +| v2-approval-step | `@/lib/registry/components/v2-approval-step` | +| v2-status-count | `@/lib/registry/components/v2-status-count` | +| v2-section-paper | `@/lib/registry/components/v2-section-paper` | +| v2-split-line | `@/lib/registry/components/v2-split-line` | +| v2-repeat-container | `@/lib/registry/components/v2-repeat-container` | +| v2-repeater | `@/lib/registry/components/v2-repeater` | +| v2-category-manager | `@/lib/registry/components/v2-category-manager` | +| v2-media | `@/lib/registry/components/v2-media` | +| v2-location-swap-selector | `@/lib/registry/components/v2-location-swap-selector` | +| v2-rack-structure | `@/lib/registry/components/v2-rack-structure` | +| v2-process-work-standard | `@/lib/registry/components/v2-process-work-standard` | +| v2-item-routing | `@/lib/registry/components/v2-item-routing` | +| v2-bom-item-editor | `@/lib/registry/components/v2-bom-item-editor` | + +--- + +## 8. 패턴별 layout_data 완전 예시 + +### 8.1 패턴 A: 기본 마스터 (검색 + 테이블) + +**사용 조건**: 단일 테이블 CRUD, 마스터-디테일 관계 없음 + +```json +{ + "version": "2.0", + "components": [ + { + "id": "search_1", + "url": "@/lib/registry/components/v2-table-search-widget", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1920, "height": 100 }, + "displayOrder": 0, + "overrides": { + "label": "검색", + "autoSelectFirstTable": true, + "showTableSelector": false + } + }, + { + "id": "table_1", + "url": "@/lib/registry/components/v2-table-list", + "position": { "x": 0, "y": 120 }, + "size": { "width": 1920, "height": 700 }, + "displayOrder": 1, + "overrides": { + "label": "{화면제목}", + "tableName": "{테이블명}", + "autoLoad": true, + "displayMode": "table", + "checkbox": { "enabled": true, "multiple": true, "position": "left", "selectAll": true }, + "pagination": { "enabled": true, "pageSize": 20, "showSizeSelector": true, "showPageInfo": true }, + "horizontalScroll": { "enabled": true, "maxVisibleColumns": 8 }, + "toolbar": { "showEditMode": true, "showExcel": true, "showRefresh": true } + } + } + ], + "gridSettings": { "columns": 12, "gap": 16, "padding": 16 }, + "screenResolution": { "width": 1920, "height": 1080 } +} +``` + +### 8.2 패턴 B: 마스터-디테일 (좌우 분할) + +**사용 조건**: 좌측 마스터 테이블 선택 → 우측 디테일 테이블 연동. 두 테이블 간 FK 관계. + +```json +{ + "version": "2.0", + "components": [ + { + "id": "split_1", + "url": "@/lib/registry/components/v2-split-panel-layout", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1920, "height": 850 }, + "displayOrder": 0, + "overrides": { + "label": "{화면제목}", + "splitRatio": 35, + "resizable": true, + "autoLoad": true, + "syncSelection": true, + "leftPanel": { + "title": "{마스터_제목}", + "displayMode": "table", + "tableName": "{마스터_테이블명}", + "showSearch": true, + "showAdd": true, + "showEdit": false, + "showDelete": true, + "columns": [ + { "name": "{컬럼1}", "label": "{라벨1}", "width": 120, "sortable": true }, + { "name": "{컬럼2}", "label": "{라벨2}", "width": 150 }, + { "name": "{컬럼3}", "label": "{라벨3}", "width": 100 } + ], + "addButton": { "enabled": true, "mode": "auto" }, + "deleteButton": { "enabled": true, "confirmMessage": "선택한 항목을 삭제하시겠습니까?" } + }, + "rightPanel": { + "title": "{디테일_제목}", + "displayMode": "table", + "tableName": "{디테일_테이블명}", + "relation": { + "type": "detail", + "leftColumn": "id", + "rightColumn": "{마스터FK_컬럼}", + "foreignKey": "{마스터FK_컬럼}" + }, + "columns": [ + { "name": "{컬럼1}", "label": "{라벨1}", "width": 120 }, + { "name": "{컬럼2}", "label": "{라벨2}", "width": 150 }, + { "name": "{컬럼3}", "label": "{라벨3}", "width": 100, "editable": true } + ], + "addButton": { "enabled": true, "mode": "auto" }, + "editButton": { "enabled": true, "mode": "auto" }, + "deleteButton": { "enabled": true, "confirmMessage": "삭제하시겠습니까?" } + } + } + } + ], + "gridSettings": { "columns": 12, "gap": 16, "padding": 16 }, + "screenResolution": { "width": 1920, "height": 1080 } +} +``` + +### 8.3 패턴 C: 마스터-디테일 + 탭 + +**사용 조건**: 패턴 B에서 우측에 여러 종류의 상세를 탭으로 구분 + +패턴 B의 rightPanel에 **additionalTabs** 추가: + +```json +{ + "rightPanel": { + "title": "{디테일_제목}", + "displayMode": "table", + "tableName": "{기본탭_테이블}", + "relation": { + "type": "detail", + "leftColumn": "id", + "rightColumn": "{FK_컬럼}", + "foreignKey": "{FK_컬럼}" + }, + "additionalTabs": [ + { + "tabId": "tab_basic", + "label": "기본정보", + "tableName": "{기본정보_테이블}", + "displayMode": "table", + "relation": { "type": "detail", "leftColumn": "id", "rightColumn": "{FK}", "foreignKey": "{FK}" }, + "columns": [ /* 컬럼 배열 */ ], + "addButton": { "enabled": true }, + "deleteButton": { "enabled": true } + }, + { + "tabId": "tab_history", + "label": "이력", + "tableName": "{이력_테이블}", + "displayMode": "table", + "relation": { "type": "detail", "leftColumn": "id", "rightColumn": "{FK}", "foreignKey": "{FK}" }, + "columns": [ /* 컬럼 배열 */ ] + }, + { + "tabId": "tab_files", + "label": "첨부파일", + "tableName": "{파일_테이블}", + "displayMode": "table", + "relation": { "type": "detail", "leftColumn": "id", "rightColumn": "{FK}", "foreignKey": "{FK}" }, + "columns": [ /* 컬럼 배열 */ ] + } + ] + } +} +``` + +### 8.4 패턴 D: 그룹화 테이블 + +```json +{ + "version": "2.0", + "components": [ + { + "id": "grouped_1", + "url": "@/lib/registry/components/v2-table-grouped", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1920, "height": 800 }, + "displayOrder": 0, + "overrides": { + "label": "{화면제목}", + "selectedTable": "{테이블명}", + "groupConfig": { + "groupByColumn": "{그룹기준_컬럼}", + "groupLabelFormat": "{value}", + "defaultExpanded": true, + "sortDirection": "asc", + "summary": { "showCount": true, "sumColumns": ["{합계컬럼1}", "{합계컬럼2}"] } + }, + "columns": [ + { "columnName": "{컬럼1}", "displayName": "{라벨1}", "visible": true, "width": 120 }, + { "columnName": "{컬럼2}", "displayName": "{라벨2}", "visible": true, "width": 150 } + ], + "showCheckbox": true, + "showExpandAllButton": true + } + } + ], + "gridSettings": { "columns": 12, "gap": 16, "padding": 16 }, + "screenResolution": { "width": 1920, "height": 1080 } +} +``` + +### 8.5 패턴 E: 타임라인/간트차트 + +```json +{ + "version": "2.0", + "components": [ + { + "id": "timeline_1", + "url": "@/lib/registry/components/v2-timeline-scheduler", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1920, "height": 800 }, + "displayOrder": 0, + "overrides": { + "label": "{화면제목}", + "selectedTable": "{스케줄_테이블}", + "resourceTable": "{리소스_테이블}", + "fieldMapping": { + "id": "id", + "resourceId": "{리소스FK_컬럼}", + "title": "{제목_컬럼}", + "startDate": "{시작일_컬럼}", + "endDate": "{종료일_컬럼}", + "status": "{상태_컬럼}", + "progress": "{진행률_컬럼}" + }, + "resourceFieldMapping": { + "id": "id", + "name": "{리소스명_컬럼}", + "group": "{그룹_컬럼}" + }, + "defaultZoomLevel": "day", + "editable": true, + "allowDrag": true, + "allowResize": true + } + } + ], + "gridSettings": { "columns": 12, "gap": 16, "padding": 16 }, + "screenResolution": { "width": 1920, "height": 1080 } +} +``` + +--- + +## 9. Step 7: menu_info INSERT + +```sql +INSERT INTO menu_info ( + objid, menu_type, parent_obj_id, + menu_name_kor, menu_name_eng, seq, + menu_url, menu_desc, writer, regdate, status, + company_code, screen_code +) +VALUES ( + {고유_objid}, + 0, + {부모_메뉴_objid}, + '{메뉴명_한글}', + '{메뉴명_영문}', + {정렬순서}, + '/screen/{screen_code}', + '{메뉴_설명}', + 'admin', + now(), + 'active', + '{company_code}', + '{screen_code}' +); +``` + +- `objid`: BIGINT 고유값. `extract(epoch from now())::bigint * 1000` 으로 생성 +- `menu_type`: `0` = 말단 메뉴(화면), `1` = 폴더 +- `parent_obj_id`: 상위 폴더 메뉴의 objid + +**objid 생성 규칙 및 주의사항**: + +기본 생성: `extract(epoch from now())::bigint * 1000` + +> **여러 메뉴를 한 트랜잭션에서 동시에 INSERT할 때 PK 중복 위험!** +> `now()`는 같은 트랜잭션 안에서 동일한 값을 반환하므로, 복수 INSERT 시 objid가 충돌한다. +> 반드시 순서값을 더해서 고유성을 보장할 것: +> +> ```sql +> -- 폴더 메뉴 +> extract(epoch from now())::bigint * 1000 + 1 +> -- 화면 메뉴 1 +> extract(epoch from now())::bigint * 1000 + 2 +> -- 화면 메뉴 2 +> extract(epoch from now())::bigint * 1000 + 3 +> ``` + +--- + +## 10. 선택적 단계: 채번 규칙 설정 + +자동으로 코드/번호를 생성해야 하는 컬럼이 있을 때 사용. + +### numbering_rules INSERT + +```sql +INSERT INTO numbering_rules ( + rule_id, rule_name, description, separator, reset_period, + current_sequence, table_name, column_name, company_code, + created_at, updated_at, created_by +) +VALUES ( + '{rule_id}', -- 예: 'ORDER_NO_RULE' + '{규칙명}', -- 예: '수주번호 채번' + '{설명}', + '-', -- 구분자 + 'year', -- 'none', 'year', 'month', 'day' + 1, -- 시작 순번 + '{테이블명}', -- 예: 'order_master' + '{컬럼명}', -- 예: 'order_no' + '{company_code}', + now(), now(), 'admin' +); +``` + +### numbering_rule_parts INSERT (채번 구성 파트) + +```sql +-- 파트 1: 접두사 +INSERT INTO numbering_rule_parts (rule_id, part_order, part_type, generation_method, auto_config, manual_config, company_code, created_at) +VALUES ('{rule_id}', 1, 'prefix', 'auto', '{"prefix": "SO", "separatorAfter": "-"}'::jsonb, '{}'::jsonb, '{company_code}', now()); + +-- 파트 2: 날짜 +INSERT INTO numbering_rule_parts (rule_id, part_order, part_type, generation_method, auto_config, manual_config, company_code, created_at) +VALUES ('{rule_id}', 2, 'date', 'auto', '{"format": "YYYYMM", "separatorAfter": "-"}'::jsonb, '{}'::jsonb, '{company_code}', now()); + +-- 파트 3: 순번 +INSERT INTO numbering_rule_parts (rule_id, part_order, part_type, generation_method, auto_config, manual_config, company_code, created_at) +VALUES ('{rule_id}', 3, 'sequence', 'auto', '{"digits": 4, "startFrom": 1}'::jsonb, '{}'::jsonb, '{company_code}', now()); +``` + +**결과**: `SO-202603-0001`, `SO-202603-0002`, ... + +--- + +## 11. 선택적 단계: 카테고리 값 설정 + +상태, 유형 등을 카테고리로 관리할 때 사용. + +### table_column_category_values INSERT + +```sql +INSERT INTO table_column_category_values ( + table_name, column_name, value_code, value_label, value_order, + parent_value_id, depth, description, color, company_code, created_by +) +VALUES + ('{테이블명}', '{컬럼명}', 'ACTIVE', '활성', 1, NULL, 1, '활성 상태', '#22c55e', '{company_code}', 'admin'), + ('{테이블명}', '{컬럼명}', 'INACTIVE', '비활성', 2, NULL, 1, '비활성 상태', '#ef4444', '{company_code}', 'admin'), + ('{테이블명}', '{컬럼명}', 'PENDING', '대기', 3, NULL, 1, '승인 대기', '#f59e0b', '{company_code}', 'admin'); +``` + +--- + +## 12. 패턴 판단 의사결정 트리 + +사용자가 화면을 요청하면 이 트리로 패턴을 결정한다. + +``` +Q1. 시간축 기반 일정/간트차트가 필요한가? +├─ YES → 패턴 E (타임라인) → v2-timeline-scheduler +└─ NO ↓ + +Q2. 다차원 집계/피벗 분석이 필요한가? +├─ YES → 피벗 → v2-pivot-grid +└─ NO ↓ + +Q3. 데이터를 그룹별로 접기/펼치기가 필요한가? +├─ YES → 패턴 D (그룹화) → v2-table-grouped +└─ NO ↓ + +Q4. 이미지+정보를 카드 형태로 표시하는가? +├─ YES → 카드뷰 → v2-card-display +└─ NO ↓ + +Q5. 마스터 테이블 선택 시 연관 디테일이 필요한가? +├─ YES → Q5-1. 디테일에 여러 탭이 필요한가? +│ ├─ YES → 패턴 C (마스터-디테일+탭) → v2-split-panel-layout + additionalTabs +│ └─ NO → 패턴 B (마스터-디테일) → v2-split-panel-layout +└─ NO → 패턴 A (기본 마스터) → v2-table-search-widget + v2-table-list +``` + +--- + +## 13. 화면 간 연결 관계 정의 + +### 13.1 마스터-디테일 관계 (v2-split-panel-layout) + +좌측 마스터 테이블의 행을 선택하면, 우측 디테일 테이블이 해당 FK로 필터링된다. + +**relation 설정**: + +> **JSON 안에 주석(`//`, `/* */`) 절대 금지!** PostgreSQL `::jsonb` 캐스팅 시 파싱 에러 발생. 설명은 반드시 JSON 바깥에 작성한다. + +- `type`: `"detail"` (FK 관계) +- `leftColumn`: 마스터 테이블의 PK 컬럼 (보통 `"id"`) +- `rightColumn`: 디테일 테이블의 FK 컬럼 +- `foreignKey`: `rightColumn`과 동일한 값 + +```json +{ + "relation": { + "type": "detail", + "leftColumn": "id", + "rightColumn": "master_id", + "foreignKey": "master_id" + } +} +``` + +**복합 키인 경우**: + +```json +{ + "relation": { + "type": "detail", + "keys": [ + { "leftColumn": "order_no", "rightColumn": "order_no" }, + { "leftColumn": "company_code", "rightColumn": "company_code" } + ] + } +} +``` + +### 13.2 엔티티 조인 (테이블 참조 표시) + +디테일 테이블의 FK 컬럼에 다른 테이블의 이름을 표시하고 싶을 때. + +**table_type_columns에서 설정**: + +```sql +INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, ...) +VALUES ('order_detail', 'item_id', '*', 'entity', + '{"referenceTable":"item_info","referenceColumn":"id","displayColumn":"item_name"}', ...); +``` + +**v2-table-list columns에서 설정**: + +```json +{ + "columns": [ + { + "name": "item_id", + "label": "품목", + "isEntityJoin": true, + "joinInfo": { + "sourceTable": "order_detail", + "sourceColumn": "item_id", + "referenceTable": "item_info", + "joinAlias": "item_name" + } + } + ] +} +``` + +### 13.3 모달 화면 연결 + +추가/편집 버튼 클릭 시 별도 모달 화면을 띄우는 경우. + +1. **모달용 screen_definitions INSERT** (별도 화면 생성) +2. split-panel의 addButton/editButton에서 연결: + +```json +{ + "addButton": { + "enabled": true, + "mode": "modal", + "modalScreenId": "{모달_screen_id}" + }, + "editButton": { + "enabled": true, + "mode": "modal", + "modalScreenId": "{모달_screen_id}" + } +} +``` + +--- + +## 14. 비즈니스 로직 설정 (제어관리) + +버튼 클릭 시 INSERT/UPDATE/DELETE, 상태 변경, 이력 기록 등이 필요한 경우. + +### 14.1 v2-button-primary overrides + +```json +{ + "id": "btn_confirm", + "url": "@/lib/registry/components/v2-button-primary", + "position": { "x": 1700, "y": 10 }, + "size": { "width": 100, "height": 40 }, + "overrides": { + "text": "확정", + "variant": "primary", + "actionType": "button", + "action": { "type": "custom" }, + "webTypeConfig": { + "enableDataflowControl": true, + "dataflowConfig": { + "controlMode": "relationship", + "relationshipConfig": { + "relationshipId": "{관계_ID}", + "relationshipName": "{관계명}", + "executionTiming": "after" + } + } + } + } +} +``` + +### 14.2 dataflow_diagrams INSERT + +```sql +INSERT INTO dataflow_diagrams ( + diagram_name, company_code, + relationships, control, plan, node_positions +) +VALUES ( + '{관계도명}', + '{company_code}', + '[{"fromTable":"{소스_테이블}","toTable":"{타겟_테이블}","relationType":"data_save"}]'::jsonb, + '[{ + "conditions": [{"field":"status","operator":"=","value":"대기","dataType":"string"}], + "triggerType": "update" + }]'::jsonb, + '[{ + "actions": [ + { + "actionType": "update", + "targetTable": "{타겟_테이블}", + "conditions": [{"field":"status","operator":"=","value":"대기"}], + "fieldMappings": [{"targetField":"status","defaultValue":"확정"}] + }, + { + "actionType": "insert", + "targetTable": "{이력_테이블}", + "fieldMappings": [ + {"sourceField":"order_no","targetField":"order_no"}, + {"targetField":"action","defaultValue":"확정"} + ] + } + ] + }]'::jsonb, + '[]'::jsonb +) +RETURNING diagram_id; +``` + +**executionTiming 선택**: +- `before`: 메인 액션 전 → 조건 체크 (조건 불충족 시 메인 액션 중단) +- `after`: 메인 액션 후 → 후처리 (이력 기록, 상태 변경 등) +- `replace`: 메인 액션 대체 → 제어만 실행 + +--- + +## 15. 전체 예시: "수주관리 화면 만들어줘" + +### 요구사항 해석 +- 마스터: order_master (수주번호, 거래처, 수주일자, 상태) +- 디테일: order_detail (품목, 수량, 단가, 금액) +- 패턴: B (마스터-디테일) + +### 실행 SQL + +```sql +-- ===== Step 1: 테이블 생성 ===== +CREATE TABLE "order_master" ( + "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), + "order_no" varchar(500), + "customer_id" varchar(500), + "order_date" varchar(500), + "delivery_date" varchar(500), + "status" varchar(500), + "total_amount" varchar(500), + "notes" varchar(500) +); + +CREATE TABLE "order_detail" ( + "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), + "order_master_id" varchar(500), + "item_id" varchar(500), + "quantity" varchar(500), + "unit_price" varchar(500), + "amount" varchar(500), + "notes" varchar(500) +); + +-- ===== Step 2: table_labels ===== +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) VALUES + ('order_master', '수주 마스터', '수주 헤더 정보', now(), now()), + ('order_detail', '수주 상세', '수주 품목별 상세', now(), now()) +ON CONFLICT (table_name) DO UPDATE SET table_label = EXCLUDED.table_label, description = EXCLUDED.description, updated_date = now(); + +-- ===== Step 3: table_type_columns (확장 컬럼 포함) ===== +-- order_master 기본 + 비즈니스 컬럼 +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, is_unique, display_order, column_label, description, is_visible, + created_date, updated_date +) VALUES + ('order_master', 'id', '*', 'text', '{}', 'N', 'Y', -5, 'ID', '기본키', false, now(), now()), + ('order_master', 'created_date', '*', 'date', '{}', 'Y', 'N', -4, '생성일시', '레코드 생성일시', false, now(), now()), + ('order_master', 'updated_date', '*', 'date', '{}', 'Y', 'N', -3, '수정일시', '레코드 수정일시', false, now(), now()), + ('order_master', 'writer', '*', 'text', '{}', 'Y', 'N', -2, '작성자', '레코드 작성자', false, now(), now()), + ('order_master', 'company_code', '*', 'text', '{}', 'Y', 'N', -1, '회사코드', '회사 구분 코드', false, now(), now()), + ('order_master', 'order_no', '*', 'text', '{}', 'N', 'Y', 0, '수주번호', '수주 식별번호', true, now(), now()), + ('order_master', 'customer_id', '*', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', 'N', 'N', 1, '거래처', '거래처 참조', true, now(), now()), + ('order_master', 'order_date', '*', 'date', '{}', 'N', 'N', 2, '수주일자', '', true, now(), now()), + ('order_master', 'delivery_date', '*', 'date', '{}', 'Y', 'N', 3, '납기일', '', true, now(), now()), + ('order_master', 'status', '*', 'code', '{"codeCategory":"ORDER_STATUS"}', 'Y', 'N', 4, '상태', '수주 상태', true, now(), now()), + ('order_master', 'total_amount', '*', 'number', '{}', 'Y', 'N', 5, '총금액', '', true, now(), now()), + ('order_master', 'notes', '*', 'textarea', '{}', 'Y', 'N', 6, '비고', '', true, now(), now()) +ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET + input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, + is_nullable = EXCLUDED.is_nullable, is_unique = EXCLUDED.is_unique, + display_order = EXCLUDED.display_order, column_label = EXCLUDED.column_label, + description = EXCLUDED.description, is_visible = EXCLUDED.is_visible, updated_date = now(); + +-- order_detail 기본 + 비즈니스 컬럼 +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, is_unique, display_order, column_label, description, is_visible, + created_date, updated_date +) VALUES + ('order_detail', 'id', '*', 'text', '{}', 'N', 'Y', -5, 'ID', '기본키', false, now(), now()), + ('order_detail', 'created_date', '*', 'date', '{}', 'Y', 'N', -4, '생성일시', '레코드 생성일시', false, now(), now()), + ('order_detail', 'updated_date', '*', 'date', '{}', 'Y', 'N', -3, '수정일시', '레코드 수정일시', false, now(), now()), + ('order_detail', 'writer', '*', 'text', '{}', 'Y', 'N', -2, '작성자', '레코드 작성자', false, now(), now()), + ('order_detail', 'company_code', '*', 'text', '{}', 'Y', 'N', -1, '회사코드', '회사 구분 코드', false, now(), now()), + ('order_detail', 'order_master_id', '*', 'text', '{}', 'N', 'N', 0, '수주마스터ID', 'FK', false, now(), now()), + ('order_detail', 'item_id', '*', 'entity', '{"referenceTable":"item_info","referenceColumn":"id","displayColumn":"item_name"}', 'N', 'N', 1, '품목', '품목 참조', true, now(), now()), + ('order_detail', 'quantity', '*', 'number', '{}', 'N', 'N', 2, '수량', '', true, now(), now()), + ('order_detail', 'unit_price', '*', 'number', '{}', 'Y', 'N', 3, '단가', '', true, now(), now()), + ('order_detail', 'amount', '*', 'number', '{}', 'Y', 'N', 4, '금액', '', true, now(), now()), + ('order_detail', 'notes', '*', 'textarea', '{}', 'Y', 'N', 5, '비고', '', true, now(), now()) +ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET + input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, + is_nullable = EXCLUDED.is_nullable, is_unique = EXCLUDED.is_unique, + display_order = EXCLUDED.display_order, column_label = EXCLUDED.column_label, + description = EXCLUDED.description, is_visible = EXCLUDED.is_visible, updated_date = now(); + +-- ===== Step 4: column_labels (company_code 필수!) ===== +INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, company_code, created_date, updated_date) VALUES + ('order_master', 'id', 'ID', 'text', '{}', '기본키', -5, true, '*', now(), now()), + ('order_master', 'created_date', '생성일시', 'date', '{}', '', -4, true, '*', now(), now()), + ('order_master', 'updated_date', '수정일시', 'date', '{}', '', -3, true, '*', now(), now()), + ('order_master', 'writer', '작성자', 'text', '{}', '', -2, true, '*', now(), now()), + ('order_master', 'company_code', '회사코드', 'text', '{}', '', -1, true, '*', now(), now()), + ('order_master', 'order_no', '수주번호', 'text', '{}', '수주 식별번호', 0, true, '*', now(), now()), + ('order_master', 'customer_id', '거래처', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', '거래처 참조', 1, true, '*', now(), now()), + ('order_master', 'order_date', '수주일자', 'date', '{}', '', 2, true, '*', now(), now()), + ('order_master', 'delivery_date', '납기일', 'date', '{}', '', 3, true, '*', now(), now()), + ('order_master', 'status', '상태', 'code', '{"codeCategory":"ORDER_STATUS"}', '수주 상태', 4, true, '*', now(), now()), + ('order_master', 'total_amount', '총금액', 'number', '{}', '', 5, true, '*', now(), now()), + ('order_master', 'notes', '비고', 'textarea', '{}', '', 6, true, '*', now(), now()) +ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET + column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, + detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, + display_order = EXCLUDED.display_order, is_visible = EXCLUDED.is_visible, updated_date = now(); + +INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, company_code, created_date, updated_date) VALUES + ('order_detail', 'id', 'ID', 'text', '{}', '기본키', -5, true, '*', now(), now()), + ('order_detail', 'created_date', '생성일시', 'date', '{}', '', -4, true, '*', now(), now()), + ('order_detail', 'updated_date', '수정일시', 'date', '{}', '', -3, true, '*', now(), now()), + ('order_detail', 'writer', '작성자', 'text', '{}', '', -2, true, '*', now(), now()), + ('order_detail', 'company_code', '회사코드', 'text', '{}', '', -1, true, '*', now(), now()), + ('order_detail', 'order_master_id', '수주마스터ID', 'text', '{}', 'FK', 0, true, '*', now(), now()), + ('order_detail', 'item_id', '품목', 'entity', '{"referenceTable":"item_info","referenceColumn":"id","displayColumn":"item_name"}', '품목 참조', 1, true, '*', now(), now()), + ('order_detail', 'quantity', '수량', 'number', '{}', '', 2, true, '*', now(), now()), + ('order_detail', 'unit_price', '단가', 'number', '{}', '', 3, true, '*', now(), now()), + ('order_detail', 'amount', '금액', 'number', '{}', '', 4, true, '*', now(), now()), + ('order_detail', 'notes', '비고', 'textarea', '{}', '', 5, true, '*', now(), now()) +ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET + column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, + detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, + display_order = EXCLUDED.display_order, is_visible = EXCLUDED.is_visible, updated_date = now(); + +-- ===== Step 5: screen_definitions ===== +INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, description, is_active, db_source_type, data_source_type, created_date) +VALUES ('수주관리', 'ILSHIN_ORDER_MNG', 'order_master', 'ILSHIN', '수주 마스터-디테일 관리', 'Y', 'internal', 'database', now()); + +-- ===== Step 6: screen_layouts_v2 (서브쿼리로 screen_id 참조) ===== +INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at) +VALUES ( + (SELECT screen_id FROM screen_definitions WHERE screen_code = 'ILSHIN_ORDER_MNG'), + 'ILSHIN', 1, '기본 레이어', + '{ + "version": "2.0", + "components": [ + { + "id": "split_order", + "url": "@/lib/registry/components/v2-split-panel-layout", + "position": {"x": 0, "y": 0}, + "size": {"width": 1920, "height": 850}, + "displayOrder": 0, + "overrides": { + "label": "수주관리", + "splitRatio": 35, + "resizable": true, + "autoLoad": true, + "syncSelection": true, + "leftPanel": { + "title": "수주 목록", + "displayMode": "table", + "tableName": "order_master", + "showSearch": true, + "showAdd": true, + "showDelete": true, + "columns": [ + {"name": "order_no", "label": "수주번호", "width": 120, "sortable": true}, + {"name": "customer_id", "label": "거래처", "width": 150, "isEntityJoin": true, "joinInfo": {"sourceTable": "order_master", "sourceColumn": "customer_id", "referenceTable": "customer_info", "joinAlias": "customer_name"}}, + {"name": "order_date", "label": "수주일자", "width": 100}, + {"name": "status", "label": "상태", "width": 80}, + {"name": "total_amount", "label": "총금액", "width": 120} + ], + "addButton": {"enabled": true, "mode": "auto"}, + "deleteButton": {"enabled": true, "confirmMessage": "선택한 수주를 삭제하시겠습니까?"} + }, + "rightPanel": { + "title": "수주 상세", + "displayMode": "table", + "tableName": "order_detail", + "relation": { + "type": "detail", + "leftColumn": "id", + "rightColumn": "order_master_id", + "foreignKey": "order_master_id" + }, + "columns": [ + {"name": "item_id", "label": "품목", "width": 150, "isEntityJoin": true, "joinInfo": {"sourceTable": "order_detail", "sourceColumn": "item_id", "referenceTable": "item_info", "joinAlias": "item_name"}}, + {"name": "quantity", "label": "수량", "width": 80, "editable": true}, + {"name": "unit_price", "label": "단가", "width": 100, "editable": true}, + {"name": "amount", "label": "금액", "width": 100}, + {"name": "notes", "label": "비고", "width": 200, "editable": true} + ], + "addButton": {"enabled": true, "mode": "auto"}, + "editButton": {"enabled": true, "mode": "auto"}, + "deleteButton": {"enabled": true, "confirmMessage": "삭제하시겠습니까?"} + } + } + } + ], + "gridSettings": {"columns": 12, "gap": 16, "padding": 16}, + "screenResolution": {"width": 1920, "height": 1080} + }'::jsonb, + now(), now() +) +ON CONFLICT (screen_id, company_code, layer_id) DO UPDATE SET layout_data = EXCLUDED.layout_data, updated_at = now(); + +-- ===== Step 7: menu_info (objid에 순서값 더해서 PK 충돌 방지) ===== +INSERT INTO menu_info ( + objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, + seq, menu_url, menu_desc, writer, regdate, status, company_code, screen_code +) +VALUES ( + extract(epoch from now())::bigint * 1000 + 1, 0, {부모_메뉴_objid}, + '수주관리', 'Order Management', + 1, '/screen/ILSHIN_ORDER_MNG', '수주 마스터-디테일 관리', + 'admin', now(), 'active', 'ILSHIN', 'ILSHIN_ORDER_MNG' +); +``` + +--- + +## 16. 컴포넌트 빠른 참조표 + +| 요구사항 | 컴포넌트 url | 핵심 overrides | +|----------|-------------|---------------| +| 데이터 테이블 | v2-table-list | `tableName`, `columns`, `pagination` | +| 검색 바 | v2-table-search-widget | `autoSelectFirstTable` | +| 좌우 분할 | v2-split-panel-layout | `leftPanel`, `rightPanel`, `relation`, `splitRatio` | +| 그룹화 테이블 | v2-table-grouped | `groupConfig.groupByColumn`, `summary` | +| 간트차트 | v2-timeline-scheduler | `fieldMapping`, `resourceTable` | +| 피벗 분석 | v2-pivot-grid | `fields(area, summaryType)` | +| 카드 뷰 | v2-card-display | `columnMapping`, `cardsPerRow` | +| 액션 버튼 | v2-button-primary | `text`, `actionType`, `webTypeConfig.dataflowConfig` | +| 텍스트 입력 | v2-input | `inputType`, `tableName`, `columnName` | +| 선택 | v2-select | `mode`, `source` | +| 날짜 | v2-date | `dateType` | +| 자동 채번 | v2-numbering-rule | `rule` | +| BOM 트리 | v2-bom-tree | `detailTable`, `foreignKey`, `parentKey` | +| BOM 편집 | v2-bom-item-editor | `detailTable`, `sourceTable`, `itemCodeField` | +| 결재 스테퍼 | v2-approval-step | `targetTable`, `displayMode` | +| 파일 업로드 | v2-file-upload | `multiple`, `accept`, `maxSize` | +| 상태별 건수 | v2-status-count | `tableName`, `statusColumn`, `items` | +| 집계 카드 | v2-aggregation-widget | `tableName`, `items` | +| 반복 데이터 관리 | v2-repeater | `renderMode`, `mainTableName`, `foreignKeyColumn` | +| 반복 렌더링 | v2-repeat-container | `dataSourceType`, `layout`, `gridColumns` | +| 그룹 컨테이너 (테두리) | v2-section-card | `title`, `collapsible`, `borderStyle` | +| 그룹 컨테이너 (배경색) | v2-section-paper | `backgroundColor`, `shadow`, `padding` | +| 캔버스 분할선 | v2-split-line | `resizable`, `lineColor`, `lineWidth` | +| 카테고리 관리 | v2-category-manager | `tableName`, `columnName`, `menuObjid` | +| 미디어 | v2-media | `mediaType`, `multiple`, `maxSize` | +| 위치 교환 | v2-location-swap-selector | `dataSource`, `departureField`, `destinationField` | +| 창고 랙 | v2-rack-structure | `codePattern`, `namePattern`, `maxRows` | +| 공정 작업기준 | v2-process-work-standard | `dataSource.itemTable`, `dataSource.routingDetailTable` | +| 품목 라우팅 | v2-item-routing | `dataSource.itemTable`, `dataSource.routingDetailTable` | diff --git a/docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md b/docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md new file mode 100644 index 00000000..964c389f --- /dev/null +++ b/docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md @@ -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 + + + + +``` + +--- + +## 변경 후 동작 + +### 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 + + + + + +{/* 연속 등록 모드 체크박스 - ScreenModal.tsx 패턴 */} +
+
+ setContinuousAdd(checked as boolean)} + /> + +
+
+``` + +--- + +## 예상 문제 및 대응 + +`CategoryValueAddDialog.tsx`와 동일한 패턴이므로 별도 예상 문제 없음. + +--- + +## 설계 원칙 + +- `CategoryValueAddDialog.tsx`(같은 폴더, 같은 목적)의 패턴을 그대로 따름 +- 기존 수정/삭제 모달 동작은 변경하지 않음 +- 하위 추가(중분류/소분류) 모달도 동일한 `handleAdd`를 사용하므로 자동 적용 +- `Checkbox` import는 이미 존재 (24행)하므로 추가 import 불필요 +- `Label` import는 이미 존재 (53행)하므로 추가 import 불필요 +- 체크박스 위치/라벨/className 모두 `ScreenModal.tsx` (1287~1303행)과 동일 diff --git a/docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md b/docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md new file mode 100644 index 00000000..1b5cb92e --- /dev/null +++ b/docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md @@ -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 불필요 diff --git a/docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md b/docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md new file mode 100644 index 00000000..f794e0ff --- /dev/null +++ b/docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md @@ -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단계 검증은 수동 테스트 필요 | diff --git a/docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md b/docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md new file mode 100644 index 00000000..7b524b82 --- /dev/null +++ b/docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md @@ -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` 두 곳의 동일 패턴을 일관되게 수정 diff --git a/docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md b/docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md new file mode 100644 index 00000000..0cb61da0 --- /dev/null +++ b/docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md @@ -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` — 화면에만 보이는 값, 들여쓰기 포함 +- 데이터 무결성에 영향 없음 diff --git a/docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md b/docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md new file mode 100644 index 00000000..8a1cc237 --- /dev/null +++ b/docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md @@ -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단계 문서 정리 완료 | diff --git a/docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md b/docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md new file mode 100644 index 00000000..d5a44b05 --- /dev/null +++ b/docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md @@ -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> = { + 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 { + 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 = { + 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 설정 섹션 아래에 추가 */} +
+
포맷 설정
+

+ 위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고, + 구분자/라벨을 편집할 수 있습니다 +

+ + handleFormatChange("codeSegments", segs)} + sampleValues={sampleValues} + /> + + handleFormatChange("nameSegments", segs)} + sampleValues={sampleValues} + /> +
+``` + +### 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 스키마 변경 없음 diff --git a/docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md b/docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md new file mode 100644 index 00000000..73c79cef --- /dev/null +++ b/docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md @@ -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, ... (단 번호 - 숫자) +``` diff --git a/docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md b/docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md new file mode 100644 index 00000000..b904d815 --- /dev/null +++ b/docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md @@ -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단계 검증 완료, 전체 작업 완료 | diff --git a/docs/ycshin-node/PGN[계획]-페이징-직접입력.md b/docs/ycshin-node/PGN[계획]-페이징-직접입력.md new file mode 100644 index 00000000..635041b5 --- /dev/null +++ b/docs/ycshin-node/PGN[계획]-페이징-직접입력.md @@ -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 중앙 `` → `` + `/` + `` 교체 | +| | | (3) `handlePageSizeChange`에 `onConfigChange` 호출 추가 | +| | | (4) `fetchTableDataInternal`에서 `currentPage`를 단일 소스로 사용 | +| | | (5) `useMemo` 의존성에 `pageInputValue` 추가 | +| 삭제 | `PageGroupNav.tsx` | 이전 설계 산출물 삭제 (이미 삭제됨) | + +- 신규 파일 생성 없음 +- 백엔드 변경 없음, DB 변경 없음 +- v2-table-list를 사용하는 **모든 동적 화면**에 자동 적용 + +--- + +## 설계 원칙 + +- **최소 변경**: `` 1개를 `` + 유효성 검증으로 교체. 나머지 전부 유지 +- **기존 버튼 동작 무변경**: `<< < > >>` 4개 버튼의 onClick/disabled 로직은 그대로 +- **`handlePageChange` 재사용**: 기존 함수를 그대로 호출 +- **입력 중 페이지 이동 안 함**: onChange는 표시만 변경, Enter/blur로 실제 적용 +- **유효 범위 자동 보정**: 1 미만 → 1, totalPages 초과 → totalPages, 비숫자 → 현재 값 유지 +- **포커스 시 전체 선택**: 클릭하면 바로 타이핑 가능 +- **`currentPage`가 단일 소스**: fetch 시 `tableConfig.pagination?.currentPage` 대신 로컬 `currentPage`만 사용 (비동기 전파 문제 방지) +- **페이지크기 변경 시 1페이지로 리셋**: `handlePageSizeChange`가 `onConfigChange`를 호출하여 부모/백엔드 동기화 diff --git a/docs/ycshin-node/PGN[맥락]-페이징-직접입력.md b/docs/ycshin-node/PGN[맥락]-페이징-직접입력.md new file mode 100644 index 00000000..c036a089 --- /dev/null +++ b/docs/ycshin-node/PGN[맥락]-페이징-직접입력.md @@ -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 내부에 인라인으로 구현 +- **근거**: 변경이 `` → `` + 핸들러 약 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 호출 +``` diff --git a/docs/ycshin-node/PGN[체크]-페이징-직접입력.md b/docs/ycshin-node/PGN[체크]-페이징-직접입력.md new file mode 100644 index 00000000..50f8fe8d --- /dev/null +++ b/docs/ycshin-node/PGN[체크]-페이징-직접입력.md @@ -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`) +- [x] `currentPage` 변경 시 `pageInputValue` 동기화 (`useEffect`) +- [x] `commitPageInput` 핸들러 구현 (parseInt + clamp + handlePageChange) +- [x] paginationJSX 중앙의 `` → `` + `/` + `` 교체 +- [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 | 문서 최신화: 버그 수정 내역 반영, 코드 설계 섹션 제거 (구현 완료) | diff --git a/docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md b/docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md new file mode 100644 index 00000000..74b9b6a8 --- /dev/null +++ b/docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md @@ -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[기존 데이터 조회
warehouse_code + floor + zone] + D --> E[미리보기 생성] + E --> F{저장 버튼} + F --> G[렉 구조 화면 감지
floor && zone 필수] + G --> H[중복 체크
warehouse_code + floor + zone] + H --> I[일괄 INSERT
floor = 선택값] +``` + +### 데이터 흐름 (변경 후) + +```mermaid +flowchart TD + A[사용자: 창고/구역 입력
층은 선택사항] --> B{필수 필드 검증} + B -->|창고 or 구역 없음| C[경고: 해당 필드를 입력하세요] + B -->|창고+구역 있음| D{floor 값 존재?} + D -->|있음| E1[기존 데이터 조회
warehouse_code + floor + zone] + D -->|없음| E2[기존 데이터 조회
warehouse_code + zone] + E1 --> F[미리보기 생성] + E2 --> F + F --> G{저장 버튼} + G --> H[렉 구조 화면 감지
zone만 필수] + H --> I{floor 값 존재?} + I -->|있음| J1[중복 체크
warehouse_code + floor + zone] + I -->|없음| J2[중복 체크
warehouse_code + zone] + J1 --> K[일괄 INSERT
floor = 선택값] + J2 --> K2[일괄 INSERT
floor = NULL] +``` + +### 컴포넌트 관계 + +```mermaid +graph LR + subgraph 프론트엔드 + A[폼 필드
창고/층/구역] -->|formData| B[RackStructureComponent
필수 검증 + 미리보기] + B -->|locations 배열| C[buttonActions.ts
화면 감지 + 중복 체크 + 저장] + end + subgraph 백엔드 + C -->|POST /dynamic-form/save| D[DynamicFormApi
데이터 저장] + D --> E[(warehouse_location
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 = { + 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 = { + 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로 저장하여 "미입력"을 정확하게 표현 (프로젝트 표준 패턴) +- 특수 문자열("상관없음" 등) 사용하지 않음 (프로젝트 관행에 맞지 않으므로) diff --git a/docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md b/docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md new file mode 100644 index 00000000..08be3da0 --- /dev/null +++ b/docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md @@ -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) +``` + +이번 변경은 위 패턴들과 일관성을 유지합니다. diff --git a/docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md b/docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md new file mode 100644 index 00000000..a80bdacc --- /dev/null +++ b/docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md @@ -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 | 사용자 검증 완료, 전체 작업 완료 | diff --git a/docs/ycshin-node/탭_시스템_설계.md b/docs/ycshin-node/탭_시스템_설계.md index 50ca2468..99ce4a8d 100644 --- a/docs/ycshin-node/탭_시스템_설계.md +++ b/docs/ycshin-node/탭_시스템_설계.md @@ -123,15 +123,49 @@ - [ ] 비활성 탭: 캐시에서 복원 - [ ] 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제 -### 6-3. 캐시 키 관리 (clearTabStateCache) +### 6-3. 캐시 키 관리 (clearTabCache) 탭 닫기/새로고침 시 관련 sessionStorage 키 일괄 제거: -- `tab-cache-{screenId}-{menuObjid}` -- `page-scroll-{screenId}-{menuObjid}` -- `tsp-{screenId}-*`, `table-state-{screenId}-*` -- `split-sel-{screenId}-*`, `catval-sel-{screenId}-*` -- `bom-tree-{screenId}-*` -- URL 탭: `tsp-{urlHash}-*`, `admin-scroll-{url}` +- `tab-cache-{tabId}` (폼/스크롤 캐시) +- `tableState_{tabId}_*` (컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터) +- `pageSize_{tabId}_*` (표시갯수) +- `filterSettings_{tabId}_*` (검색 필터 설정) +- `groupSettings_{tabId}_*` (그룹 설정) + +### 6-4. F5 새로고침 시 캐시 정책 (구현 완료) + +| 탭 상태 | F5 시 동작 | +|---------|-----------| +| **활성 탭** | `clearTabCache(activeTabId)` → 캐시 전체 삭제 → fresh API 호출 | +| **비활성 탭** | 캐시 유지 → 탭 전환 시 복원 | + +**구현 방식**: `TabContent.tsx`에 모듈 레벨 플래그(`hasHandledPageLoad`)를 사용. +전체 페이지 로드 시 모듈이 재실행되어 플래그가 `false`로 리셋. +SPA 내비게이션에서는 모듈이 유지되므로 `true`로 남아 중복 실행 방지. + +### 6-5. 탭 바 새로고침 버튼 (구현 완료) + +`tabStore.refreshTab(tabId)` 호출 시: +1. `clearTabCache(tabId)` → 해당 탭의 모든 sessionStorage 캐시 삭제 +2. `refreshKey` 증가 → 컴포넌트 리마운트 → 기본값으로 초기화 + +### 6-6. 저장소 분류 기준 (구현 완료) + +| 데이터 성격 | 저장소 | 키 구조 | 비고 | +|------------|--------|---------|------| +| 탭별 캐시 | sessionStorage | `{prefix}_{tabId}_{tableName}` | 탭 닫으면 소멸 | +| 사용자 설정 | localStorage | `{prefix}_{tableName}_{userId}` | 세션 간 보존 | + +**탭별 캐시 (sessionStorage)**: +- tableState: 컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터 +- pageSize: 표시갯수 +- filterSettings: 검색 필터 설정 +- groupSettings: 그룹 설정 + +**사용자 설정 (localStorage)**: +- table_column_visibility: 컬럼 표시/숨김 +- table_sort_state: 정렬 상태 +- table_column_order: 컬럼 순서 --- diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 62e48e08..73a8de80 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -6,8 +6,17 @@ import { LoginForm } from "@/components/auth/LoginForm"; import { LoginFooter } from "@/components/auth/LoginFooter"; export default function LoginPage() { - const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } = - useLogin(); + const { + formData, + isLoading, + error, + showPassword, + isPopMode, + handleInputChange, + handleLogin, + togglePasswordVisibility, + togglePopMode, + } = useLogin(); return (
@@ -19,9 +28,11 @@ export default function LoginPage() { isLoading={isLoading} error={error} showPassword={showPassword} + isPopMode={isPopMode} onInputChange={handleInputChange} onSubmit={handleLogin} onTogglePassword={togglePasswordVisibility} + onTogglePop={togglePopMode} /> diff --git a/frontend/app/(main)/admin/audit-log/page.tsx b/frontend/app/(main)/admin/audit-log/page.tsx index 74cb550b..747d4640 100644 --- a/frontend/app/(main)/admin/audit-log/page.tsx +++ b/frontend/app/(main)/admin/audit-log/page.tsx @@ -74,16 +74,15 @@ const RESOURCE_TYPE_CONFIG: Record< SCREEN_LAYOUT: { label: "레이아웃", icon: Monitor, color: "bg-purple-100 text-purple-700" }, FLOW: { label: "플로우", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" }, FLOW_STEP: { label: "플로우 스텝", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" }, + NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" }, USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" }, ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" }, - PERMISSION: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" }, COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" }, CODE_CATEGORY: { 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" }, TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" }, 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 = { @@ -816,7 +815,7 @@ export default function AuditLogPage() { {entry.company_code && entry.company_code !== "*" && ( - [{entry.company_code}] + [{entry.company_name || entry.company_code}] )}
@@ -861,9 +860,11 @@ export default function AuditLogPage() {
-

{selectedEntry.company_code}

+

+ {selectedEntry.company_name || selectedEntry.company_code} +

)} + {/* 일반 모드 네비게이션 바 */} + {!isPreviewMode && ( +
+ + {screen.screenName} + +
+ )} + {/* POP 화면 컨텐츠 */}
- {/* 현재 모드 표시 (일반 모드) */} - {!isPreviewMode && ( -
- {currentModeKey.replace("_", " ")} -
- )}
= ({ }); // 화면 할당 관련 상태 - const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당) + const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard" | "pop">("screen"); const [selectedScreen, setSelectedScreen] = useState(null); const [screens, setScreens] = useState([]); const [screenSearchText, setScreenSearchText] = useState(""); const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false); + // POP 화면 할당 관련 상태 + const [selectedPopScreen, setSelectedPopScreen] = useState(null); + const [popScreenSearchText, setPopScreenSearchText] = useState(""); + const [isPopScreenDropdownOpen, setIsPopScreenDropdownOpen] = useState(false); + const [isPopLanding, setIsPopLanding] = useState(false); + const [hasOtherPopLanding, setHasOtherPopLanding] = useState(false); + // 대시보드 할당 관련 상태 const [selectedDashboard, setSelectedDashboard] = useState(null); const [dashboards, setDashboards] = useState([]); @@ -196,8 +203,27 @@ export const MenuFormModal: React.FC = ({ 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 타입 변경 시 처리 - const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => { + const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard" | "pop") => { // console.log("🔄 URL 타입 변경:", { // from: urlType, // to: type, @@ -208,36 +234,53 @@ export const MenuFormModal: React.FC = ({ setUrlType(type); if (type === "direct") { - // 직접 입력 모드로 변경 시 선택된 화면 초기화 setSelectedScreen(null); - // URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록) + setSelectedPopScreen(null); setFormData((prev) => ({ ...prev, menuUrl: "", - screenCode: undefined, // 화면 코드도 함께 초기화 + screenCode: undefined, })); - } else { - // 화면 할당 모드로 변경 시 - // 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지 + } else if (type === "pop") { + setSelectedScreen(null); + if (selectedPopScreen) { + const actualScreenId = selectedPopScreen.screenId || selectedPopScreen.id; + setFormData((prev) => ({ + ...prev, + menuUrl: `/pop/screens/${actualScreenId}`, + })); + } else { + setFormData((prev) => ({ + ...prev, + menuUrl: "", + })); + } + } else if (type === "screen") { + setSelectedPopScreen(null); if (selectedScreen) { - console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName); - // 현재 선택된 화면으로 URL 재생성 const actualScreenId = selectedScreen.screenId || selectedScreen.id; let screenUrl = `/screens/${actualScreenId}`; - - // 관리자 메뉴인 경우 mode=admin 파라미터 추가 const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0"; if (isAdminMenu) { screenUrl += "?mode=admin"; } - setFormData((prev) => ({ ...prev, menuUrl: screenUrl, - screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지 + screenCode: selectedScreen.screenCode, })); } else { - // 선택된 화면이 없으면 URL과 screenCode 초기화 + setFormData((prev) => ({ + ...prev, + menuUrl: "", + screenCode: undefined, + })); + } + } else { + // dashboard + setSelectedScreen(null); + setSelectedPopScreen(null); + if (!selectedDashboard) { setFormData((prev) => ({ ...prev, menuUrl: "", @@ -297,8 +340,8 @@ export const MenuFormModal: React.FC = ({ const menuUrl = menu.menu_url || menu.MENU_URL || ""; - // URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정) - const isScreenUrl = menuUrl.startsWith("/screens/"); + const isPopScreenUrl = menuUrl.startsWith("/pop/screens/"); + const isScreenUrl = !isPopScreenUrl && menuUrl.startsWith("/screens/"); setFormData({ objid: menu.objid || menu.OBJID, @@ -360,10 +403,31 @@ export const MenuFormModal: React.FC = ({ }, 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/")) { setUrlType("dashboard"); setSelectedScreen(null); - // 대시보드 ID 추출 및 선택은 useEffect에서 처리됨 } else { setUrlType("direct"); setSelectedScreen(null); @@ -408,6 +472,7 @@ export const MenuFormModal: React.FC = ({ } else { console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType); setIsEdit(false); + setIsPopLanding(false); // 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1) let defaultMenuType = "1"; // 기본값은 사용자 @@ -470,6 +535,31 @@ export const MenuFormModal: React.FC = ({ } }, [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(() => { if (isOpen) { @@ -517,6 +607,22 @@ export const MenuFormModal: React.FC = ({ } }, [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(() => { const handleClickOutside = (event: MouseEvent) => { @@ -533,16 +639,20 @@ export const MenuFormModal: React.FC = ({ setIsDashboardDropdownOpen(false); setDashboardSearchText(""); } + if (!target.closest(".pop-screen-dropdown")) { + setIsPopScreenDropdownOpen(false); + setPopScreenSearchText(""); + } }; - if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) { + if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen || isPopScreenDropdownOpen) { document.addEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; - }, [isLangKeyDropdownOpen, isScreenDropdownOpen]); + }, [isLangKeyDropdownOpen, isScreenDropdownOpen, isDashboardDropdownOpen, isPopScreenDropdownOpen]); const loadCompanies = async () => { try { @@ -590,10 +700,17 @@ export const MenuFormModal: React.FC = ({ try { 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 = { ...formData, - // 상태를 소문자로 변환 (백엔드에서 소문자 기대) + menuDesc: finalMenuDesc, status: formData.status.toLowerCase(), }; @@ -853,7 +970,7 @@ export const MenuFormModal: React.FC = ({ {/* URL 타입 선택 */} - +
+
+ + +
)} + {/* POP 화면 할당 */} + {urlType === "pop" && ( +
+
+ + + {isPopScreenDropdownOpen && ( +
+
+
+ + setPopScreenSearchText(e.target.value)} + className="pl-8" + /> +
+
+ +
+ {screens + .filter( + (screen) => + screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) || + screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()), + ) + .map((screen, index) => ( +
handlePopScreenSelect(screen)} + className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100" + > +
+
+
{screen.screenName}
+
{screen.screenCode}
+
+
ID: {screen.screenId || screen.id || "N/A"}
+
+
+ ))} + {screens.filter( + (screen) => + screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) || + screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()), + ).length === 0 &&
검색 결과가 없습니다.
} +
+
+ )} +
+ + {selectedPopScreen && ( +
+
{selectedPopScreen.screenName}
+
코드: {selectedPopScreen.screenCode}
+
생성된 URL: {formData.menuUrl}
+
+ )} + + {/* POP 기본 화면 설정 */} +
+ setIsPopLanding(e.target.checked)} + className="h-4 w-4 rounded border-gray-300 accent-primary disabled:cursor-not-allowed disabled:opacity-50" + /> + + {!isPopLanding && hasOtherPopLanding && ( + + (이미 다른 메뉴가 기본 화면으로 설정되어 있습니다) + + )} +
+ {isPopLanding && ( +

+ 프로필에서 POP 모드 전환 시 이 화면으로 바로 이동합니다. +

+ )} +
+ )} + {/* URL 직접 입력 */} {urlType === "direct" && ( ) => void; onSubmit: (e: React.FormEvent) => void; onTogglePassword: () => void; + onTogglePop: () => void; } /** @@ -24,9 +27,11 @@ export function LoginForm({ isLoading, error, showPassword, + isPopMode, onInputChange, onSubmit, onTogglePassword, + onTogglePop, }: LoginFormProps) { return ( @@ -82,6 +87,19 @@ export function LoginForm({
+ {/* POP 모드 토글 */} +
+
+ + POP 모드 +
+ +
+ {/* 로그인 버튼 */} )} + {scannedCode && ( + + )} + {scannedCode && !autoSubmit && ( )} diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index dca348c2..9fff1d01 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -98,10 +98,43 @@ export const ScreenModal: React.FC = ({ className }) => { const savedMode = localStorage.getItem("screenModal_continuousMode"); if (savedMode === "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 = {}; + 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[]) => { if (components.length === 0) { diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 20175b5e..a9e01016 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -1,8 +1,10 @@ "use client"; -import React, { useMemo } from "react"; +import React, { useMemo, useState, useEffect } from "react"; import dynamic from "next/dynamic"; import { Loader2 } from "lucide-react"; +import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page"; +import { apiClient } from "@/lib/api/client"; const LoadingFallback = () => (
@@ -10,11 +12,52 @@ const LoadingFallback = () => (
); -/** - * 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리. - * 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다. - * 매핑되지 않은 URL은 catch-all fallback으로 처리된다. - */ +function ScreenCodeResolver({ screenCode }: { screenCode: string }) { + const [screenId, setScreenId] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const numericId = parseInt(screenCode); + if (!isNaN(numericId)) { + setScreenId(numericId); + setLoading(false); + return; + } + const resolve = async () => { + try { + const res = await apiClient.get("/screen-management/screens", { + params: { searchTerm: screenCode, size: 50 }, + }); + const items = res.data?.data?.data || res.data?.data || []; + const arr = Array.isArray(items) ? items : []; + const exact = arr.find((s: any) => s.screenCode === screenCode); + const target = exact || arr[0]; + if (target) setScreenId(target.screenId || target.screen_id); + } catch { + console.error("스크린 코드 변환 실패:", screenCode); + } finally { + setLoading(false); + } + }; + resolve(); + }, [screenCode]); + + if (loading) return ; + if (!screenId) { + return ( +
+

화면을 찾을 수 없습니다 (코드: {screenCode})

+
+ ); + } + return ; +} + +const DashboardViewPage = dynamic( + () => import("@/app/(main)/dashboard/[dashboardId]/page"), + { ssr: false, loading: LoadingFallback }, +); + const ADMIN_PAGE_REGISTRY: Record> = { // 관리자 메인 "/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }), @@ -39,7 +82,9 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }), "/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }), "/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/cascading-managementList": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), "/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/dataflow/node-editorList": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }), // 자동화 관리 "/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }), @@ -62,29 +107,163 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }), "/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }), + // 결재 관리 + "/admin/approvalTemplate": dynamic(() => import("@/app/(main)/admin/approvalTemplate/page"), { ssr: false, loading: LoadingFallback }), + "/admin/approvalBox": dynamic(() => import("@/app/(main)/admin/approvalBox/page"), { ssr: false, loading: LoadingFallback }), + "/admin/approvalMng": dynamic(() => import("@/app/(main)/admin/approvalMng/page"), { ssr: false, loading: LoadingFallback }), + + // 시스템 + "/admin/audit-log": dynamic(() => import("@/app/(main)/admin/audit-log/page"), { ssr: false, loading: LoadingFallback }), + "/admin/system-notices": dynamic(() => import("@/app/(main)/admin/system-notices/page"), { ssr: false, loading: LoadingFallback }), + "/admin/aiAssistant": dynamic(() => import("@/app/(main)/admin/aiAssistant/page"), { ssr: false, loading: LoadingFallback }), + // 기타 "/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), - "/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }), + "/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), "/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }), "/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }), "/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }), "/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }), "/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }), - "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }), + "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), }; -// 매핑되지 않은 URL용 Fallback +const DYNAMIC_ADMIN_IMPORTS: Record Promise> = { + "/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; + extractParams: (match: RegExpMatchArray) => Record; +}> = [ + { + 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 }) { + const [Component, setComponent] = useState | 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 ; + if (!Component) return ; + if (params) return ; + return ; +} + function AdminPageFallback({ url }: { url: string }) { return (

페이지 로딩 불가

-

- 경로: {url} -

-

- AdminPageRenderer 레지스트리에 이 URL을 추가해주세요. -

+

경로: {url}

+

해당 페이지가 존재하지 않습니다.

); @@ -95,15 +274,53 @@ interface AdminPageRendererProps { } export function AdminPageRenderer({ url }: AdminPageRendererProps) { - const PageComponent = useMemo(() => { - // URL에서 쿼리스트링/해시 제거 후 매칭 - const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); - return ADMIN_PAGE_REGISTRY[cleanUrl] || null; - }, [url]); + const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); - if (!PageComponent) { - return ; + console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl }); + + // 화면 할당: /screens/[id] + const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/); + if (screensIdMatch) { + console.log("[AdminPageRenderer] → /screens/[id] 매칭:", screensIdMatch[1]); + return ; } - return ; + // 화면 할당: /screen/[code] (구 형식) + const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/); + if (screenCodeMatch) { + console.log("[AdminPageRenderer] → /screen/[code] 매칭:", screenCodeMatch[1]); + return ; + } + + // 대시보드 할당: /dashboard/[id] + const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/); + if (dashboardMatch) { + console.log("[AdminPageRenderer] → /dashboard/[id] 매칭:", dashboardMatch[1]); + return ; + } + + // URL 직접 입력: 레지스트리 매칭 + const PageComponent = useMemo(() => { + return ADMIN_PAGE_REGISTRY[cleanUrl] || null; + }, [cleanUrl]); + + if (PageComponent) { + console.log("[AdminPageRenderer] → 레지스트리 매칭:", cleanUrl); + return ; + } + + // 레지스트리에 없으면 동적 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가 자동 import 시도 + console.log("[AdminPageRenderer] → 자동 import 시도:", cleanUrl); + return ; } diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index ad9a6aaf..c80cb581 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -19,11 +19,12 @@ import { User, Building2, FileCheck, + Monitor, } from "lucide-react"; import { useMenu } from "@/contexts/MenuContext"; import { useAuth } from "@/hooks/useAuth"; 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 { apiClient } from "@/lib/api/client"; import { toast } from "sonner"; @@ -202,12 +203,26 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle); + const menuUrl = menu.menu_url || menu.MENU_URL || "#"; + const screenCode = menu.screen_code || menu.SCREEN_CODE || null; + const menuType = String(menu.menu_type ?? menu.MENU_TYPE ?? ""); + + let screenId: number | null = null; + const screensMatch = menuUrl.match(/^\/screens\/(\d+)/); + if (screensMatch) { + screenId = parseInt(screensMatch[1]); + } + return { id: menuId, + objid: menuId, name: displayName, tabTitle, icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON), - url: menu.menu_url || menu.MENU_URL || "#", + url: menuUrl, + screenCode, + screenId, + menuType, children: children.length > 0 ? children : undefined, hasChildren: children.length > 0, }; @@ -341,42 +356,76 @@ function AppLayoutInner({ children }: AppLayoutProps) { const handleMenuClick = async (menu: any) => { if (menu.hasChildren) { toggleMenu(menu.id); - } else { - const menuName = menu.tabTitle || menu.label || menu.name || "메뉴"; - if (typeof window !== "undefined") { - localStorage.setItem("currentMenuName", menuName); + return; + } + + const menuName = menu.tabTitle || menu.label || menu.name || "메뉴"; + if (typeof window !== "undefined") { + localStorage.setItem("currentMenuName", menuName); + } + + const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0"); + const isAdminMenu = menu.menuType === "0"; + + console.log("[handleMenuClick] 메뉴 클릭:", { + menuName, + menuObjid, + menuType: menu.menuType, + isAdminMenu, + screenId: menu.screenId, + screenCode: menu.screenCode, + url: menu.url, + fullMenu: menu, + }); + + // 관리자 메뉴 (menu_type = 0): URL 직접 입력 → admin 탭 + if (isAdminMenu) { + if (menu.url && menu.url !== "#") { + console.log("[handleMenuClick] → admin 탭:", menu.url); + openTab({ type: "admin", title: menuName, adminUrl: menu.url }); + if (isMobile) setSidebarOpen(false); + } else { + toast.warning("이 메뉴에는 연결된 페이지가 없습니다."); } + return; + } + // 사용자 메뉴 (menu_type = 1, 2): 화면/대시보드 할당 + // 1) screenId가 메뉴 URL에서 추출된 경우 바로 screen 탭 + if (menu.screenId) { + console.log("[handleMenuClick] → screen 탭 (URL에서 screenId 추출):", menu.screenId); + openTab({ type: "screen", title: menuName, screenId: menu.screenId, menuObjid }); + if (isMobile) setSidebarOpen(false); + return; + } + + // 2) screen_menu_assignments 테이블 조회 + if (menuObjid) { try { - const menuObjid = menu.objid || menu.id; + console.log("[handleMenuClick] → screen_menu_assignments 조회 시도, menuObjid:", menuObjid); const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid); - + console.log("[handleMenuClick] → 조회 결과:", assignedScreens); if (assignedScreens.length > 0) { - const firstScreen = assignedScreens[0]; - openTab({ - type: "screen", - title: menuName, - screenId: firstScreen.screenId, - menuObjid: parseInt(menuObjid), - }); + console.log("[handleMenuClick] → screen 탭 (assignments):", assignedScreens[0].screenId); + openTab({ type: "screen", title: menuName, screenId: assignedScreens[0].screenId, menuObjid }); if (isMobile) setSidebarOpen(false); return; } - } catch { - console.warn("할당된 화면 조회 실패"); - } - - if (menu.url && menu.url !== "#") { - openTab({ - type: "admin", - title: menuName, - adminUrl: menu.url, - }); - if (isMobile) setSidebarOpen(false); - } else { - toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다."); + } catch (err) { + console.error("[handleMenuClick] 할당된 화면 조회 실패:", err); } } + + // 3) 대시보드 할당 (/dashboard/xxx) → admin 탭으로 렌더링 (AdminPageRenderer가 처리) + if (menu.url && menu.url.startsWith("/dashboard/")) { + console.log("[handleMenuClick] → 대시보드 탭:", menu.url); + openTab({ type: "admin", title: menuName, adminUrl: menu.url }); + if (isMobile) setSidebarOpen(false); + return; + } + + console.warn("[handleMenuClick] 어떤 조건에도 매칭 안 됨:", { menuName, menuType: menu.menuType, url: menu.url, screenId: menu.screenId }); + toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요."); }; const handleModeSwitch = () => { @@ -405,6 +454,31 @@ function AppLayoutInner({ children }: AppLayoutProps) { 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 isExpanded = expandedMenus.has(menu.id); const isLeaf = !menu.hasChildren; @@ -528,6 +602,10 @@ function AppLayoutInner({ children }: AppLayoutProps) { 결재함 + + + POP 모드 +
@@ -700,6 +778,10 @@ function AppLayoutInner({ children }: AppLayoutProps) { 결재함 + + + POP 모드 + diff --git a/frontend/components/layout/MainHeader.tsx b/frontend/components/layout/MainHeader.tsx index f04dcca3..2b6ab40d 100644 --- a/frontend/components/layout/MainHeader.tsx +++ b/frontend/components/layout/MainHeader.tsx @@ -6,13 +6,14 @@ interface MainHeaderProps { user: any; onSidebarToggle: () => void; onProfileClick: () => void; + onPopModeClick?: () => void; onLogout: () => void; } /** * 메인 헤더 컴포넌트 */ -export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) { +export function MainHeader({ user, onSidebarToggle, onProfileClick, onPopModeClick, onLogout }: MainHeaderProps) { return (
@@ -27,7 +28,7 @@ export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: {/* Right side - Admin Button + User Menu */}
- +
diff --git a/frontend/components/layout/TabContent.tsx b/frontend/components/layout/TabContent.tsx index 0c1fabfb..f2236b80 100644 --- a/frontend/components/layout/TabContent.tsx +++ b/frontend/components/layout/TabContent.tsx @@ -19,6 +19,11 @@ import { clearTabCache, } from "@/lib/tabStateCache"; +// 페이지 로드(F5 새로고침) 감지용 모듈 레벨 플래그. +// 전체 페이지 로드 시 모듈이 재실행되어 false로 리셋된다. +// SPA 내비게이션에서는 모듈이 유지되므로 true로 남는다. +let hasHandledPageLoad = false; + export function TabContent() { const tabs = useTabStore(selectTabs); const activeTabId = useTabStore(selectActiveTabId); @@ -39,6 +44,13 @@ export function TabContent() { // 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함) const pathCacheRef = useRef>(new WeakMap()); + // 페이지 로드(F5) 시 활성 탭 캐시만 삭제 → fresh API 호출 유도 + // 비활성 탭 캐시는 유지하여 탭 전환 시 복원 + if (!hasHandledPageLoad && activeTabId) { + hasHandledPageLoad = true; + clearTabCache(activeTabId); + } + if (activeTabId) { mountedTabIdsRef.current.add(activeTabId); } @@ -226,6 +238,14 @@ function TabPageRenderer({ tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string }; refreshKey: number; }) { + console.log("[TabPageRenderer] 탭 렌더링:", { + tabId: tab.id, + type: tab.type, + screenId: tab.screenId, + adminUrl: tab.adminUrl, + menuObjid: tab.menuObjid, + }); + if (tab.type === "screen" && tab.screenId != null) { return ( void; + onPopModeClick?: () => void; onLogout: () => void; } /** * 사용자 드롭다운 메뉴 컴포넌트 */ -export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) { +export function UserDropdown({ user, onProfileClick, onPopModeClick, onLogout }: UserDropdownProps) { const router = useRouter(); if (!user) return null; @@ -73,7 +74,6 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro ? `${user.deptName}, ${user.positionName}` : user.deptName || user.positionName || "부서 정보 없음"}

- {/* 사진 상태 표시 */}
@@ -86,6 +86,12 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro 결재함 + {onPopModeClick && ( + + + POP 모드 + + )} diff --git a/frontend/components/pop/dashboard/DashboardHeader.tsx b/frontend/components/pop/dashboard/DashboardHeader.tsx index a16cbb05..20136c59 100644 --- a/frontend/components/pop/dashboard/DashboardHeader.tsx +++ b/frontend/components/pop/dashboard/DashboardHeader.tsx @@ -1,7 +1,7 @@ "use client"; 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"; interface DashboardHeaderProps { @@ -11,6 +11,7 @@ interface DashboardHeaderProps { company: CompanyInfo; onThemeToggle: () => void; onUserClick: () => void; + onPcModeClick?: () => void; } export function DashboardHeader({ @@ -20,6 +21,7 @@ export function DashboardHeader({ company, onThemeToggle, onUserClick, + onPcModeClick, }: DashboardHeaderProps) { const [mounted, setMounted] = useState(false); const [currentTime, setCurrentTime] = useState(new Date()); @@ -81,6 +83,17 @@ export function DashboardHeader({
{company.subTitle}
+ {/* PC 모드 복귀 */} + {onPcModeClick && ( + + )} + {/* 사용자 배지 */} )} + + {conn.filterConfig?.targetColumn && ( +
+ + {conn.filterConfig.targetColumn} + + + {conn.filterConfig.filterMode} + + {conn.filterConfig.isSubTable && ( + + 하위 테이블 + + )} +
+ )} )} ))} - {isFilterSource ? ( - onAddConnection?.(data)} - submitLabel="연결 추가" - /> - ) : ( - onAddConnection?.(data)} - submitLabel="연결 추가" - /> - )} + onAddConnection?.(data)} + submitLabel="연결 추가" + /> ); } @@ -263,6 +205,19 @@ interface SimpleConnectionFormProps { submitLabel: string; } +function extractSubTableName(comp: PopComponentDefinitionV5): string | null { + const cfg = comp.config as Record | 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({ component, allComponents, @@ -274,6 +229,18 @@ function SimpleConnectionForm({ const [selectedTargetId, setSelectedTargetId] = React.useState( initial?.targetComponent || "" ); + const [isSubTable, setIsSubTable] = React.useState( + initial?.filterConfig?.isSubTable || false + ); + const [targetColumn, setTargetColumn] = React.useState( + initial?.filterConfig?.targetColumn || "" + ); + const [filterMode, setFilterMode] = React.useState( + initial?.filterConfig?.filterMode || "equals" + ); + + const [subColumns, setSubColumns] = React.useState([]); + const [loadingColumns, setLoadingColumns] = React.useState(false); const targetCandidates = allComponents.filter((c) => { if (c.id === component.id) return false; @@ -281,14 +248,39 @@ function SimpleConnectionForm({ 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 = () => { 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 tgtLabel = targetComp?.label || targetComp?.id || "?"; + const tgtLabel = tComp?.label || tComp?.id || "?"; - onSubmit({ + const conn: Omit = { sourceComponent: component.id, sourceField: "", sourceOutput: "_auto", @@ -296,10 +288,23 @@ function SimpleConnectionForm({ targetField: "", targetInput: "_auto", label: `${srcLabel} → ${tgtLabel}`, - }); + }; + + if (isFilterConnection && isSubTable && targetColumn) { + conn.filterConfig = { + targetColumn, + filterMode: filterMode as "equals" | "contains" | "starts_with" | "range", + isSubTable: true, + }; + } + + onSubmit(conn); if (!initial) { setSelectedTargetId(""); + setIsSubTable(false); + setTargetColumn(""); + setFilterMode("equals"); } }; @@ -319,224 +324,12 @@ function SimpleConnectionForm({
어디로? - -
- - - - ); -} - -// ======================================== -// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지) -// ======================================== - -interface FilterConnectionFormProps { - component: PopComponentDefinitionV5; - meta: ComponentConnectionMeta; - allComponents: PopComponentDefinitionV5[]; - initial?: PopDataConnection; - onSubmit: (data: Omit) => 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( - 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([]); - 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 ( -
- {onCancel && ( -
-

연결 수정

- -
- )} - {!onCancel && ( -

새 연결 추가

- )} - -
- 보내는 값 - -
- -
- 받는 컴포넌트
- {targetMeta && ( -
- 받는 방식 - -
- )} - - {selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && ( -
-

필터할 컬럼

- - {dbColumnsLoading ? ( -
- - 컬럼 조회 중... -
- ) : hasAnyColumns ? ( -
- {displayColumns.length > 0 && ( -
-

화면 표시 컬럼

- {displayColumns.map((col) => ( -
- toggleColumn(col)} - /> - -
- ))} -
- )} - - {dataOnlyColumns.length > 0 && ( -
- {displayColumns.length > 0 && ( -
- )} -

데이터 전용 컬럼

- {dataOnlyColumns.map((col) => ( -
- toggleColumn(col)} - /> - -
- ))} -
- )} -
- ) : ( - setFilterColumns(e.target.value ? [e.target.value] : [])} - placeholder="컬럼명 입력" - className="h-7 text-xs" + {isFilterConnection && selectedTargetId && subTableName && ( +
+
+ { + setIsSubTable(v === true); + if (!v) setTargetColumn(""); + }} /> - )} - - {filterColumns.length > 0 && ( -

- {filterColumns.length}개 컬럼 중 하나라도 일치하면 표시 -

- )} - -
-

필터 방식

- +
+ + {isSubTable && ( +
+
+ 대상 컬럼 + {loadingColumns ? ( +
+ + 컬럼 로딩 중... +
+ ) : ( + + )} +
+ +
+ 비교 방식 + +
+
+ )}
)} @@ -662,7 +408,7 @@ function FilterConnectionForm({ size="sm" variant="outline" className="h-7 w-full text-xs" - disabled={!selectedOutput || !selectedTargetId || !selectedTargetInput} + disabled={!selectedTargetId} onClick={handleSubmit} > {!initial && } @@ -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}`; -} diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 598f0e90..a9c7db6e 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -72,10 +72,14 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-icon": "아이콘", "pop-dashboard": "대시보드", "pop-card-list": "카드 목록", + "pop-card-list-v2": "카드 목록 V2", "pop-button": "버튼", "pop-string-list": "리스트 목록", "pop-search": "검색", + "pop-status-bar": "상태 바", "pop-field": "입력", + "pop-scanner": "스캐너", + "pop-profile": "프로필", }; // ======================================== @@ -554,7 +558,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect if (ActualComp) { // 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 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 (
= ({ } }, [relatedButtonFilter]); + // TableOptionsContext 필터 변경 시 데이터 재조회 (TableSearchWidget 연동) + const filtersAppliedRef = useRef(false); + useEffect(() => { + // 초기 렌더 시 빈 배열은 무시 (불필요한 재조회 방지) + if (!filtersAppliedRef.current && filters.length === 0) return; + filtersAppliedRef.current = true; + + const filterSearchParams: Record = {}; + filters.forEach((f) => { + if (f.value !== "" && f.value !== undefined && f.value !== null) { + filterSearchParams[f.columnName] = f.value; + } + }); + loadData(1, { ...searchValues, ...filterSearchParams }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters]); + // 카테고리 타입 컬럼의 값 매핑 로드 useEffect(() => { const loadCategoryMappings = async () => { diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 49f0f586..17fd7616 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1850,7 +1850,7 @@ export const InteractiveScreenViewer: React.FC = ( try { // console.log("🗑️ 삭제 실행:", { recordId, tableName, formData }); - const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName); + const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName, screenInfo?.id); if (result.success) { alert("삭제되었습니다."); diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index f8a46f1d..4726cf39 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -47,6 +47,7 @@ interface RealtimePreviewProps { selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백 selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID + onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void; onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백 // 버튼 액션을 위한 props @@ -150,6 +151,7 @@ const RealtimePreviewDynamicComponent: React.FC = ({ selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백 selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID + onNestedPanelSelect, onResize, // 🆕 리사이즈 콜백 }) => { // 🆕 화면 다국어 컨텍스트 @@ -768,6 +770,7 @@ const RealtimePreviewDynamicComponent: React.FC = ({ selectedTabComponentId={selectedTabComponentId} onSelectPanelComponent={onSelectPanelComponent} selectedPanelComponentId={selectedPanelComponentId} + onNestedPanelSelect={onNestedPanelSelect} />
diff --git a/frontend/components/screen/ResponsiveGridRenderer.tsx b/frontend/components/screen/ResponsiveGridRenderer.tsx index 8f671c21..c718c70e 100644 --- a/frontend/components/screen/ResponsiveGridRenderer.tsx +++ b/frontend/components/screen/ResponsiveGridRenderer.tsx @@ -109,6 +109,8 @@ interface ProcessedRow { mainComponent?: ComponentData; overlayComps: ComponentData[]; normalComps: ComponentData[]; + rowMinY?: number; + rowMaxBottom?: number; } function FullWidthOverlayRow({ @@ -202,6 +204,66 @@ function FullWidthOverlayRow({ ); } +function ProportionalRenderer({ + components, + canvasWidth, + canvasHeight, + renderComponent, +}: ResponsiveGridRendererProps) { + const containerRef = useRef(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 ( +
0 ? `${maxBottom * ratio}px` : "200px" }} + > + {containerW > 0 && + topLevel.map((component) => { + const typeId = getComponentTypeId(component); + return ( +
+ {renderComponent(component)} +
+ ); + })} +
+ ); +} + export function ResponsiveGridRenderer({ components, canvasWidth, @@ -211,6 +273,18 @@ export function ResponsiveGridRenderer({ const { isMobile } = useResponsive(); const topLevel = components.filter((c) => !c.parentId); + const hasFullWidthComponent = topLevel.some((c) => isFullWidthComponent(c)); + + if (!isMobile && !hasFullWidthComponent) { + return ( + + ); + } const rows = groupComponentsIntoRows(topLevel); 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) { for (const fwComp of fullWidthComps) { processedRows.push({ @@ -234,6 +312,8 @@ export function ResponsiveGridRenderer({ mainComponent: fwComp, overlayComps: normalComps, normalComps: [], + rowMinY, + rowMaxBottom, }); } } else if (fullWidthComps.length > 0) { @@ -243,6 +323,8 @@ export function ResponsiveGridRenderer({ mainComponent: fwComp, overlayComps: [], normalComps: [], + rowMinY, + rowMaxBottom, }); } } else { @@ -250,6 +332,8 @@ export function ResponsiveGridRenderer({ type: "normal", overlayComps: [], normalComps, + rowMinY, + rowMaxBottom, }); } } @@ -261,21 +345,71 @@ export function ResponsiveGridRenderer({ style={{ minHeight: "200px" }} > {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) { return ( - +
0 ? `${rowMarginTop}px` : undefined }}> + +
); } const { normalComps } = processedRow; 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 ( +
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 ( +
+ {renderComponent(component)} +
+ ); + })} +
+ ); + } + + const gap = isMobile ? 8 : getRowGap(normalComps, canvasWidth); const hasFlexHeightComp = normalComps.some((c) => { const h = c.size?.height || 0; @@ -287,10 +421,9 @@ export function ResponsiveGridRenderer({ key={`row-${rowIndex}`} className={cn( "flex w-full flex-wrap overflow-hidden", - allButtons && "justify-end px-2 py-1", 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) => { const typeId = getComponentTypeId(component); @@ -334,13 +467,13 @@ export function ResponsiveGridRenderer({ style={{ width: isFullWidth ? "100%" : undefined, flexBasis: useFlexHeight ? undefined : flexBasis, - flexGrow: 1, + flexGrow: percentWidth, flexShrink: 1, minWidth: isMobile ? "100%" : undefined, - minHeight: useFlexHeight ? "300px" : undefined, - height: useFlexHeight ? "100%" : (component.size?.height + minHeight: useFlexHeight ? "300px" : (component.size?.height ? `${component.size.height}px` - : "auto"), + : undefined), + height: useFlexHeight ? "100%" : "auto", }} > {renderComponent(component)} diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 70102da2..d22a9c14 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -2870,9 +2870,190 @@ export default function ScreenDesigner({ } } - // 🎯 탭 컨테이너 내부 드롭 처리 (중첩 구조 지원) + // 🎯 컨테이너 드롭 우선순위: 가장 안쪽(innermost) 컨테이너 우선 + // 분할패널과 탭 둘 다 감지될 경우, DOM 트리에서 더 가까운 쪽을 우선 처리 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 activeTabId = tabsContainer.getAttribute("data-active-tab-id"); 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(); if (!rect) return; @@ -3387,15 +3505,12 @@ export default function ScreenDesigner({ e.preventDefault(); const dragData = e.dataTransfer.getData("application/json"); - // console.log("🎯 드롭 이벤트:", { dragData }); if (!dragData) { - // console.log("❌ 드래그 데이터가 없습니다"); return; } try { const parsedData = JSON.parse(dragData); - // console.log("📋 파싱된 데이터:", parsedData); // 템플릿 드래그인 경우 if (parsedData.type === "template") { @@ -3489,9 +3604,225 @@ export default function ScreenDesigner({ } } - // 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원) + // 🎯 컨테이너 감지: innermost 우선 (분할패널 > 탭) 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 activeTabId = tabsContainer.getAttribute("data-active-tab-id"); if (containerId && activeTabId) { @@ -3657,9 +3988,8 @@ export default function ScreenDesigner({ } } - // 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리 - const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]'); - if (splitPanelContainer && type === "column" && column) { + // 🎯 분할 패널 커스텀 모드 (탭 밖 최상위) 컬럼 드롭 처리 + if (splitPanelContainer && !splitPanelFirst && type === "column" && column) { const containerId = splitPanelContainer.getAttribute("data-component-id"); const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right" if (containerId && panelSide) { @@ -3671,12 +4001,11 @@ export default function ScreenDesigner({ 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 cs2 = window.getComputedStyle(splitPanelContainer); + 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({ widgetType: column.widgetType, columnName: column.columnName, @@ -6424,15 +6753,6 @@ export default function ScreenDesigner({ const { splitPanelId, panelSide } = selectedPanelComponentInfo; const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; - console.log("🔧 updatePanelComponentProperty 호출:", { - componentId, - path, - value, - splitPanelId, - panelSide, - }); - - // 🆕 안전한 깊은 경로 업데이트 헬퍼 함수 const setNestedValue = (obj: any, pathStr: string, val: any): any => { const result = JSON.parse(JSON.stringify(obj)); const parts = pathStr.split("."); @@ -6449,9 +6769,27 @@ export default function ScreenDesigner({ 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) => { - const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId); - if (!splitPanelComponent) return prevLayout; + const result = findSplitPanelInLayout(prevLayout.components); + if (!result) return prevLayout; + const splitPanelComponent = result.found; const currentConfig = (splitPanelComponent as any).componentConfig || {}; const panelConfig = currentConfig[panelKey] || {}; @@ -6487,17 +6825,37 @@ export default function ScreenDesigner({ }, }; - // selectedPanelComponentInfo 업데이트 setSelectedPanelComponentInfo((prev) => prev ? { ...prev, component: updatedComp } : null, ); - return { - ...prevLayout, - components: prevLayout.components.map((c) => - c.id === splitPanelId ? updatedComponent : c, - ), + // 중첩 구조 반영 + 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 { + ...layout, + components: layout.components.map((c: any) => { + if (c.id !== info.parentTabId) return c; + const cfgKey = c.componentConfig?.tabs ? "componentConfig" : "overrides"; + const cfg = c[cfgKey] || {}; + return { + ...c, + [cfgKey]: { + ...cfg, + tabs: (cfg.tabs || []).map((t: any) => + t.id === info.parentTabTabId + ? { ...t, components: (t.components || []).map((tc: any) => tc.id === splitPanelId ? updated : tc) } + : t, + ), + }, + }; + }), + }; }; + + return applyUpdatedSplitPanel(prevLayout, updatedComponent, result); }); }; @@ -6507,8 +6865,23 @@ export default function ScreenDesigner({ const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; setLayout((prevLayout) => { - const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId); - if (!splitPanelComponent) return prevLayout; + const findResult = (() => { + 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 panelConfig = currentConfig[panelKey] || {}; @@ -6529,11 +6902,27 @@ export default function ScreenDesigner({ setSelectedPanelComponentInfo(null); + if (findResult.path === "top") { + return { ...prevLayout, components: prevLayout.components.map((c: any) => c.id === splitPanelId ? updatedComponent : c) }; + } return { ...prevLayout, - components: prevLayout.components.map((c) => - c.id === splitPanelId ? updatedComponent : c, - ), + components: prevLayout.components.map((c: any) => { + 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) => handleSelectPanelComponent(component.id, panelSide, compId, comp) } + onNestedPanelSelect={handleSelectPanelComponent} selectedPanelComponentId={ selectedPanelComponentInfo?.splitPanelId === component.id ? selectedPanelComponentInfo.componentId diff --git a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx index 48a7cbf9..3cffffff 100644 --- a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx +++ b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx @@ -541,8 +541,31 @@ export function DataFilterConfigPanel({ {/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */} {filter.valueType === "category" && categoryValues[filter.columnName] ? ( = ({ />
- + = ({ />
- + = ({ {/* Title (group/area) */} {(selectedComponent.type === "group" || selectedComponent.type === "area") && ( -
-

CONTENT

+
+

CONTENT

- 제목 + 제목
= ({
{selectedComponent.type === "area" && (
- 설명 + 설명
= ({ )} {/* OPTIONS 섹션 */} -
-

OPTIONS

- {(isInputField || widget.required !== undefined) && (() => { - const colName = widget.columnName || selectedComponent?.columnName; - const colMeta = colName ? currentTable?.columns?.find( - (c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase() - ) : 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 ( -
- - 필수 - {isNotNull && (NOT NULL)} - - { - if (isNotNull) return; - handleUpdate("required", checked); - handleUpdate("componentConfig.required", checked); - }} - disabled={!!isNotNull} - className="h-4 w-4" - /> -
- ); - })()} +
+

OPTIONS

+ {(isInputField || widget.required !== undefined) && + (() => { + const colName = widget.columnName || selectedComponent?.columnName; + const colMeta = colName + ? currentTable?.columns?.find( + (c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase(), + ) + : 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 ( +
+ + 필수 + {isNotNull && (NOT NULL)} + + { + if (isNotNull) return; + handleUpdate("required", checked); + handleUpdate("componentConfig.required", checked); + }} + disabled={!!isNotNull} + className="h-4 w-4" + /> +
+ ); + })()} {(isInputField || widget.readonly !== undefined) && (
- 읽기전용 + 읽기전용 { @@ -489,7 +557,7 @@ export const V2PropertiesPanel: React.FC = ({
)}
- 숨김 + 숨김 { @@ -505,13 +573,13 @@ export const V2PropertiesPanel: React.FC = ({ {isInputField && ( - LABEL - + LABEL + {/* 라벨 텍스트 */}
- 텍스트 + 텍스트
= ({ {/* 위치 + 간격 */}
- +
- + { const pos = selectedComponent.style?.labelPosition; @@ -570,7 +639,7 @@ export const V2PropertiesPanel: React.FC = ({ {/* 크기 + 색상 */}
- + handleUpdate("style.labelFontSize", e.target.value)} @@ -578,7 +647,7 @@ export const V2PropertiesPanel: React.FC = ({ />
- + handleUpdate("style.labelColor", value)} @@ -589,7 +658,7 @@ export const V2PropertiesPanel: React.FC = ({
{/* 굵기 */}
- 굵기 + 굵기
= 10 && filter.width <= 100 ? filter.width : 25} onChange={(e) => { - const newWidth = parseInt(e.target.value) || 200; + const newWidth = Math.min(100, Math.max(10, parseInt(e.target.value) || 25)); setColumnFilters((prev) => prev.map((f) => (f.columnName === filter.columnName ? { ...f, width: newWidth } : f)), ); }} disabled={!filter.enabled} - placeholder="너비" + placeholder="25" className="h-8 w-[80px] text-xs sm:h-9 sm:text-sm" - min={50} - max={500} + min={10} + max={100} /> - px + %
))}
diff --git a/frontend/components/screen/table-options/TableOptionsToolbar.tsx b/frontend/components/screen/table-options/TableOptionsToolbar.tsx index 20cbf299..0eb144c8 100644 --- a/frontend/components/screen/table-options/TableOptionsToolbar.tsx +++ b/frontend/components/screen/table-options/TableOptionsToolbar.tsx @@ -109,9 +109,8 @@ export const TableOptionsToolbar: React.FC = () => { onOpenChange={setColumnPanelOpen} /> setFilterPanelOpen(false)} /> = ({ isOpen, onClose, onFilters inputType, enabled: false, filterType, - width: 200, + width: 25, }; }); @@ -271,7 +271,7 @@ export const TableSettingsModal: React.FC = ({ isOpen, onClose, onFilters operator: "contains", value: "", filterType: f.filterType, - width: f.width || 200, + width: f.width && f.width >= 10 && f.width <= 100 ? f.width : 25, })); onFiltersApplied?.(activeFilters); @@ -498,15 +498,15 @@ export const TableSettingsModal: React.FC = ({ isOpen, onClose, onFilters = 10 && filter.width <= 100 ? filter.width : 25} 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" /> - px + %
))}
diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 83c55777..688a6ca7 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -430,28 +430,28 @@ export function TabsWidget({ return ( ( - - )} - /> + components={componentDataList} + canvasWidth={canvasWidth} + canvasHeight={canvasHeight} + renderComponent={(comp) => ( + + )} + /> ); }; diff --git a/frontend/components/table-category/CategoryValueManagerTree.tsx b/frontend/components/table-category/CategoryValueManagerTree.tsx index 88ecfb49..f6f7ff8a 100644 --- a/frontend/components/table-category/CategoryValueManagerTree.tsx +++ b/frontend/components/table-category/CategoryValueManagerTree.tsx @@ -288,6 +288,7 @@ export const CategoryValueManagerTree: React.FC = const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); const [parentValue, setParentValue] = useState(null); + const [continuousAdd, setContinuousAdd] = useState(false); const [editingValue, setEditingValue] = useState(null); const [deletingValue, setDeletingValue] = useState(null); @@ -512,21 +513,24 @@ export const CategoryValueManagerTree: React.FC = const response = await createCategoryValue(input); if (response.success) { toast.success("카테고리가 추가되었습니다"); - // 폼 초기화 (모달은 닫지 않고 연속 입력) - setFormData((prev) => ({ - ...prev, - valueCode: "", - valueLabel: "", - description: "", - color: "", - })); - setTimeout(() => addNameRef.current?.focus(), 50); - // 기존 펼침 상태 유지하면서 데이터 새로고침 await loadTree(true); - // 부모 노드만 펼치기 (하위 추가 시) if (parentValue) { setExpandedNodes((prev) => new Set([...prev, parentValue.valueId])); } + + if (continuousAdd) { + setFormData((prev) => ({ + ...prev, + valueCode: "", + valueLabel: "", + description: "", + color: "", + })); + setTimeout(() => addNameRef.current?.focus(), 50); + } else { + setFormData({ valueCode: "", valueLabel: "", description: "", color: "", isActive: true }); + setIsAddModalOpen(false); + } } else { toast.error(response.error || "추가 실패"); } @@ -818,6 +822,19 @@ export const CategoryValueManagerTree: React.FC = 추가 + +
+
+ setContinuousAdd(checked as boolean)} + /> + +
+
diff --git a/frontend/components/unified/UnifiedSelect.tsx b/frontend/components/unified/UnifiedSelect.tsx index d307fbc1..1045ba8c 100644 --- a/frontend/components/unified/UnifiedSelect.tsx +++ b/frontend/components/unified/UnifiedSelect.tsx @@ -629,7 +629,7 @@ export const UnifiedSelect = forwardRef((pro ): SelectOption[] => { const result: SelectOption[] = []; for (const item of items) { - const prefix = depth > 0 ? " ".repeat(depth) + "└ " : ""; + const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : ""; result.push({ value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치) label: prefix + item.valueLabel, diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index e61ba143..c8204faf 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -18,6 +18,7 @@ import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } import { Input } from "@/components/ui/input"; import { Slider } from "@/components/ui/slider"; import { Label } from "@/components/ui/label"; +import { formatNumber as centralFormatNumber } from "@/lib/formatting"; import { cn } from "@/lib/utils"; import { V2InputProps, V2InputConfig, V2InputFormat } from "@/types/v2-components"; import { AutoGenerationUtils } from "@/lib/utils/autoGeneration"; @@ -61,11 +62,11 @@ export function validateInputFormat(value: string, format: V2InputFormat): { isV return { isValid, errorMessage: isValid ? "" : formatConfig.errorMessage }; } -// 통화 형식 변환 +// 통화 형식 변환 (공통 formatNumber 사용) function formatCurrency(value: string | number): string { const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value; if (isNaN(num)) return ""; - return num.toLocaleString("ko-KR"); + return centralFormatNumber(num); } // 사업자번호 형식 변환 @@ -234,7 +235,22 @@ const TextInput = forwardRef< 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< HTMLInputElement, @@ -250,40 +266,112 @@ const NumberInput = forwardRef< className?: string; 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(null); + const combinedRef = (node: HTMLInputElement | null) => { + (innerRef as React.MutableRefObject).current = node; + if (typeof ref === "function") ref(node); + else if (ref) (ref as React.MutableRefObject).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( (e: React.ChangeEvent) => { - const val = e.target.value; - if (val === "") { + const input = e.target; + 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); 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 (max !== undefined && num > max) num = max; 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 ( ); }); @@ -909,10 +997,10 @@ export const V2Input = forwardRef((props, ref) => const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : ""; return ( -
+
{/* 고정 접두어 */} {templatePrefix && ( - + {templatePrefix} )} @@ -945,13 +1033,13 @@ export const V2Input = forwardRef((props, ref) => } }} placeholder="입력" - className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none" + className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm ring-0" disabled={disabled || isGeneratingNumbering} - style={inputTextStyle} + style={{ ...inputTextStyle, outline: 'none' }} /> {/* 고정 접미어 */} {templateSuffix && ( - + {templateSuffix} )} diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 959abe05..9062e7bc 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -901,7 +901,7 @@ export const V2Select = forwardRef((props, ref) = ): SelectOption[] => { const result: SelectOption[] = []; for (const item of items) { - const prefix = depth > 0 ? " ".repeat(depth) + "└ " : ""; + const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : ""; result.push({ value: item.valueCode, // 🔧 valueCode를 value로 사용 label: prefix + item.valueLabel, diff --git a/frontend/components/v2/config-panels/V2InputConfigPanel.tsx b/frontend/components/v2/config-panels/V2InputConfigPanel.tsx index 44d7bd2a..9059741a 100644 --- a/frontend/components/v2/config-panels/V2InputConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2InputConfigPanel.tsx @@ -10,8 +10,21 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 { 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 { AutoGenerationType, AutoGenerationConfig } from "@/types/screen"; import { AutoGenerationUtils } from "@/lib/utils/autoGeneration"; @@ -22,9 +35,15 @@ interface V2InputConfigPanelProps { config: Record; onChange: (config: Record) => void; menuObjid?: number; + allComponents?: any[]; } -export const V2InputConfigPanel: React.FC = ({ config, onChange, menuObjid }) => { +export const V2InputConfigPanel: React.FC = ({ + config, + onChange, + menuObjid, + allComponents = [], +}) => { const [numberingRules, setNumberingRules] = useState([]); const [loadingRules, setLoadingRules] = useState(false); const [parentMenus, setParentMenus] = useState([]); @@ -49,7 +68,7 @@ export const V2InputConfigPanel: React.FC = ({ config, const userMenus = allMenus.filter((menu: any) => { const menuType = menu.menu_type || menu.menuType; 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); } @@ -68,7 +87,10 @@ export const V2InputConfigPanel: React.FC = ({ config, const loadRules = async () => { const isNumbering = inputType === "numbering" || config.autoGeneration?.type === "numbering_rule"; if (!isNumbering) return; - if (!selectedMenuObjid) { setNumberingRules([]); return; } + if (!selectedMenuObjid) { + setNumberingRules([]); + return; + } setLoadingRules(true); try { const response = await getAvailableNumberingRules(selectedMenuObjid); @@ -90,10 +112,10 @@ export const V2InputConfigPanel: React.FC = ({ config, {/* ─── 1단계: 입력 타입 선택 (카드 방식) ─── */}
- +

입력 타입

-

입력 필드의 종류를 선택해요

+

입력 필드의 종류를 선택해요

@@ -130,20 +152,23 @@ export const V2InputConfigPanel: React.FC = ({ config, className={cn( "flex items-center gap-2 rounded-lg border p-2.5 text-left transition-all", inputType === item.value - ? "border-primary bg-primary/5 ring-1 ring-primary/20" - : "border-border hover:border-primary/30 hover:bg-muted/30" + ? "border-primary bg-primary/5 ring-primary/20 ring-1" + : "border-border hover:border-primary/30 hover:bg-muted/30", )} > - +
- {item.label} - {item.desc} + + {item.label} + + {item.desc}
))} @@ -151,34 +176,34 @@ export const V2InputConfigPanel: React.FC = ({ config, {/* ─── 채번 타입 전용 설정 ─── */} {inputType === "numbering" && ( -
+
- + 채번 규칙
-

적용할 메뉴

+

적용할 메뉴

{menuObjid && selectedMenuObjid === menuObjid ? ( -
-

현재 화면 메뉴 사용 중

+
+

현재 화면 메뉴 사용 중

- {parentMenus.find((m: any) => m.objid === menuObjid)?.menu_name_kor - || parentMenus.find((m: any) => m.objid === menuObjid)?.translated_name - || `메뉴 #${menuObjid}`} + {parentMenus.find((m: any) => m.objid === menuObjid)?.menu_name_kor || + parentMenus.find((m: any) => m.objid === menuObjid)?.translated_name || + `메뉴 #${menuObjid}`}

) : loadingMenus ? ( -
+
메뉴 목록 로딩 중...
@@ -214,9 +239,9 @@ export const V2InputConfigPanel: React.FC = ({ config, {selectedMenuObjid && (
-

채번 규칙

+

채번 규칙

{loadingRules ? ( -
+
채번 규칙 로딩 중...
@@ -241,13 +266,14 @@ export const V2InputConfigPanel: React.FC = ({ config, {numberingRules.map((rule) => ( - {rule.ruleName} ({rule.separator || "-"}{"{번호}"}) + {rule.ruleName} ({rule.separator || "-"} + {"{번호}"}) ))} ) : ( -

선택한 메뉴에 등록된 채번 규칙이 없어요

+

선택한 메뉴에 등록된 채번 규칙이 없어요

)}
)} @@ -255,7 +281,7 @@ export const V2InputConfigPanel: React.FC = ({ config,

읽기전용

-

채번 필드는 자동 생성되므로 읽기전용을 권장해요

+

채번 필드는 자동 생성되므로 읽기전용을 권장해요

= ({ config, {inputType !== "numbering" && ( <> {/* 기본 설정 영역 */} -
+
{/* 안내 텍스트 (placeholder) */}
- 안내 텍스트 + 안내 텍스트 updateConfig("placeholder", e.target.value)} @@ -284,7 +310,7 @@ export const V2InputConfigPanel: React.FC = ({ config, {/* 입력 형식 - 텍스트 타입 전용 */} {(inputType === "text" || !config.inputType) && (
- 입력 형식 + 입력 형식 = ({ config, {/* 숫자/슬라이더: 범위 설정 */} {(inputType === "number" || inputType === "slider") && (
-

값 범위

+

값 범위

- + = ({ config, />
- + = ({ config, />
- + = ({ config, {/* 여러 줄 텍스트: 줄 수 */} {inputType === "textarea" && (
- 줄 수 + 줄 수 = ({ config, -
+
{/* 자동 생성 토글 */}

자동 생성

-

값이 자동으로 채워져요

+

값이 자동으로 채워져요

= ({ config,
{config.autoGeneration?.enabled && ( -
+
{/* 자동 생성 타입 */}
-

생성 방식

+

생성 방식

= ({ config, {selectedMenuObjid ? (
-

+

채번 규칙 *

{loadingRules ? ( -
+
규칙 로딩 중...
@@ -542,7 +568,7 @@ export const V2InputConfigPanel: React.FC = ({ config,
{["random_string", "random_number"].includes(config.autoGeneration.type) && (
- 길이 + 길이 = ({ config, )}
- 접두사 + 접두사 { @@ -581,7 +607,7 @@ export const V2InputConfigPanel: React.FC = ({ config,
- 접미사 + 접미사 { @@ -598,8 +624,8 @@ export const V2InputConfigPanel: React.FC = ({ config,
- 미리보기 -
+ 미리보기 +
{AutoGenerationUtils.generatePreviewValue(config.autoGeneration)}
@@ -612,10 +638,195 @@ export const V2InputConfigPanel: React.FC = ({ config, )} + + {/* 데이터 바인딩 설정 */} + +
); }; V2InputConfigPanel.displayName = "V2InputConfigPanel"; +/** + * 데이터 바인딩 설정 섹션 + * 같은 화면의 v2-table-list 컴포넌트를 자동 감지하여 드롭다운으로 표시 + */ +function DataBindingSection({ + config, + onChange, + allComponents, +}: { + config: Record; + onChange: (config: Record) => void; + allComponents: any[]; +}) { + const [tableColumns, setTableColumns] = useState([]); + 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 ( +
+
+ { + if (checked) { + const firstTable = tableListComponents[0]; + updateConfig("dataBinding", { + sourceComponentId: firstTable?.id || "", + sourceColumn: "", + }); + } else { + updateConfig("dataBinding", undefined); + } + }} + /> + +
+ + {config.dataBinding && ( +
+

테이블에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다

+ + {/* 소스 테이블 컴포넌트 선택 */} +
+ + {tableListComponents.length === 0 ? ( +

이 화면에 v2-table-list 컴포넌트가 없습니다

+ ) : ( + + )} +
+ + {/* 소스 컬럼 선택 */} + {config.dataBinding?.sourceComponentId && ( +
+ + {loadingColumns ? ( +

컬럼 로딩 중...

+ ) : tableColumns.length === 0 ? ( + <> + { + updateConfig("dataBinding", { + ...config.dataBinding, + sourceColumn: e.target.value, + }); + }} + placeholder="컬럼명 직접 입력" + className="h-7 text-xs" + /> +

컬럼 정보를 불러올 수 없어 직접 입력

+ + ) : ( + + )} +
+ )} +
+ )} +
+ ); +} + export default V2InputConfigPanel; diff --git a/frontend/components/v2/config-panels/V2ItemRoutingConfigPanel.tsx b/frontend/components/v2/config-panels/V2ItemRoutingConfigPanel.tsx index e47c61ba..851d90fe 100644 --- a/frontend/components/v2/config-panels/V2ItemRoutingConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2ItemRoutingConfigPanel.tsx @@ -2,7 +2,6 @@ /** * V2 품목별 라우팅 설정 패널 - * 토스식 단계별 UX: 데이터 소스 -> 모달 연동 -> 공정 컬럼 -> 레이아웃(접힘) */ import React, { useState, useEffect } from "react"; @@ -16,10 +15,10 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { Badge } from "@/components/ui/badge"; import { Settings, ChevronDown, ChevronRight, Plus, Trash2, Check, ChevronsUpDown, - Database, Monitor, Columns, + Database, Monitor, Columns, List, Filter, Eye, } from "lucide-react"; 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"; interface V2ItemRoutingConfigPanelProps { @@ -27,53 +26,21 @@ interface V2ItemRoutingConfigPanelProps { onChange: (config: Partial) => void; } -interface TableInfo { - tableName: string; - displayName?: string; -} +interface TableInfo { tableName: string; displayName?: string; } +interface ColumnInfo { columnName: string; displayName?: string; dataType?: string; } +interface ScreenInfo { screenId: number; screenName: string; screenCode: string; } -interface ColumnInfo { - columnName: string; - displayName?: string; - 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; +// ─── 공용: 테이블 Combobox ─── +function TableCombobox({ value, onChange, tables, loading }: { + value: string; onChange: (v: string) => void; tables: TableInfo[]; loading: boolean; }) { const [open, setOpen] = useState(false); const selected = tables.find((t) => t.tableName === value); - return ( - @@ -84,12 +51,8 @@ function TableCombobox({ 테이블을 찾을 수 없습니다. {tables.map((t) => ( - { onChange(t.tableName); setOpen(false); }} - className="text-xs" - > + { onChange(t.tableName); setOpen(false); }} className="text-xs">
{t.displayName || t.tableName} @@ -105,17 +68,9 @@ function TableCombobox({ ); } -// ─── 컬럼 Combobox ─── -function ColumnCombobox({ - value, - onChange, - tableName, - placeholder, -}: { - value: string; - onChange: (v: string) => void; - tableName: string; - placeholder?: string; +// ─── 공용: 컬럼 Combobox ─── +function ColumnCombobox({ value, onChange, tableName, placeholder }: { + value: string; onChange: (v: string, displayName?: string) => void; tableName: string; placeholder?: string; }) { const [open, setOpen] = useState(false); const [columns, setColumns] = useState([]); @@ -128,26 +83,17 @@ function ColumnCombobox({ try { const { tableManagementApi } = await import("@/lib/api/tableManagement"); const res = await tableManagementApi.getColumnList(tableName); - if (res.success && res.data?.columns) { - setColumns(res.data.columns); - } + if (res.success && res.data?.columns) setColumns(res.data.columns); } catch { /* ignore */ } finally { setLoading(false); } }; load(); }, [tableName]); const selected = columns.find((c) => c.columnName === value); - return ( - @@ -239,12 +162,8 @@ function ScreenCombobox({ 화면을 찾을 수 없습니다. {screens.map((s) => ( - { onChange(s.screenId); setOpen(false); }} - className="text-xs" - > + { onChange(s.screenId); setOpen(false); }} className="text-xs">
{s.screenName} @@ -260,17 +179,104 @@ function ScreenCombobox({ ); } +// ─── 컬럼 편집 카드 (품목/모달/공정 공용) ─── +function ColumnEditor({ columns, onChange, tableName, title, icon }: { + columns: ColumnDef[]; + onChange: (cols: ColumnDef[]) => void; + tableName: string; + title: string; + icon: React.ReactNode; +}) { + const [open, setOpen] = useState(false); + + const addColumn = () => onChange([...columns, { name: "", label: "새 컬럼", width: 100, align: "left" }]); + const removeColumn = (idx: number) => onChange(columns.filter((_, i) => i !== idx)); + const updateColumn = (idx: number, field: keyof ColumnDef, value: string | number) => { + const next = [...columns]; + next[idx] = { ...next[idx], [field]: value }; + onChange(next); + }; + + return ( + + + + + +
+ {columns.map((col, idx) => ( + +
+ + + + + +
+
+ 컬럼 + { + updateColumn(idx, "name", v); + if (!col.label || col.label === "새 컬럼" || col.label === col.name) updateColumn(idx, "label", displayName || v); + }} tableName={tableName} placeholder="컬럼 선택" /> +
+
+ 표시명 + updateColumn(idx, "label", e.target.value)} className="h-7 text-xs" /> +
+
+ 너비 + updateColumn(idx, "width", parseInt(e.target.value) || 100)} className="h-7 text-xs" /> +
+
+ 정렬 + +
+
+
+
+
+ ))} + +
+
+
+ ); +} + // ─── 메인 컴포넌트 ─── -export const V2ItemRoutingConfigPanel: React.FC = ({ - config: configProp, - onChange, -}) => { +export const V2ItemRoutingConfigPanel: React.FC = ({ config: configProp, onChange }) => { const [tables, setTables] = useState([]); const [loadingTables, setLoadingTables] = useState(false); const [modalOpen, setModalOpen] = useState(false); - const [columnsOpen, setColumnsOpen] = useState(false); const [dataSourceOpen, setDataSourceOpen] = useState(false); const [layoutOpen, setLayoutOpen] = useState(false); + const [filterOpen, setFilterOpen] = useState(false); const config: ItemRoutingConfig = { ...defaultConfig, @@ -278,6 +284,9 @@ export const V2ItemRoutingConfigPanel: React.FC = 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(() => { @@ -287,12 +296,7 @@ export const V2ItemRoutingConfigPanel: React.FC = 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, - })) - ); + setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName }))); } } catch { /* ignore */ } finally { setLoadingTables(false); } }; @@ -301,11 +305,7 @@ export const V2ItemRoutingConfigPanel: React.FC = const dispatchConfigEvent = (newConfig: Partial) => { if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("componentConfigChanged", { - detail: { config: { ...config, ...newConfig } }, - }) - ); + window.dispatchEvent(new CustomEvent("componentConfigChanged", { detail: { config: { ...config, ...newConfig } } })); } }; @@ -316,61 +316,141 @@ export const V2ItemRoutingConfigPanel: React.FC = }; const updateDataSource = (field: string, value: string) => { - const newDataSource = { ...config.dataSource, [field]: value }; - const partial = { dataSource: newDataSource }; - onChange({ ...configProp, ...partial }); - dispatchConfigEvent(partial); + const newDS = { ...config.dataSource, [field]: value }; + onChange({ ...configProp, dataSource: newDS }); + dispatchConfigEvent({ dataSource: newDS }); }; const updateModals = (field: string, value?: number) => { - const newModals = { ...config.modals, [field]: value }; - const partial = { modals: newModals }; - onChange({ ...configProp, ...partial }); - dispatchConfigEvent(partial); + const newM = { ...config.modals, [field]: value }; + onChange({ ...configProp, modals: newM }); + dispatchConfigEvent({ modals: newM }); }; - // 공정 컬럼 관리 - 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 }; - update({ processColumns: next }); + // 필터 조건 관리 + 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 (
- {/* ─── 1단계: 모달 연동 (Collapsible) ─── */} + {/* ─── 품목 목록 모드 ─── */} +
+
+ + 품목 목록 모드 +
+

좌측 품목 목록에 표시할 방식을 선택하세요

+
+ + +
+ {config.itemListMode === "registered" && ( +

+ 현재 화면 ID를 기준으로 품목 목록이 자동 관리됩니다. +

+ )} +
+ + {/* ─── 품목 표시 컬럼 ─── */} + update({ itemDisplayColumns: cols })} + tableName={config.dataSource.itemTable} + title="품목 목록 컬럼" + icon={} + /> + + {/* ─── 모달 표시 컬럼 (등록 모드에서만 의미 있지만 항상 설정 가능) ─── */} + update({ modalDisplayColumns: cols })} + tableName={config.dataSource.itemTable} + title="품목 추가 모달 컬럼" + icon={} + /> + + {/* ─── 품목 필터 조건 ─── */} + + + + + +
+

품목 조회 시 자동으로 적용되는 필터 조건입니다

+ {filters.map((f, idx) => ( +
+
+ 컬럼 + updateFilter(idx, "column", v)} + tableName={config.dataSource.itemTable} placeholder="필터 컬럼" /> +
+
+ 조건 + +
+
+ + updateFilter(idx, "value", e.target.value)} + placeholder="필터값" className="h-7 text-xs" /> +
+ +
+ ))} + +
+
+
+ + {/* ─── 모달 연동 ─── */} - @@ -379,291 +459,103 @@ export const V2ItemRoutingConfigPanel: React.FC =
버전 추가 - updateModals("versionAddScreenId", v)} - /> + updateModals("versionAddScreenId", v)} />
공정 추가 - updateModals("processAddScreenId", v)} - /> + updateModals("processAddScreenId", v)} />
공정 수정 - updateModals("processEditScreenId", v)} - /> + updateModals("processEditScreenId", v)} />
- {/* ─── 2단계: 공정 테이블 컬럼 (Collapsible + 접이식 카드) ─── */} - - - - - -
-

공정 순서 테이블에 표시할 컬럼

-
- {config.processColumns.map((col, idx) => ( - -
- - - - - -
-
- 컬럼명 - updateColumn(idx, "name", e.target.value)} - className="h-7 text-xs" - placeholder="컬럼명" - /> -
-
- 표시명 - updateColumn(idx, "label", e.target.value)} - className="h-7 text-xs" - placeholder="표시명" - /> -
-
- 너비 - updateColumn(idx, "width", parseInt(e.target.value) || 100)} - className="h-7 text-xs" - placeholder="100" - /> -
-
- 정렬 - -
-
-
-
-
- ))} -
- -
-
-
+ {/* ─── 공정 테이블 컬럼 ─── */} + update({ processColumns: cols })} + tableName={config.dataSource.routingDetailTable} + title="공정 테이블 컬럼" + icon={} + /> - {/* ─── 3단계: 데이터 소스 (Collapsible) ─── */} + {/* ─── 데이터 소스 ─── */} -
품목 테이블 - updateDataSource("itemTable", v)} - tables={tables} - loading={loadingTables} - /> + updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
품목명 컬럼 - updateDataSource("itemNameColumn", v)} - tableName={config.dataSource.itemTable} - placeholder="품목명" - /> + updateDataSource("itemNameColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목명" />
품목코드 컬럼 - updateDataSource("itemCodeColumn", v)} - tableName={config.dataSource.itemTable} - placeholder="품목코드" - /> + updateDataSource("itemCodeColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목코드" />
라우팅 버전 테이블 - updateDataSource("routingVersionTable", v)} - tables={tables} - loading={loadingTables} - /> + updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
품목 FK 컬럼 - updateDataSource("routingVersionFkColumn", v)} - tableName={config.dataSource.routingVersionTable} - placeholder="FK 컬럼" - /> + updateDataSource("routingVersionFkColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="FK 컬럼" />
버전명 컬럼 - updateDataSource("routingVersionNameColumn", v)} - tableName={config.dataSource.routingVersionTable} - placeholder="버전명" - /> + updateDataSource("routingVersionNameColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="버전명" />
라우팅 상세 테이블 - updateDataSource("routingDetailTable", v)} - tables={tables} - loading={loadingTables} - /> + updateDataSource("routingDetailTable", v)} tables={tables} loading={loadingTables} />
버전 FK 컬럼 - updateDataSource("routingDetailFkColumn", v)} - tableName={config.dataSource.routingDetailTable} - placeholder="FK 컬럼" - /> + updateDataSource("routingDetailFkColumn", v)} tableName={config.dataSource.routingDetailTable} placeholder="FK 컬럼" />
공정 마스터 테이블 - updateDataSource("processTable", v)} - tables={tables} - loading={loadingTables} - /> + updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
공정명 컬럼 - updateDataSource("processNameColumn", v)} - tableName={config.dataSource.processTable} - placeholder="공정명" - /> + updateDataSource("processNameColumn", v)} tableName={config.dataSource.processTable} placeholder="공정명" />
공정코드 컬럼 - updateDataSource("processCodeColumn", v)} - tableName={config.dataSource.processTable} - placeholder="공정코드" - /> + updateDataSource("processCodeColumn", v)} tableName={config.dataSource.processTable} placeholder="공정코드" />
- {/* ─── 4단계: 레이아웃 & 기타 (Collapsible) ─── */} + {/* ─── 레이아웃 & 기타 ─── */} - @@ -673,76 +565,38 @@ export const V2ItemRoutingConfigPanel: React.FC = 좌측 패널 비율 (%)

품목 목록 패널의 너비

- update({ splitRatio: parseInt(e.target.value) || 40 })} - className="h-7 w-[80px] text-xs" - /> + update({ splitRatio: parseInt(e.target.value) || 40 })} className="h-7 w-[80px] text-xs" />
-
좌측 패널 제목 - update({ leftPanelTitle: e.target.value })} - placeholder="품목 목록" - className="h-7 w-[140px] text-xs" - /> + update({ leftPanelTitle: e.target.value })} placeholder="품목 목록" className="h-7 w-[140px] text-xs" />
-
우측 패널 제목 - update({ rightPanelTitle: e.target.value })} - placeholder="공정 순서" - className="h-7 w-[140px] text-xs" - /> + update({ rightPanelTitle: e.target.value })} placeholder="공정 순서" className="h-7 w-[140px] text-xs" />
-
버전 추가 버튼 텍스트 - update({ versionAddButtonText: e.target.value })} - placeholder="+ 라우팅 버전 추가" - className="h-7 w-[140px] text-xs" - /> + update({ versionAddButtonText: e.target.value })} placeholder="+ 라우팅 버전 추가" className="h-7 w-[140px] text-xs" />
-
공정 추가 버튼 텍스트 - update({ processAddButtonText: e.target.value })} - placeholder="+ 공정 추가" - className="h-7 w-[140px] text-xs" - /> + update({ processAddButtonText: e.target.value })} placeholder="+ 공정 추가" className="h-7 w-[140px] text-xs" />
-

첫 번째 버전 자동 선택

품목 선택 시 첫 버전을 자동으로 선택해요

- update({ autoSelectFirstVersion: checked })} - /> + update({ autoSelectFirstVersion: checked })} />
-

읽기 전용

추가/수정/삭제 버튼을 숨겨요

- update({ readonly: checked })} - /> + update({ readonly: checked })} />
@@ -752,5 +606,4 @@ export const V2ItemRoutingConfigPanel: React.FC = }; V2ItemRoutingConfigPanel.displayName = "V2ItemRoutingConfigPanel"; - export default V2ItemRoutingConfigPanel; diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index 4f5e216b..4266e8e0 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -38,37 +38,22 @@ import { Columns3, MousePointerClick, } from "lucide-react"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { tableTypeApi } from "@/lib/api/screen"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule"; import { NumberingRuleConfig } from "@/types/numbering-rule"; import { cn } from "@/lib/utils"; -import { - V2RepeaterConfig, - RepeaterColumnConfig, - DEFAULT_REPEATER_CONFIG, -} from "@/types/v2-repeater"; +import { V2RepeaterConfig, RepeaterColumnConfig, DEFAULT_REPEATER_CONFIG } from "@/types/v2-repeater"; // 테이블 엔티티 관계 정보 interface TableRelation { tableName: string; tableLabel: string; - foreignKeyColumn: string; // 저장 테이블의 FK 컬럼 - referenceColumn: string; // 마스터 테이블의 PK 컬럼 + foreignKeyColumn: string; // 저장 테이블의 FK 컬럼 + referenceColumn: string; // 마스터 테이블의 PK 컬럼 } interface V2RepeaterConfigPanelProps { @@ -116,7 +101,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ menuObjid, }) => { const currentTableName = screenTableName || propCurrentTableName; - + // config 안전하게 초기화 const config: V2RepeaterConfig = useMemo(() => { const merged = { @@ -144,20 +129,18 @@ export const V2RepeaterConfigPanel: React.FC = ({ const [currentTableColumns, setCurrentTableColumns] = useState([]); // 현재 테이블 컬럼 const [entityColumns, setEntityColumns] = useState([]); // 엔티티 타입 컬럼 const [sourceTableColumns, setSourceTableColumns] = useState([]); // 소스(엔티티) 테이블 컬럼 - const [calculationRules, setCalculationRules] = useState( - config.calculationRules || [] - ); + const [calculationRules, setCalculationRules] = useState(config.calculationRules || []); const [loadingColumns, setLoadingColumns] = useState(false); const [loadingSourceColumns, setLoadingSourceColumns] = useState(false); - + // 저장 테이블 관련 상태 const [allTables, setAllTables] = useState>([]); const [loadingTables, setLoadingTables] = useState(false); const [relatedTables, setRelatedTables] = useState([]); // 현재 테이블과 연관된 테이블 목록 const [loadingRelations, setLoadingRelations] = useState(false); const [tableComboboxOpen, setTableComboboxOpen] = useState(false); // 테이블 Combobox 열림 상태 - + // Entity 조인 관련 상태 const [entityJoinData, setEntityJoinData] = useState<{ joinTables: Array<{ @@ -182,7 +165,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ // 🆕 확장된 컬럼 (상세 설정 표시용) const [expandedColumn, setExpandedColumn] = useState(null); - + // Collapsible 상태 const [featureOptionsOpen, setFeatureOptionsOpen] = useState(true); const [columnSelectOpen, setColumnSelectOpen] = useState(true); @@ -190,22 +173,24 @@ export const V2RepeaterConfigPanel: React.FC = ({ const [calcRulesOpen, setCalcRulesOpen] = useState(false); const [entityJoinSubOpen, setEntityJoinSubOpen] = useState>({}); const [configuredJoinsOpen, setConfiguredJoinsOpen] = useState(false); - + // 🆕 채번 규칙 목록 const [numberingRules, setNumberingRules] = useState([]); const [loadingNumberingRules, setLoadingNumberingRules] = useState(false); - + // 🆕 대상 메뉴 목록 (채번 규칙 선택용) const [parentMenus, setParentMenus] = useState([]); const [loadingMenus, setLoadingMenus] = useState(false); - + // 🆕 선택된 메뉴 OBJID (컬럼별로 저장, 한 번 선택하면 공유) const [selectedMenuObjid, setSelectedMenuObjid] = useState(() => { // 기존 config에서 저장된 값이 있으면 복원 - const existingAutoFill = config.columns.find(c => c.autoFill?.type === "numbering" && c.autoFill.selectedMenuObjid); + const existingAutoFill = config.columns.find( + (c) => c.autoFill?.type === "numbering" && c.autoFill.selectedMenuObjid, + ); return existingAutoFill?.autoFill?.selectedMenuObjid || (menuObjid ? Number(menuObjid) : undefined); }); - + // 자동 입력 타입 옵션 const autoFillOptions = [ { value: "none", label: "없음" }, @@ -217,7 +202,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ { value: "fixed", label: "고정값" }, { value: "parentSequence", label: "부모채번+순번 (예: WO-001-01)" }, ]; - + // 🆕 대상 메뉴 목록 로드 (사용자 메뉴의 레벨 2) useEffect(() => { const loadMenus = async () => { @@ -225,15 +210,13 @@ export const V2RepeaterConfigPanel: React.FC = ({ try { const { apiClient } = await import("@/lib/api/client"); const response = await apiClient.get("/admin/menus"); - + if (response.data.success && response.data.data) { const allMenus = response.data.data; - + // 사용자 메뉴(menu_type='1')의 레벨 2만 필터링 - const level2UserMenus = allMenus.filter((menu: any) => - menu.menu_type === '1' && menu.lev === 2 - ); - + const level2UserMenus = allMenus.filter((menu: any) => menu.menu_type === "1" && menu.lev === 2); + setParentMenus(level2UserMenus); } } catch (error) { @@ -244,7 +227,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ }; loadMenus(); }, []); - + // 🆕 채번 규칙 로드 (선택된 메뉴 기준) useEffect(() => { const loadNumberingRules = async () => { @@ -253,11 +236,11 @@ export const V2RepeaterConfigPanel: React.FC = ({ setNumberingRules([]); return; } - + setLoadingNumberingRules(true); try { const result = await getAvailableNumberingRules(selectedMenuObjid); - + if (result?.success && result.data) { setNumberingRules(result.data); } @@ -278,10 +261,12 @@ export const V2RepeaterConfigPanel: React.FC = ({ try { const response = await tableManagementApi.getTableList(); if (response.success && response.data) { - setAllTables(response.data.map((t: any) => ({ - tableName: t.tableName || t.table_name, - displayName: t.displayName || t.table_label || t.tableName || t.table_name, - }))); + setAllTables( + response.data.map((t: any) => ({ + tableName: t.tableName || t.table_name, + displayName: t.displayName || t.table_label || t.tableName || t.table_name, + })), + ); } } catch (error) { console.error("테이블 목록 로드 실패:", error); @@ -308,7 +293,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ try { const { apiClient } = await import("@/lib/api/client"); const allRelations: TableRelation[] = []; - + // 1. 화면 메인 테이블을 참조하는 테이블 조회 (자식 테이블) if (currentTableName) { const response = await apiClient.get(`/table-management/columns/${currentTableName}/referenced-by`); @@ -322,7 +307,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ allRelations.push(...relations); } } - + // 2. 저장 테이블이 화면 메인 테이블과 다르면, 저장 테이블을 참조하는 테이블도 조회 if (config.mainTableName && config.mainTableName !== currentTableName) { const response2 = await apiClient.get(`/table-management/columns/${config.mainTableName}/referenced-by`); @@ -334,14 +319,14 @@ export const V2RepeaterConfigPanel: React.FC = ({ referenceColumn: rel.referenceColumn || rel.reference_column || "id", })); // 중복 제거 후 추가 - relations2.forEach(rel => { - if (!allRelations.some(r => r.tableName === rel.tableName)) { + relations2.forEach((rel) => { + if (!allRelations.some((r) => r.tableName === rel.tableName)) { allRelations.push(rel); } }); } } - + setRelatedTables(allRelations); } catch (error) { console.error("연관 테이블 로드 실패:", error); @@ -354,9 +339,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ }, [currentTableName, config.mainTableName]); // Entity 조인 컬럼 정보 로드 (저장 테이블 기준) - const entityJoinTargetTable = config.useCustomTable && config.mainTableName - ? config.mainTableName - : currentTableName; + const entityJoinTargetTable = config.useCustomTable && config.mainTableName ? config.mainTableName : currentTableName; useEffect(() => { const fetchEntityJoinColumns = async () => { @@ -388,12 +371,22 @@ export const V2RepeaterConfigPanel: React.FC = ({ // Entity 조인 컬럼 토글 (추가/제거) const toggleEntityJoinColumn = useCallback( - (joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => { + ( + joinTableName: string, + sourceColumn: string, + refColumnName: string, + refColumnLabel: string, + displayField: string, + columnType?: string, + ) => { const currentJoins = config.entityJoins || []; const existingJoinIdx = currentJoins.findIndex( (j) => j.sourceColumn === sourceColumn && j.referenceTable === joinTableName, ); + let newEntityJoins = [...currentJoins]; + let newColumns = [...config.columns]; + if (existingJoinIdx >= 0) { const existingJoin = currentJoins[existingJoinIdx]; const existingColIdx = existingJoin.columns.findIndex((c) => c.referenceField === refColumnName); @@ -401,34 +394,49 @@ export const V2RepeaterConfigPanel: React.FC = ({ if (existingColIdx >= 0) { const updatedColumns = existingJoin.columns.filter((_, i) => i !== existingColIdx); if (updatedColumns.length === 0) { - updateConfig({ entityJoins: currentJoins.filter((_, i) => i !== existingJoinIdx) }); + newEntityJoins = newEntityJoins.filter((_, i) => i !== existingJoinIdx); } else { - const updated = [...currentJoins]; - updated[existingJoinIdx] = { ...existingJoin, columns: updatedColumns }; - updateConfig({ entityJoins: updated }); + newEntityJoins[existingJoinIdx] = { ...existingJoin, columns: updatedColumns }; } + // config.columns에서도 제거 + newColumns = newColumns.filter((c) => !(c.key === displayField && c.isJoinColumn)); } else { - const updated = [...currentJoins]; - updated[existingJoinIdx] = { + newEntityJoins[existingJoinIdx] = { ...existingJoin, columns: [...existingJoin.columns, { referenceField: refColumnName, displayField }], }; - updateConfig({ entityJoins: updated }); + // config.columns에 추가 + newColumns.push({ + key: displayField, + title: refColumnLabel, + width: "auto", + visible: true, + editable: false, + isJoinColumn: true, + inputType: columnType || "text", + }); } } else { - updateConfig({ - entityJoins: [ - ...currentJoins, - { - sourceColumn, - referenceTable: joinTableName, - columns: [{ referenceField: refColumnName, displayField }], - }, - ], + newEntityJoins.push({ + sourceColumn, + referenceTable: joinTableName, + columns: [{ referenceField: refColumnName, displayField }], + }); + // config.columns에 추가 + newColumns.push({ + key: displayField, + title: refColumnLabel, + width: "auto", + visible: true, + editable: false, + isJoinColumn: true, + inputType: columnType || "text", }); } + + updateConfig({ entityJoins: newEntityJoins, columns: newColumns }); }, - [config.entityJoins, updateConfig], + [config.entityJoins, config.columns, updateConfig], ); // Entity 조인에 특정 컬럼이 설정되어 있는지 확인 @@ -472,45 +480,46 @@ export const V2RepeaterConfigPanel: React.FC = ({ ); // 저장 테이블 선택 핸들러 - 엔티티 관계에서 FK/PK 자동 설정 - const handleSaveTableSelect = useCallback((tableName: string) => { - // 빈 값 선택 시 (현재 테이블로 복원) - if (!tableName || tableName === currentTableName) { - updateConfig({ - useCustomTable: false, - mainTableName: undefined, - foreignKeyColumn: undefined, - foreignKeySourceColumn: undefined, - }); - return; - } + const handleSaveTableSelect = useCallback( + (tableName: string) => { + // 빈 값 선택 시 (현재 테이블로 복원) + if (!tableName || tableName === currentTableName) { + updateConfig({ + useCustomTable: false, + mainTableName: undefined, + foreignKeyColumn: undefined, + foreignKeySourceColumn: undefined, + }); + return; + } - // 연관 테이블에서 FK 관계 찾기 - const relation = relatedTables.find(r => r.tableName === tableName); - - if (relation) { - // 엔티티 관계가 있으면 자동으로 FK/PK 설정 - updateConfig({ - useCustomTable: true, - mainTableName: tableName, - foreignKeyColumn: relation.foreignKeyColumn, - foreignKeySourceColumn: relation.referenceColumn, - }); - } else { - // 엔티티 관계가 없으면 직접 입력 필요 - updateConfig({ - useCustomTable: true, - mainTableName: tableName, - foreignKeyColumn: undefined, - foreignKeySourceColumn: "id", - }); - } - }, [currentTableName, relatedTables, updateConfig]); + // 연관 테이블에서 FK 관계 찾기 + const relation = relatedTables.find((r) => r.tableName === tableName); + + if (relation) { + // 엔티티 관계가 있으면 자동으로 FK/PK 설정 + updateConfig({ + useCustomTable: true, + mainTableName: tableName, + foreignKeyColumn: relation.foreignKeyColumn, + foreignKeySourceColumn: relation.referenceColumn, + }); + } else { + // 엔티티 관계가 없으면 직접 입력 필요 + updateConfig({ + useCustomTable: true, + mainTableName: tableName, + foreignKeyColumn: undefined, + foreignKeySourceColumn: "id", + }); + } + }, + [currentTableName, relatedTables, updateConfig], + ); // 저장 테이블 컬럼 로드 (저장 테이블이 설정되면 해당 테이블, 아니면 현재 화면 테이블) // 실제 저장할 테이블의 컬럼을 보여줘야 함 - const targetTableForColumns = config.useCustomTable && config.mainTableName - ? config.mainTableName - : currentTableName; + const targetTableForColumns = config.useCustomTable && config.mainTableName ? config.mainTableName : currentTableName; useEffect(() => { const loadCurrentTableColumns = async () => { @@ -531,9 +540,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ let detailSettings: any = null; if (c.detailSettings) { try { - detailSettings = typeof c.detailSettings === "string" - ? JSON.parse(c.detailSettings) - : c.detailSettings; + detailSettings = typeof c.detailSettings === "string" ? JSON.parse(c.detailSettings) : c.detailSettings; } catch (e) { console.warn("detailSettings 파싱 실패:", c.detailSettings); } @@ -543,13 +550,15 @@ export const V2RepeaterConfigPanel: React.FC = ({ columnName: c.columnName || c.column_name, displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, inputType: c.inputType || c.input_type, - detailSettings: detailSettings ? { - codeGroup: detailSettings.codeGroup, - referenceTable: detailSettings.referenceTable, - referenceColumn: detailSettings.referenceColumn, - displayColumn: detailSettings.displayColumn, - format: detailSettings.format, - } : undefined, + detailSettings: detailSettings + ? { + codeGroup: detailSettings.codeGroup, + referenceTable: detailSettings.referenceTable, + referenceColumn: detailSettings.referenceColumn, + displayColumn: detailSettings.displayColumn, + format: detailSettings.format, + } + : undefined, }; cols.push(col); @@ -558,7 +567,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ const referenceTable = detailSettings?.referenceTable || c.referenceTable; const referenceColumn = detailSettings?.referenceColumn || c.referenceColumn || "id"; const displayColumn = detailSettings?.displayColumn || c.displayColumn; - + if (referenceTable) { entityCols.push({ columnName: col.columnName, @@ -609,7 +618,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ setLoadingSourceColumns(false); } }; - + if (config.renderMode === "modal") { loadSourceTableColumns(); } @@ -617,9 +626,11 @@ export const V2RepeaterConfigPanel: React.FC = ({ // 컬럼 토글 (현재 테이블 컬럼 - 입력용) const toggleInputColumn = (column: ColumnOption) => { - const existingIndex = config.columns.findIndex((c) => c.key === column.columnName); + const existingIndex = config.columns.findIndex( + (c) => c.key === column.columnName && !c.isJoinColumn && !c.isSourceDisplay, + ); if (existingIndex >= 0) { - const newColumns = config.columns.filter((c) => c.key !== column.columnName); + const newColumns = config.columns.filter((_, i) => i !== existingIndex); updateConfig({ columns: newColumns }); } else { // 컬럼의 inputType과 detailSettings 정보 포함 @@ -630,13 +641,15 @@ export const V2RepeaterConfigPanel: React.FC = ({ visible: true, editable: true, inputType: column.inputType || "text", - detailSettings: column.detailSettings ? { - codeGroup: column.detailSettings.codeGroup, - referenceTable: column.detailSettings.referenceTable, - referenceColumn: column.detailSettings.referenceColumn, - displayColumn: column.detailSettings.displayColumn, - format: column.detailSettings.format, - } : undefined, + detailSettings: column.detailSettings + ? { + codeGroup: column.detailSettings.codeGroup, + referenceTable: column.detailSettings.referenceTable, + referenceColumn: column.detailSettings.referenceColumn, + displayColumn: column.detailSettings.displayColumn, + format: column.detailSettings.format, + } + : undefined, }; updateConfig({ columns: [...config.columns, newColumn] }); } @@ -645,7 +658,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ // 🆕 소스 컬럼 토글 - columns 배열에 isSourceDisplay: true로 추가 const toggleSourceDisplayColumn = (column: ColumnOption) => { const exists = config.columns.some((c) => c.key === column.columnName && c.isSourceDisplay); - + if (exists) { // 제거 updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName) }); @@ -664,7 +677,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ }; const isColumnAdded = (columnName: string) => { - return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay); + return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay && !c.isJoinColumn); }; const isSourceColumnSelected = (columnName: string) => { @@ -685,28 +698,23 @@ export const V2RepeaterConfigPanel: React.FC = ({ // 계산 규칙 추가 const addCalculationRule = () => { - const newRules = [ - ...calculationRules, - { id: `calc_${Date.now()}`, targetColumn: "", formula: "" } - ]; + const newRules = [...calculationRules, { id: `calc_${Date.now()}`, targetColumn: "", formula: "" }]; syncCalculationRules(newRules); }; // 계산 규칙 삭제 const removeCalculationRule = (id: string) => { - syncCalculationRules(calculationRules.filter(r => r.id !== id)); + syncCalculationRules(calculationRules.filter((r) => r.id !== id)); }; // 계산 규칙 업데이트 const updateCalculationRule = (id: string, field: keyof CalculationRule, value: string) => { - syncCalculationRules( - calculationRules.map(r => r.id === id ? { ...r, [field]: value } : r) - ); + syncCalculationRules(calculationRules.map((r) => (r.id === id ? { ...r, [field]: value } : r))); }; // 수식 입력 필드에 컬럼명 삽입 const insertColumnToFormula = (ruleId: string, columnKey: string) => { - const rule = calculationRules.find(r => r.id === ruleId); + const rule = calculationRules.find((r) => r.id === ruleId); if (!rule) return; const newFormula = rule.formula ? `${rule.formula} ${columnKey}` : columnKey; updateCalculationRule(ruleId, "formula", newFormula); @@ -729,15 +737,15 @@ export const V2RepeaterConfigPanel: React.FC = ({ // 엔티티 컬럼 선택 시 소스 테이블 자동 설정 const handleEntityColumnSelect = (columnName: string) => { - const selectedEntity = entityColumns.find(c => c.columnName === columnName); - + const selectedEntity = entityColumns.find((c) => c.columnName === columnName); + if (selectedEntity) { console.log("엔티티 컬럼 선택:", selectedEntity); - + // 소스 테이블 컬럼에서 라벨 정보 찾기 - const displayColInfo = sourceTableColumns.find(c => c.columnName === selectedEntity.displayColumn); + const displayColInfo = sourceTableColumns.find((c) => c.columnName === selectedEntity.displayColumn); const displayLabel = displayColInfo?.displayName || selectedEntity.displayColumn || ""; - + updateConfig({ dataSource: { ...config.dataSource, @@ -749,8 +757,8 @@ export const V2RepeaterConfigPanel: React.FC = ({ modal: { ...config.modal, searchFields: selectedEntity.displayColumn ? [selectedEntity.displayColumn] : [], - sourceDisplayColumns: selectedEntity.displayColumn - ? [{ key: selectedEntity.displayColumn, label: displayLabel }] + sourceDisplayColumns: selectedEntity.displayColumn + ? [{ key: selectedEntity.displayColumn, label: displayLabel }] : [], } as V2RepeaterConfig["modal"], }); @@ -764,19 +772,23 @@ export const V2RepeaterConfigPanel: React.FC = ({ // 엔티티 컬럼 제외한 입력 가능 컬럼 (FK 컬럼 제외) const inputableColumns = useMemo(() => { const fkColumn = config.dataSource?.foreignKey; - return currentTableColumns.filter(col => - col.columnName !== fkColumn && // FK 컬럼 제외 - col.inputType !== "entity" // 다른 엔티티 컬럼도 제외 (필요시) + return currentTableColumns.filter( + (col) => + col.columnName !== fkColumn && // FK 컬럼 제외 + col.inputType !== "entity", // 다른 엔티티 컬럼도 제외 (필요시) ); }, [currentTableColumns, config.dataSource?.foreignKey]); return (
- - 기본 - 컬럼 - Entity 조인 + + + 기본 + + + 컬럼 + {/* 기본 설정 탭 */} @@ -788,7 +800,12 @@ export const V2RepeaterConfigPanel: React.FC = ({ {[ { value: "inline", icon: Rows3, title: "직접 입력", description: "테이블 컬럼에 바로 입력해요" }, { value: "modal", icon: Columns3, title: "모달 선택", description: "엔티티를 검색해서 추가해요" }, - { value: "button", icon: MousePointerClick, title: "버튼 연결", description: "버튼으로 관련 화면을 열어요" }, + { + value: "button", + icon: MousePointerClick, + title: "버튼 연결", + description: "버튼으로 관련 화면을 열어요", + }, ].map((card) => { const Icon = card.icon; const isSelected = config.renderMode === card.value; @@ -822,15 +839,15 @@ export const V2RepeaterConfigPanel: React.FC = ({ } }} className={cn( - "flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]", + "flex min-h-[80px] flex-col items-center justify-center rounded-lg border p-3 text-center transition-all", isSelected - ? "border-primary bg-primary/5 ring-1 ring-primary/20" - : "border-border hover:border-primary/50 hover:bg-muted/50" + ? "border-primary bg-primary/5 ring-primary/20 ring-1" + : "border-border hover:border-primary/50 hover:bg-muted/50", )} > - - {card.title} - {card.description} + + {card.title} + {card.description} ); })} @@ -838,50 +855,51 @@ export const V2RepeaterConfigPanel: React.FC = ({
{/* 저장 대상 테이블 */} -
+
- + 데이터를 어디에 저장하나요?
- + {/* 현재 선택된 테이블 표시 (기존 테이블 UI와 동일한 스타일) */} -
+
- +
-

+

{config.useCustomTable && config.mainTableName - ? (allTables.find(t => t.tableName === config.mainTableName)?.displayName || config.mainTableName) - : (currentTableName || "미설정") - } + ? allTables.find((t) => t.tableName === config.mainTableName)?.displayName || config.mainTableName + : currentTableName || "미설정"}

{config.useCustomTable && config.mainTableName && config.foreignKeyColumn && ( -

+

FK: {config.foreignKeyColumn} → {currentTableName}.{config.foreignKeySourceColumn || "id"}

)} {!config.useCustomTable && currentTableName && ( -

화면 메인 테이블

+

화면 메인 테이블

)}
- + {/* 테이블 변경 Combobox */} @@ -896,18 +914,12 @@ export const V2RepeaterConfigPanel: React.FC = ({ - + - - 테이블을 찾을 수 없습니다. - - + 테이블을 찾을 수 없습니다. + {/* 현재 테이블 (기본) */} {currentTableName && ( @@ -922,16 +934,16 @@ export const V2RepeaterConfigPanel: React.FC = ({ - + {currentTableName} - (기본) + (기본) )} - + {/* 연관 테이블 (엔티티 관계) */} {relatedTables.length > 0 && ( @@ -948,23 +960,24 @@ export const V2RepeaterConfigPanel: React.FC = ({ {rel.tableLabel} - - ({rel.foreignKeyColumn}) - + ({rel.foreignKeyColumn}) ))} )} - + {/* 전체 테이블 목록 */} {allTables - .filter(t => t.tableName !== currentTableName && !relatedTables.some(r => r.tableName === t.tableName)) + .filter( + (t) => + t.tableName !== currentTableName && !relatedTables.some((r) => r.tableName === t.tableName), + ) .map((table) => ( = ({ - + {table.displayName} - )) - } + ))} - + {/* FK 직접 입력 - 화면 메인 테이블이 있고 연관 테이블이 아닌 경우만 표시 */} {/* 화면 메인 테이블이 없으면 FK 설정 불필요 (독립 저장) */} - {config.useCustomTable && config.mainTableName && currentTableName && - !relatedTables.some(r => r.tableName === config.mainTableName) && ( -
-

- 화면 테이블({currentTableName})과의 엔티티 관계가 없습니다. FK 컬럼을 직접 입력하세요. -

-
-
- - updateConfig({ foreignKeyColumn: e.target.value })} - placeholder="예: master_id" - className="h-7 text-xs" - /> -
-
- - updateConfig({ foreignKeySourceColumn: e.target.value })} - placeholder="id" - className="h-7 text-xs" - /> + {config.useCustomTable && + config.mainTableName && + currentTableName && + !relatedTables.some((r) => r.tableName === config.mainTableName) && ( +
+

+ 화면 테이블({currentTableName})과의 엔티티 관계가 없습니다. FK 컬럼을 직접 입력하세요. +

+
+
+ + updateConfig({ foreignKeyColumn: e.target.value })} + placeholder="예: master_id" + className="h-7 text-xs" + /> +
+
+ + updateConfig({ foreignKeySourceColumn: e.target.value })} + placeholder="id" + className="h-7 text-xs" + /> +
-
- )} - + )} + {/* 화면 메인 테이블이 없을 때 안내 */} {config.useCustomTable && config.mainTableName && !currentTableName && ( -
-

- 독립 저장 모드: 화면 테이블 없이 직접 저장해요 -

+
+

독립 저장 모드: 화면 테이블 없이 직접 저장해요

)} {/* 현재 화면 정보 (메인 테이블이 설정된 경우에만 표시) */} {currentTableName && ( -
-

화면 메인 테이블

+
+

화면 메인 테이블

{currentTableName}

-

+

컬럼 {currentTableColumns.length}개 / 엔티티 {entityColumns.length}개

@@ -1047,15 +1059,13 @@ export const V2RepeaterConfigPanel: React.FC = ({ {/* 모달 모드: 엔티티 컬럼 선택 */} {isModalMode && ( <> -
+
- + 어떤 엔티티를 검색하나요?
-

- 모달에서 검색할 엔티티를 선택하면 FK만 저장돼요 -

- +

모달에서 검색할 엔티티를 선택하면 FK만 저장돼요

+ {entityColumns.length > 0 ? ( ) : (
-

- {loadingColumns - ? "컬럼 정보를 불러오고 있어요..." - : !targetTableForColumns +

+ {loadingColumns + ? "컬럼 정보를 불러오고 있어요..." + : !targetTableForColumns ? "저장 테이블을 먼저 선택해주세요" : "엔티티 타입 컬럼이 없어요"}

)} - + {/* 선택된 엔티티 정보 */} {config.dataSource?.sourceTable && ( -
-

선택된 엔티티

+
+

선택된 엔티티

{config.dataSource.sourceTable}

-

+

{config.dataSource.foreignKey} 컬럼에 FK로 저장돼요

@@ -1103,11 +1113,11 @@ export const V2RepeaterConfigPanel: React.FC = ({
{/* 모달 표시 설정 */} -
+
모달 표시 설정
- + updateModal("title", e.target.value)} @@ -1116,7 +1126,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ />
- + updateModal("buttonText", e.target.value)} @@ -1134,22 +1144,29 @@ export const V2RepeaterConfigPanel: React.FC = ({ -
+

추가 버튼

-

새로운 행을 추가할 수 있어요

+

새로운 행을 추가할 수 있어요

= ({

삭제 버튼

-

선택한 행을 삭제할 수 있어요

+

선택한 행을 삭제할 수 있어요

= ({

인라인 편집

-

셀을 클릭하면 바로 수정할 수 있어요

+

셀을 클릭하면 바로 수정할 수 있어요

= ({

다중 선택

-

여러 행을 동시에 선택할 수 있어요

+

여러 행을 동시에 선택할 수 있어요

= ({

행 번호

-

각 행에 순번을 표시해요

+

각 행에 순번을 표시해요

= ({

행 선택

-

체크박스로 행을 선택할 수 있어요

+

체크박스로 행을 선택할 수 있어요

= ({ -
+
{/* 소스 디테일 자동 조회 */}

소스 디테일 자동 조회

-

마스터 데이터의 디테일 행을 자동으로 채워요

+

마스터 데이터의 디테일 행을 자동으로 채워요

= ({
{config.sourceDetailConfig && ( -
+
-

디테일 테이블

+

디테일 테이블

- @@ -1276,7 +1289,9 @@ export const V2RepeaterConfigPanel: React.FC = ({ - 테이블을 찾을 수 없습니다. + + 테이블을 찾을 수 없습니다. + {allTables.map((table) => ( = ({ }} className="text-xs" > - + {table.displayName} ))} @@ -1305,7 +1327,7 @@ export const V2RepeaterConfigPanel: React.FC = ({
-

디테일 FK 컬럼

+

디테일 FK 컬럼

@@ -1321,7 +1343,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ />
-

마스터 키 컬럼

+

마스터 키 컬럼

@@ -1338,9 +1360,10 @@ export const V2RepeaterConfigPanel: React.FC = ({
-

- 마스터에서 [{config.sourceDetailConfig.parentKey || "?"}] 추출 → - {" "}{config.sourceDetailConfig.tableName || "?"}.{config.sourceDetailConfig.foreignKey || "?"} 로 조회해요 +

+ 마스터에서 [{config.sourceDetailConfig.parentKey || "?"}] 추출 →{" "} + {config.sourceDetailConfig.tableName || "?"}.{config.sourceDetailConfig.foreignKey || "?"} 로 + 조회해요

)} @@ -1356,33 +1379,35 @@ export const V2RepeaterConfigPanel: React.FC = ({ -
-

+

+

{isModalMode ? "소스 테이블 컬럼은 표시용, 저장 테이블 컬럼은 입력용이에요" - : "체크한 컬럼이 리피터에 입력 필드로 표시돼요" - } + : "체크한 컬럼이 리피터에 입력 필드로 표시돼요"}

- + {/* 모달 모드: 소스 테이블 컬럼 (표시용) */} {isModalMode && config.dataSource?.sourceTable && ( <> -
+
소스 테이블 ({config.dataSource.sourceTable}) - 표시용
@@ -1391,7 +1416,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ ) : sourceTableColumns.length === 0 ? (

컬럼 정보가 없습니다

) : ( -
+
{sourceTableColumns.map((column) => (
= ({ /> {column.displayName} - 표시 + 표시
))}
@@ -1417,16 +1442,14 @@ export const V2RepeaterConfigPanel: React.FC = ({ )} {/* 저장 테이블 컬럼 (입력용) */} -
+
저장 테이블 ({targetTableForColumns || "미선택"}) - 입력용
{loadingColumns ? (

로딩 중...

) : inputableColumns.length === 0 ? ( -

- 컬럼 정보가 없습니다 -

+

컬럼 정보가 없습니다

) : (
{inputableColumns.map((column) => ( @@ -1445,12 +1468,89 @@ export const V2RepeaterConfigPanel: React.FC = ({ /> {column.displayName} - {column.inputType} + {column.inputType}
))}
)}
+ + {/* ===== 🆕 Entity 조인 컬럼 (표시용) ===== */} +
+
+ + +
+

+ FK 컬럼을 기반으로 참조 테이블의 데이터를 자동으로 조회하여 표시합니다. +

+ + {loadingEntityJoins ? ( +

로딩 중...

+ ) : entityJoinData.joinTables.length === 0 ? ( +

+ {entityJoinTargetTable + ? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다` + : "저장 테이블을 먼저 설정해주세요"} +

+ ) : ( +
+ {entityJoinData.joinTables.map((joinTable, tableIndex) => { + const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || ""; + + return ( +
+
+ + {joinTable.tableName} + ({sourceColumn}) +
+
+ {joinTable.availableColumns.map((column, colIndex) => { + const isActive = isEntityJoinColumnActive( + joinTable.tableName, + sourceColumn, + column.columnName, + ); + const matchingCol = config.columns.find( + (c) => c.key === column.columnName && c.isJoinColumn, + ); + const displayField = matchingCol?.key || column.columnName; + + return ( +
+ toggleEntityJoinColumn( + joinTable.tableName, + sourceColumn, + column.columnName, + column.columnLabel, + displayField, + column.inputType || column.dataType, + ) + } + > + + + {column.columnLabel} + + {column.inputType || column.dataType} + +
+ ); + })} +
+
+ ); + })} +
+ )} +
@@ -1460,341 +1560,379 @@ export const V2RepeaterConfigPanel: React.FC = ({ -
-

드래그로 순서 변경, 클릭하여 상세 설정

-
- {config.columns.map((col, index) => ( -
- {/* 컬럼 헤더 (드래그 가능) */} -
{ - e.dataTransfer.setData("columnIndex", String(index)); - }} - onDragOver={(e) => e.preventDefault()} - onDrop={(e) => { - e.preventDefault(); - const fromIndex = parseInt(e.dataTransfer.getData("columnIndex"), 10); - if (fromIndex !== index) { - const newColumns = [...config.columns]; - const [movedCol] = newColumns.splice(fromIndex, 1); - newColumns.splice(index, 0, movedCol); - updateConfig({ columns: newColumns }); - } - }} - > - - - {/* 확장/축소 버튼 (입력 컬럼만) */} - {!col.isSourceDisplay && ( - - )} - - {col.isSourceDisplay ? ( - - ) : ( - - )} - - updateColumnProp(col.key, "title", e.target.value)} - placeholder="제목" - className="h-6 flex-1 text-xs" - /> - - {/* 히든 토글 (입력 컬럼만) */} - {!col.isSourceDisplay && ( - - )} - - {/* 자동입력 표시 아이콘 */} - {!col.isSourceDisplay && col.autoFill?.type && col.autoFill.type !== "none" && ( - - )} - - {/* 편집 가능 토글 */} - {!col.isSourceDisplay && ( - - )} - - -
- - {/* 확장된 상세 설정 (입력 컬럼만) */} - {!col.isSourceDisplay && expandedColumn === col.key && ( -
- {/* 자동 입력 설정 */} -
- - -
- - {/* 채번 규칙 선택 */} - {col.autoFill?.type === "numbering" && ( -
- {/* 대상 메뉴 선택 */} -
- - -

- 이 입력 필드가 어느 메뉴에 속할지 선택하세요 (해당 메뉴의 채번규칙이 적용됩니다) -

-
- - {/* 채번 규칙 선택 (메뉴 선택 후) */} - {selectedMenuObjid ? ( -
- - {loadingNumberingRules ? ( -

규칙 로딩 중...

- ) : numberingRules.length === 0 ? ( -
- 선택된 메뉴에 사용 가능한 채번 규칙이 없습니다 -
- ) : ( - - )} - {col.autoFill?.numberingRuleId && ( -

- 저장 시 채번 API를 통해 자동 생성됩니다. -

- )} -
- ) : ( -
- 먼저 대상 메뉴를 선택하세요 -
+ {expandedColumn === col.key ? ( + + ) : ( + + )} + + )} + + {col.isSourceDisplay ? ( + + ) : ( + + )} + + updateColumnProp(col.key, "title", e.target.value)} + placeholder="제목" + className="h-6 flex-1 text-xs" + /> + + {/* 히든 토글 (입력 컬럼만) */} + {!col.isSourceDisplay && !col.isJoinColumn && ( + + )} + + {/* 자동입력 표시 아이콘 */} + {!col.isSourceDisplay && + !col.isJoinColumn && + col.autoFill?.type && + col.autoFill.type !== "none" && ( + + )} + + {/* 편집 가능 토글 */} + {!col.isSourceDisplay && !col.isJoinColumn && ( + + )} + + +
+ + {/* 확장된 상세 설정 (입력 컬럼만) */} + {!col.isSourceDisplay && !col.isJoinColumn && expandedColumn === col.key && ( +
+ {/* 자동 입력 설정 */} +
+ + +
+ + {/* 채번 규칙 선택 */} + {col.autoFill?.type === "numbering" && ( +
+ {/* 대상 메뉴 선택 */} +
+ + +

+ 이 입력 필드가 어느 메뉴에 속할지 선택하세요 (해당 메뉴의 채번규칙이 적용됩니다) +

+
+ + {/* 채번 규칙 선택 (메뉴 선택 후) */} + {selectedMenuObjid ? ( +
+ + {loadingNumberingRules ? ( +

규칙 로딩 중...

+ ) : numberingRules.length === 0 ? ( +
+ 선택된 메뉴에 사용 가능한 채번 규칙이 없습니다 +
+ ) : ( + updateColumnProp(col.key, "autoFill", { - ...col.autoFill, - sourceField: e.target.value, - })} - placeholder="order_no" - className="h-6 text-xs" - /> -
- )} - - {/* 고정값 설정 */} - {col.autoFill?.type === "fixed" && ( -
- - updateColumnProp(col.key, "autoFill", { - ...col.autoFill, - fixedValue: e.target.value, - })} - placeholder="고정값 입력" - className="h-6 text-xs" - /> -
- )} - - {/* 부모채번+순번 설정 */} - {col.autoFill?.type === "parentSequence" && ( -
+ + ))} + + + )} + {col.autoFill?.numberingRuleId && ( +

+ 저장 시 채번 API를 통해 자동 생성됩니다. +

+ )} +
+ ) : ( +
+ 먼저 대상 메뉴를 선택하세요 +
+ )} +
+ )} + + {/* 메인 폼에서 복사 설정 */} + {col.autoFill?.type === "fromMainForm" && (
- + updateColumnProp(col.key, "autoFill", { - ...col.autoFill, - parentField: e.target.value, - })} - placeholder="work_order_no" + value={col.autoFill?.sourceField || ""} + onChange={(e) => + updateColumnProp(col.key, "autoFill", { + ...col.autoFill, + sourceField: e.target.value, + }) + } + placeholder="order_no" className="h-6 text-xs" /> -

메인 폼에서 가져올 부모 채번 필드

-
-
- - updateColumnProp(col.key, "autoFill", { + )} + + {/* 고정값 설정 */} + {col.autoFill?.type === "fixed" && ( +
+ + + updateColumnProp(col.key, "autoFill", { ...col.autoFill, - separator: e.target.value, - })} - placeholder="-" + fixedValue: e.target.value, + }) + } + placeholder="고정값 입력" + className="h-6 text-xs" + /> +
+ )} + + {/* 부모채번+순번 설정 */} + {col.autoFill?.type === "parentSequence" && ( +
+
+ + + updateColumnProp(col.key, "autoFill", { + ...col.autoFill, + parentField: e.target.value, + }) + } + placeholder="work_order_no" className="h-6 text-xs" /> +

+ 메인 폼에서 가져올 부모 채번 필드 +

-
- - updateColumnProp(col.key, "autoFill", { - ...col.autoFill, - sequenceLength: parseInt(e.target.value) || 2, - })} - className="h-6 text-xs" - /> +
+
+ + + updateColumnProp(col.key, "autoFill", { + ...col.autoFill, + separator: e.target.value, + }) + } + placeholder="-" + className="h-6 text-xs" + /> +
+
+ + + updateColumnProp(col.key, "autoFill", { + ...col.autoFill, + sequenceLength: parseInt(e.target.value) || 2, + }) + } + className="h-6 text-xs" + /> +
+

+ 예시: WO-20260223-005{col.autoFill?.separator ?? "-"} + {String(1).padStart(col.autoFill?.sequenceLength ?? 2, "0")} +

-

- 예시: WO-20260223-005{col.autoFill?.separator ?? "-"}{String(1).padStart(col.autoFill?.sequenceLength ?? 2, "0")} -

-
- )} -
- )} -
- ))} + )} +
+ )} +
+ ))} +
-
)} @@ -1805,127 +1943,143 @@ export const V2RepeaterConfigPanel: React.FC = ({ -
-
- -
- -
- {calculationRules.map((rule) => ( -
-
- updateCalculationRule(rule.id, "targetColumn", value)} + > + + + + + {config.columns + .filter((col) => !col.isSourceDisplay) + .map((col) => ( + + {col.title || col.key} + + ))} + + + = + updateCalculationRule(rule.id, "formula", e.target.value)} + placeholder="컬럼 클릭 또는 직접 입력" + className="h-6 flex-1 font-mono text-[10px]" + /> + +
+ + {/* 한글 수식 미리보기 */} + {rule.formula && ( +

+ {config.columns.find((c) => c.key === rule.targetColumn)?.title || + rule.targetColumn || + "결과"}{" "} + = {formulaToKorean(rule.formula)} +

+ )} + + {/* 컬럼 칩: 디테일 컬럼 + 소스(품목) 컬럼 + 연산자 */} +
+ {config.columns + .filter((col) => col.key !== rule.targetColumn && !col.isSourceDisplay) + .map((col) => ( + ))} - - - = - updateCalculationRule(rule.id, "formula", e.target.value)} - placeholder="컬럼 클릭 또는 직접 입력" - className="h-6 flex-1 font-mono text-[10px]" - /> - + {config.columns + .filter((col) => col.isSourceDisplay) + .map((col) => ( + + ))} + {["+", "-", "*", "/", "(", ")"].map((op) => ( + + ))} +
+ ))} - {/* 한글 수식 미리보기 */} - {rule.formula && ( -

- {config.columns.find(c => c.key === rule.targetColumn)?.title || rule.targetColumn || "결과"} = {formulaToKorean(rule.formula)} -

- )} - - {/* 컬럼 칩: 디테일 컬럼 + 소스(품목) 컬럼 + 연산자 */} -
- {config.columns - .filter(col => col.key !== rule.targetColumn && !col.isSourceDisplay) - .map((col) => ( - - ))} - {config.columns - .filter(col => col.isSourceDisplay) - .map((col) => ( - - ))} - {["+", "-", "*", "/", "(", ")"].map((op) => ( - - ))} + {calculationRules.length === 0 && ( +
+ +

아직 계산 규칙이 없어요

+

위의 추가 버튼으로 수식을 만들어보세요

-
- ))} - - {calculationRules.length === 0 && ( -
- -

아직 계산 규칙이 없어요

-

위의 추가 버튼으로 수식을 만들어보세요

-
- )} + )} +
-
)} @@ -1933,30 +2087,28 @@ export const V2RepeaterConfigPanel: React.FC = ({ {/* Entity 조인 설정 탭 */} -
+
- + 연결된 테이블 데이터 표시
-

+

FK 컬럼을 기반으로 참조 테이블의 데이터를 자동으로 가져와서 표시해요

{loadingEntityJoins ? (
-
+
조인 가능한 컬럼을 찾고 있어요...
) : entityJoinData.joinTables.length === 0 ? (
- -

- {entityJoinTargetTable - ? "조인 가능한 컬럼이 없어요" - : "저장 테이블을 먼저 설정해주세요"} + +

+ {entityJoinTargetTable ? "조인 가능한 컬럼이 없어요" : "저장 테이블을 먼저 설정해주세요"}

{entityJoinTargetTable && ( -

+

테이블 타입 관리에서 엔티티 관계를 설정해보세요

)} @@ -1965,30 +2117,41 @@ export const V2RepeaterConfigPanel: React.FC = ({
{entityJoinData.joinTables.map((joinTable, tableIndex) => { const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || ""; - const activeCount = joinTable.availableColumns.filter(col => - isEntityJoinColumnActive(joinTable.tableName, sourceColumn, col.columnName) + const activeCount = joinTable.availableColumns.filter((col) => + isEntityJoinColumnActive(joinTable.tableName, sourceColumn, col.columnName), ).length; const isSubOpen = entityJoinSubOpen[tableIndex] ?? false; return ( - setEntityJoinSubOpen((prev) => ({ ...prev, [tableIndex]: open }))}> + setEntityJoinSubOpen((prev) => ({ ...prev, [tableIndex]: open }))} + > -
+
{joinTable.availableColumns.map((column, colIndex) => { const isActive = isEntityJoinColumnActive( joinTable.tableName, @@ -2002,7 +2165,7 @@ export const V2RepeaterConfigPanel: React.FC = ({
@@ -2015,13 +2178,10 @@ export const V2RepeaterConfigPanel: React.FC = ({ ) } > - - + + {column.columnLabel} - + {column.inputType || column.dataType}
@@ -2041,27 +2201,35 @@ export const V2RepeaterConfigPanel: React.FC = ({
{config.entityJoins.map((join, idx) => ( -
- +
+ {join.sourceColumn} - + {join.referenceTable} - + ({join.columns.map((c) => c.referenceField).join(", ")}) @@ -2085,7 +2253,6 @@ export const V2RepeaterConfigPanel: React.FC = ({ )}
-
); diff --git a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx index a490d6b6..ae5679b6 100644 --- a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx @@ -157,10 +157,13 @@ function SortableColumnRow({ /> onWidthChange(parseInt(e.target.value) || 100)} - placeholder="너비" + onChange={(e) => onWidthChange(parseInt(e.target.value) || 20)} + placeholder="20" className="h-6 w-14 shrink-0 text-xs" + min={5} + max={100} /> + % {isNumeric && (
- + setEditingColumn({ ...editingColumn, - width: e.target.value ? parseInt(e.target.value) : undefined, + width: e.target.value ? Math.min(100, Math.max(5, parseInt(e.target.value) || 20)) : undefined, }) } placeholder="자동" className="mt-1 h-9" + min={5} + max={100} />
diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index f3ed2145..a36836a6 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -36,7 +36,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { apiClient } from "@/lib/api/client"; -import { getCategoryValues } from "@/lib/api/tableCategoryValue"; +import { getCategoryValues, getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue"; export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps { // 추가 props @@ -1354,6 +1354,40 @@ export const SplitPanelLayout2Component: React.FC { + if (isDesignMode) return; + const allData = [...leftData, ...rightData]; + if (allData.length === 0) return; + + const unresolvedCodes = new Set(); + const checkValue = (v: unknown) => { + if (typeof v === "string" && (v.startsWith("CAT_") || v.startsWith("CATEGORY_"))) { + if (!categoryLabelMap[v]) unresolvedCodes.add(v); + } + }; + for (const item of allData) { + for (const val of Object.values(item)) { + if (Array.isArray(val)) { + val.forEach(checkValue); + } else { + checkValue(val); + } + } + } + + if (unresolvedCodes.size === 0) return; + + const resolveMissingLabels = async () => { + const result = await getCategoryLabelsByCodes(Array.from(unresolvedCodes)); + if (result.success && result.data && Object.keys(result.data).length > 0) { + setCategoryLabelMap((prev) => ({ ...prev, ...result.data })); + } + }; + + resolveMissingLabels(); + }, [isDesignMode, leftData, rightData, categoryLabelMap]); + // 컴포넌트 언마운트 시 DataProvider 해제 useEffect(() => { return () => { @@ -1717,7 +1751,7 @@ export const SplitPanelLayout2Component: React.FC {displayColumns.map((col, idx) => ( - + {col.label || col.name} ))} @@ -1918,7 +1952,7 @@ export const SplitPanelLayout2Component: React.FC )} {displayColumns.map((col, idx) => ( - + {col.label || col.name} ))} diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx index 90c4f801..03997ce0 100644 --- a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -1,11 +1,53 @@ "use client"; -import React from "react"; +import React, { useEffect, useRef } from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { V2InputDefinition } from "./index"; import { V2Input } from "@/components/v2/V2Input"; import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer"; +/** + * dataBinding이 설정된 v2-input을 위한 wrapper + * v2-table-list의 선택 이벤트를 window CustomEvent로 수신하여 + * 선택된 행의 특정 컬럼 값을 자동으로 formData에 반영 + */ +function DataBindingWrapper({ + dataBinding, + columnName, + onFormDataChange, + children, +}: { + dataBinding: { sourceComponentId: string; sourceColumn: string }; + columnName: string; + onFormDataChange?: (field: string, value: any) => void; + children: React.ReactNode; +}) { + const lastBoundValueRef = useRef(null); + const onFormDataChangeRef = useRef(onFormDataChange); + onFormDataChangeRef.current = onFormDataChange; + + useEffect(() => { + if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return; + + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail || detail.source !== dataBinding.sourceComponentId) return; + + const selectedRow = detail.data?.[0]; + const value = selectedRow?.[dataBinding.sourceColumn] ?? ""; + if (value !== lastBoundValueRef.current) { + lastBoundValueRef.current = value; + onFormDataChangeRef.current?.(columnName, value); + } + }; + + window.addEventListener("v2-table-selection", handler); + return () => window.removeEventListener("v2-table-selection", handler); + }, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName]); + + return <>{children}; +} + /** * V2Input 렌더러 * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 @@ -16,41 +58,25 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer { render(): React.ReactElement { const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; - // 컴포넌트 설정 추출 const config = component.componentConfig || component.config || {}; const columnName = component.columnName; const tableName = component.tableName || this.props.tableName; - // formData에서 현재 값 가져오기 const currentValue = formData?.[columnName] ?? component.value ?? ""; - // 값 변경 핸들러 const handleChange = (value: any) => { - console.log("🔄 [V2InputRenderer] handleChange 호출:", { - columnName, - value, - isInteractive, - hasOnFormDataChange: !!onFormDataChange, - }); if (isInteractive && onFormDataChange && columnName) { onFormDataChange(columnName, value); - } else { - console.warn("⚠️ [V2InputRenderer] onFormDataChange 호출 스킵:", { - isInteractive, - hasOnFormDataChange: !!onFormDataChange, - columnName, - }); } }; - // 라벨: style.labelText 우선, 없으면 component.label 사용 - // 🔧 style.labelDisplay를 먼저 체크 (속성 패널에서 style 객체로 업데이트하므로) const style = component.style || {}; const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay; - // labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김) const effectiveLabel = labelDisplay === true ? style.labelText || component.label : undefined; - return ( + const dataBinding = config.dataBinding || (component as any).dataBinding || config.componentConfig?.dataBinding; + + const inputElement = ( ); + + // dataBinding이 있으면 wrapper로 감싸서 이벤트 구독 + if (dataBinding?.sourceComponentId && dataBinding?.sourceColumn) { + return ( + + {inputElement} + + ); + } + + return inputElement; } } diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx index a8f752f9..d0144d0b 100644 --- a/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx @@ -1,217 +1,203 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star } from "lucide-react"; +import React, { useState, useEffect, useCallback } from "react"; +import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { useToast } from "@/hooks/use-toast"; -import { ItemRoutingConfig, ItemRoutingComponentProps } from "./types"; -import { defaultConfig } from "./config"; +import { ItemRoutingComponentProps, ColumnDef } from "./types"; import { useItemRouting } from "./hooks/useItemRouting"; +const DEFAULT_ITEM_COLS: ColumnDef[] = [ + { name: "item_name", label: "품명" }, + { name: "item_code", label: "품번", width: 100 }, +]; + export function ItemRoutingComponent({ config: configProp, isPreview, + screenId, }: ItemRoutingComponentProps) { const { toast } = useToast(); + const resolvedConfig = React.useMemo(() => { + if (configProp?.itemListMode === "registered" && !configProp?.screenCode && screenId) { + return { ...configProp, screenCode: `screen_${screenId}` }; + } + return configProp; + }, [configProp, screenId]); + const { - config, - items, - versions, - details, - loading, - selectedItemCode, - selectedItemName, - selectedVersionId, - fetchItems, - selectItem, - selectVersion, - refreshVersions, - refreshDetails, - deleteDetail, - deleteVersion, - setDefaultVersion, - unsetDefaultVersion, - } = useItemRouting(configProp || {}); + config, items, allItems, versions, details, loading, + selectedItemCode, selectedItemName, selectedVersionId, isRegisteredMode, + fetchItems, fetchRegisteredItems, fetchAllItems, + registerItemsBatch, unregisterItem, + selectItem, selectVersion, refreshVersions, refreshDetails, + deleteDetail, deleteVersion, setDefaultVersion, unsetDefaultVersion, + } = useItemRouting(resolvedConfig || {}); const [searchText, setSearchText] = useState(""); const [deleteTarget, setDeleteTarget] = useState<{ - type: "version" | "detail"; - id: string; - name: string; + type: "version" | "detail"; id: string; name: string; } | null>(null); - // 초기 로딩 (마운트 시 1회만) + // 품목 추가 다이얼로그 + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [addSearchText, setAddSearchText] = useState(""); + const [selectedAddItems, setSelectedAddItems] = useState>(new Set()); + const [addLoading, setAddLoading] = useState(false); + + const itemDisplayCols = config.itemDisplayColumns?.length + ? config.itemDisplayColumns : DEFAULT_ITEM_COLS; + const modalDisplayCols = config.modalDisplayColumns?.length + ? config.modalDisplayColumns : DEFAULT_ITEM_COLS; + + // 초기 로딩 const mountedRef = React.useRef(false); useEffect(() => { if (!mountedRef.current) { mountedRef.current = true; - fetchItems(); + if (isRegisteredMode) fetchRegisteredItems(); + else fetchItems(); } - }, [fetchItems]); + }, [fetchItems, fetchRegisteredItems, isRegisteredMode]); - // 모달 저장 성공 감지 -> 데이터 새로고침 + // 모달 저장 성공 감지 const refreshVersionsRef = React.useRef(refreshVersions); const refreshDetailsRef = React.useRef(refreshDetails); refreshVersionsRef.current = refreshVersions; refreshDetailsRef.current = refreshDetails; - useEffect(() => { - const handleSaveSuccess = () => { - refreshVersionsRef.current(); - refreshDetailsRef.current(); - }; - window.addEventListener("saveSuccessInModal", handleSaveSuccess); - return () => { - window.removeEventListener("saveSuccessInModal", handleSaveSuccess); - }; + const h = () => { refreshVersionsRef.current(); refreshDetailsRef.current(); }; + window.addEventListener("saveSuccessInModal", h); + return () => window.removeEventListener("saveSuccessInModal", h); }, []); - // 품목 검색 + // 검색 const handleSearch = useCallback(() => { - fetchItems(searchText || undefined); - }, [fetchItems, searchText]); + if (isRegisteredMode) fetchRegisteredItems(searchText || undefined); + else fetchItems(searchText || undefined); + }, [fetchItems, fetchRegisteredItems, isRegisteredMode, searchText]); - const handleSearchKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") handleSearch(); + // ──── 품목 추가 모달 ──── + const handleOpenAddDialog = useCallback(() => { + setAddSearchText(""); setSelectedAddItems(new Set()); setAddDialogOpen(true); + fetchAllItems(); + }, [fetchAllItems]); + + const handleToggleAddItem = useCallback((itemId: string) => { + setSelectedAddItems((prev) => { + const next = new Set(prev); + next.has(itemId) ? next.delete(itemId) : next.add(itemId); + return next; + }); + }, []); + + const handleConfirmAdd = useCallback(async () => { + if (selectedAddItems.size === 0) return; + setAddLoading(true); + const itemList = allItems + .filter((item) => selectedAddItems.has(item.id)) + .map((item) => ({ + itemId: item.id, + itemCode: item.item_code || item[config.dataSource.itemCodeColumn] || "", + })); + const success = await registerItemsBatch(itemList); + setAddLoading(false); + if (success) { + toast({ title: `${itemList.length}개 품목이 등록되었습니다` }); + setAddDialogOpen(false); + } else { + toast({ title: "품목 등록 실패", variant: "destructive" }); + } + }, [selectedAddItems, allItems, config.dataSource.itemCodeColumn, registerItemsBatch, toast]); + + const handleUnregisterItem = useCallback( + async (registeredId: string, itemName: string) => { + const success = await unregisterItem(registeredId); + if (success) toast({ title: `${itemName} 등록 해제됨` }); + else toast({ title: "등록 해제 실패", variant: "destructive" }); }, - [handleSearch] + [unregisterItem, toast] ); - // 버전 추가 모달 + // ──── 기존 핸들러 ──── const handleAddVersion = useCallback(() => { - if (!selectedItemCode) { - toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" }); - return; - } - const screenId = config.modals.versionAddScreenId; - if (!screenId) return; - - window.dispatchEvent( - new CustomEvent("openScreenModal", { - detail: { - screenId, - urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable }, - splitPanelParentData: { - [config.dataSource.routingVersionFkColumn]: selectedItemCode, - }, - }, - }) - ); + if (!selectedItemCode) { toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" }); return; } + const sid = config.modals.versionAddScreenId; + if (!sid) return; + window.dispatchEvent(new CustomEvent("openScreenModal", { + detail: { screenId: sid, urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable }, + splitPanelParentData: { [config.dataSource.routingVersionFkColumn]: selectedItemCode } }, + })); }, [selectedItemCode, config, toast]); - // 공정 추가 모달 const handleAddProcess = useCallback(() => { - if (!selectedVersionId) { - toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" }); - return; - } - const screenId = config.modals.processAddScreenId; - if (!screenId) return; - - window.dispatchEvent( - new CustomEvent("openScreenModal", { - detail: { - screenId, - urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable }, - splitPanelParentData: { - [config.dataSource.routingDetailFkColumn]: selectedVersionId, - }, - }, - }) - ); + if (!selectedVersionId) { toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" }); return; } + const sid = config.modals.processAddScreenId; + if (!sid) return; + window.dispatchEvent(new CustomEvent("openScreenModal", { + detail: { screenId: sid, urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable }, + splitPanelParentData: { [config.dataSource.routingDetailFkColumn]: selectedVersionId } }, + })); }, [selectedVersionId, config, toast]); - // 공정 수정 모달 const handleEditProcess = useCallback( (detail: Record) => { - const screenId = config.modals.processEditScreenId; - if (!screenId) return; - - window.dispatchEvent( - new CustomEvent("openScreenModal", { - detail: { - screenId, - urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable }, - editData: detail, - }, - }) - ); - }, - [config] + const sid = config.modals.processEditScreenId; + if (!sid) return; + window.dispatchEvent(new CustomEvent("openScreenModal", { + detail: { screenId: sid, urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable }, editData: detail }, + })); + }, [config] ); - // 기본 버전 토글 const handleToggleDefault = useCallback( async (versionId: string, currentIsDefault: boolean) => { - let success: boolean; - if (currentIsDefault) { - success = await unsetDefaultVersion(versionId); - if (success) toast({ title: "기본 버전이 해제되었습니다" }); - } else { - success = await setDefaultVersion(versionId); - if (success) toast({ title: "기본 버전으로 설정되었습니다" }); - } - if (!success) { - toast({ title: "기본 버전 변경 실패", variant: "destructive" }); - } + const success = currentIsDefault ? await unsetDefaultVersion(versionId) : await setDefaultVersion(versionId); + if (success) toast({ title: currentIsDefault ? "기본 버전이 해제되었습니다" : "기본 버전으로 설정되었습니다" }); + else toast({ title: "기본 버전 변경 실패", variant: "destructive" }); }, [setDefaultVersion, unsetDefaultVersion, toast] ); - // 삭제 확인 const handleConfirmDelete = useCallback(async () => { if (!deleteTarget) return; - - let success = false; - if (deleteTarget.type === "version") { - success = await deleteVersion(deleteTarget.id); - } else { - success = await deleteDetail(deleteTarget.id); - } - - if (success) { - toast({ title: `${deleteTarget.name} 삭제 완료` }); - } else { - toast({ title: "삭제 실패", variant: "destructive" }); - } + const success = deleteTarget.type === "version" + ? await deleteVersion(deleteTarget.id) : await deleteDetail(deleteTarget.id); + toast({ title: success ? `${deleteTarget.name} 삭제 완료` : "삭제 실패", variant: success ? undefined : "destructive" }); setDeleteTarget(null); }, [deleteTarget, deleteVersion, deleteDetail, toast]); const splitRatio = config.splitRatio || 40; + const registeredItemIds = React.useMemo(() => new Set(items.map((i) => i.id)), [items]); + + // ──── 셀 값 추출 헬퍼 ──── + const getCellValue = (item: Record, colName: string) => { + return item[colName] ?? item[`item_${colName}`] ?? "-"; + }; if (isPreview) { return (
-

- 품목별 라우팅 관리 -

+

품목별 라우팅 관리

- 품목 선택 - 라우팅 버전 - 공정 순서 + {isRegisteredMode ? "등록 품목 모드" : "전체 품목 모드"}

@@ -221,94 +207,111 @@ export function ItemRoutingComponent({ return (
- {/* 좌측 패널: 품목 목록 */} -
-
+ {/* ════ 좌측 패널: 품목 목록 (테이블) ════ */} +
+

{config.leftPanelTitle || "품목 목록"} + {isRegisteredMode && ( + (등록 모드) + )}

+ {isRegisteredMode && !config.readonly && ( + + )}
- {/* 검색 */}
- setSearchText(e.target.value)} - onKeyDown={handleSearchKeyDown} - placeholder="품목명/품번 검색" - className="h-8 text-xs" - /> + setSearchText(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }} + placeholder="품목명/품번 검색" className="h-8 text-xs" />
- {/* 품목 리스트 */} -
+ {/* 품목 테이블 */} +
{items.length === 0 ? ( -
+

- {loading ? "로딩 중..." : "품목이 없습니다"} + {loading ? "로딩 중..." : isRegisteredMode ? "등록된 품목이 없습니다" : "품목이 없습니다"}

+ {isRegisteredMode && !loading && !config.readonly && ( + + )}
) : ( -
- {items.map((item) => { - const itemCode = - item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number; - const itemName = - item[config.dataSource.itemNameColumn] || item.item_name; - const isSelected = selectedItemCode === itemCode; + + + + {itemDisplayCols.map((col) => ( + + {col.label} + + ))} + {isRegisteredMode && !config.readonly && ( + + )} + + + + {items.map((item) => { + const itemCode = item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number; + const itemName = item[config.dataSource.itemNameColumn] || item.item_name; + const isSelected = selectedItemCode === itemCode; - return ( - - ); - })} - + return ( + selectItem(itemCode, itemName)}> + {itemDisplayCols.map((col) => ( + + {getCellValue(item, col.name)} + + ))} + {isRegisteredMode && !config.readonly && item.registered_id && ( + + + + )} + + ); + })} + +
)}
- {/* 우측 패널: 버전 + 공정 */} + {/* ════ 우측 패널: 버전 + 공정 ════ */}
{selectedItemCode ? ( <> - {/* 헤더: 선택된 품목 + 버전 추가 */}

{selectedItemName}

{selectedItemCode}

{!config.readonly && ( - )}
- {/* 버전 선택 버튼들 */} {versions.length > 0 ? (
버전: @@ -317,50 +320,24 @@ export function ItemRoutingComponent({ const isDefault = ver.is_default === true; return (
- selectVersion(ver.id)} - > + isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700")} + onClick={() => selectVersion(ver.id)}> {isDefault && } {ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id} {!config.readonly && ( <> - - @@ -371,112 +348,65 @@ export function ItemRoutingComponent({
) : (
-

- 라우팅 버전이 없습니다. 버전을 추가해주세요. -

+

라우팅 버전이 없습니다. 버전을 추가해주세요.

)} - {/* 공정 테이블 */} {selectedVersionId ? (
- {/* 공정 테이블 헤더 */}

{config.rightPanelTitle || "공정 순서"} ({details.length}건)

{!config.readonly && ( - )}
- - {/* 테이블 */}
{details.length === 0 ? (
-

- {loading ? "로딩 중..." : "등록된 공정이 없습니다"} -

+

{loading ? "로딩 중..." : "등록된 공정이 없습니다"}

) : ( {config.processColumns.map((col) => ( - + className={cn("text-xs", col.align === "center" && "text-center", col.align === "right" && "text-right")}> {col.label} ))} - {!config.readonly && ( - - 관리 - - )} + {!config.readonly && 관리} {details.map((detail) => ( {config.processColumns.map((col) => { - let cellValue = detail[col.name]; - if (cellValue == null) { - const aliasKey = Object.keys(detail).find( - (k) => k.endsWith(`_${col.name}`) - ); - if (aliasKey) cellValue = detail[aliasKey]; + let v = detail[col.name]; + if (v == null) { + const ak = Object.keys(detail).find((k) => k.endsWith(`_${col.name}`)); + if (ak) v = detail[ak]; } return ( - - {cellValue ?? "-"} + + {v ?? "-"} ); })} {!config.readonly && (
- -
@@ -492,9 +422,7 @@ export function ItemRoutingComponent({ ) : ( versions.length > 0 && (
-

- 라우팅 버전을 선택해주세요 -

+

라우팅 버전을 선택해주세요

) )} @@ -502,43 +430,121 @@ export function ItemRoutingComponent({ ) : (
-

- 좌측에서 품목을 선택하세요 -

-

- 품목을 선택하면 라우팅 버전별 공정 순서를 관리할 수 있습니다 -

+

좌측에서 품목을 선택하세요

+

품목을 선택하면 라우팅 버전별 공정 순서를 관리할 수 있습니다

)} - {/* 삭제 확인 다이얼로그 */} + {/* ════ 삭제 확인 ════ */} setDeleteTarget(null)}> 삭제 확인 {deleteTarget?.name}을(를) 삭제하시겠습니까? - {deleteTarget?.type === "version" && ( - <> -
- 해당 버전에 포함된 모든 공정 정보도 함께 삭제됩니다. - - )} + {deleteTarget?.type === "version" && (<>
해당 버전에 포함된 모든 공정 정보도 함께 삭제됩니다.)}
취소 - + 삭제
+ + {/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */} + + + + 품목 추가 + + 좌측 목록에 표시할 품목을 선택하세요 + {(config.itemFilterConditions?.length ?? 0) > 0 && ( + + (필터 {config.itemFilterConditions!.length}건 적용됨) + + )} + + + +
+ setAddSearchText(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") fetchAllItems(addSearchText || undefined); }} + placeholder="품목명/품번 검색" className="h-8 text-xs sm:h-10 sm:text-sm" /> + +
+ +
+ {allItems.length === 0 ? ( +
+

품목이 없습니다

+
+ ) : ( +
+ + + + {modalDisplayCols.map((col) => ( + + {col.label} + + ))} + 상태 + + + + {allItems.map((item) => { + const isAlreadyRegistered = registeredItemIds.has(item.id); + const isChecked = selectedAddItems.has(item.id); + return ( + { if (!isAlreadyRegistered) handleToggleAddItem(item.id); }}> + + + + {modalDisplayCols.map((col) => ( + + {getCellValue(item, col.name)} + + ))} + + {isAlreadyRegistered && ( + 등록됨 + )} + + + ); + })} + +
+ )} +
+ + {selectedAddItems.size > 0 && ( +

{selectedAddItems.size}개 선택됨

+ )} + + + + + + +
); } diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx index 7a9fa624..653d351d 100644 --- a/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx @@ -9,7 +9,7 @@ export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer { static componentDefinition = V2ItemRoutingDefinition; render(): React.ReactElement { - const { formData, isPreview, config, tableName } = this.props as Record< + const { formData, isPreview, config, tableName, screenId } = this.props as Record< string, unknown >; @@ -20,6 +20,7 @@ export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer { formData={formData as Record} tableName={tableName as string} isPreview={isPreview as boolean} + screenId={screenId as number | string} /> ); } diff --git a/frontend/lib/registry/components/v2-item-routing/config.ts b/frontend/lib/registry/components/v2-item-routing/config.ts index a84ff23e..42ae1479 100644 --- a/frontend/lib/registry/components/v2-item-routing/config.ts +++ b/frontend/lib/registry/components/v2-item-routing/config.ts @@ -35,4 +35,15 @@ export const defaultConfig: ItemRoutingConfig = { autoSelectFirstVersion: true, versionAddButtonText: "+ 라우팅 버전 추가", processAddButtonText: "+ 공정 추가", + itemListMode: "all", + screenCode: "", + itemDisplayColumns: [ + { name: "item_name", label: "품명" }, + { name: "item_code", label: "품번", width: 100 }, + ], + modalDisplayColumns: [ + { name: "item_name", label: "품명" }, + { name: "item_code", label: "품번", width: 100 }, + ], + itemFilterConditions: [], }; diff --git a/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts b/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts index 97f6be4f..bd1f551b 100644 --- a/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts +++ b/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts @@ -1,12 +1,21 @@ "use client"; -import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { useState, useCallback, useMemo, useRef } from "react"; import { apiClient } from "@/lib/api/client"; -import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData } from "../types"; +import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData, ColumnDef } from "../types"; import { defaultConfig } from "../config"; const API_BASE = "/process-work-standard"; +/** 표시 컬럼 목록에서 기본(item_name, item_code) 외 추가 컬럼만 추출 */ +function getExtraColumnNames(columns?: ColumnDef[]): string { + if (!columns || columns.length === 0) return ""; + return columns + .map((c) => c.name) + .filter((n) => n && n !== "item_name" && n !== "item_code") + .join(","); +} + export function useItemRouting(configPartial: Partial) { const configKey = useMemo( () => JSON.stringify(configPartial), @@ -27,21 +36,81 @@ export function useItemRouting(configPartial: Partial) { configRef.current = config; const [items, setItems] = useState([]); + const [allItems, setAllItems] = useState([]); const [versions, setVersions] = useState([]); const [details, setDetails] = useState([]); const [loading, setLoading] = useState(false); - // 선택 상태 const [selectedItemCode, setSelectedItemCode] = useState(null); const [selectedItemName, setSelectedItemName] = useState(null); const [selectedVersionId, setSelectedVersionId] = useState(null); - // 품목 목록 조회 + const isRegisteredMode = config.itemListMode === "registered"; + + /** API 기본 파라미터 생성 */ + const buildBaseParams = useCallback((search?: string, columns?: ColumnDef[]) => { + const ds = configRef.current.dataSource; + const extra = getExtraColumnNames(columns); + const filters = configRef.current.itemFilterConditions; + const params: Record = { + tableName: ds.itemTable, + nameColumn: ds.itemNameColumn, + codeColumn: ds.itemCodeColumn, + routingTable: ds.routingVersionTable, + routingFkColumn: ds.routingVersionFkColumn, + }; + if (search) params.search = search; + if (extra) params.extraColumns = extra; + if (filters && filters.length > 0) { + params.filterConditions = JSON.stringify(filters); + } + return new URLSearchParams(params); + }, []); + + // ──────────────────────────────────────── + // 품목 목록 조회 (all 모드) + // ──────────────────────────────────────── const fetchItems = useCallback( async (search?: string) => { + try { + setLoading(true); + const cols = configRef.current.itemDisplayColumns; + const params = buildBaseParams(search, cols); + const res = await apiClient.get(`${API_BASE}/items?${params}`); + if (res.data?.success) { + const data = res.data.data || []; + if (configRef.current.itemListMode !== "registered") { + setItems(data); + } + return data; + } + } catch (err) { + console.error("품목 조회 실패", err); + } finally { + setLoading(false); + } + return []; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [configKey, buildBaseParams] + ); + + // ──────────────────────────────────────── + // 등록 품목 조회 (registered 모드) + // ──────────────────────────────────────── + const fetchRegisteredItems = useCallback( + async (search?: string) => { + const screenCode = configRef.current.screenCode; + if (!screenCode) { + console.warn("screenCode가 설정되지 않았습니다"); + setItems([]); + return; + } try { setLoading(true); const ds = configRef.current.dataSource; + const cols = configRef.current.itemDisplayColumns; + const extra = getExtraColumnNames(cols); const params = new URLSearchParams({ tableName: ds.itemTable, nameColumn: ds.itemNameColumn, @@ -49,13 +118,16 @@ export function useItemRouting(configPartial: Partial) { routingTable: ds.routingVersionTable, routingFkColumn: ds.routingVersionFkColumn, ...(search ? { search } : {}), + ...(extra ? { extraColumns: extra } : {}), }); - const res = await apiClient.get(`${API_BASE}/items?${params}`); + const res = await apiClient.get( + `${API_BASE}/registered-items/${encodeURIComponent(screenCode)}?${params}` + ); if (res.data?.success) { setItems(res.data.data || []); } } catch (err) { - console.error("품목 조회 실패", err); + console.error("등록 품목 조회 실패", err); } finally { setLoading(false); } @@ -64,7 +136,104 @@ export function useItemRouting(configPartial: Partial) { [configKey] ); - // 라우팅 버전 목록 조회 + // ──────────────────────────────────────── + // 전체 품목 조회 (등록 팝업용 - 필터+추가컬럼 적용) + // ──────────────────────────────────────── + const fetchAllItems = useCallback( + async (search?: string) => { + try { + const cols = configRef.current.modalDisplayColumns; + const params = buildBaseParams(search, cols); + const res = await apiClient.get(`${API_BASE}/items?${params}`); + if (res.data?.success) { + setAllItems(res.data.data || []); + } + } catch (err) { + console.error("전체 품목 조회 실패", err); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [configKey, buildBaseParams] + ); + + // ──────────────────────────────────────── + // 품목 등록/제거 (registered 모드) + // ──────────────────────────────────────── + const registerItem = useCallback( + async (itemId: string, itemCode: string) => { + const screenCode = configRef.current.screenCode; + if (!screenCode) return false; + try { + const res = await apiClient.post(`${API_BASE}/registered-items`, { + screenCode, + itemId, + itemCode, + }); + if (res.data?.success) { + await fetchRegisteredItems(); + return true; + } + } catch (err) { + console.error("품목 등록 실패", err); + } + return false; + }, + [fetchRegisteredItems] + ); + + const registerItemsBatch = useCallback( + async (itemList: { itemId: string; itemCode: string }[]) => { + const screenCode = configRef.current.screenCode; + if (!screenCode) return false; + try { + const res = await apiClient.post(`${API_BASE}/registered-items/batch`, { + screenCode, + items: itemList, + }); + if (res.data?.success) { + await fetchRegisteredItems(); + return true; + } + } catch (err) { + console.error("품목 일괄 등록 실패", err); + } + return false; + }, + [fetchRegisteredItems] + ); + + const unregisterItem = useCallback( + async (registeredId: string) => { + try { + const res = await apiClient.delete(`${API_BASE}/registered-items/${registeredId}`); + if (res.data?.success) { + if (selectedItemCode) { + const removedItem = items.find((i) => i.registered_id === registeredId); + if (removedItem) { + const removedCode = removedItem.item_code || removedItem[configRef.current.dataSource.itemCodeColumn]; + if (selectedItemCode === removedCode) { + setSelectedItemCode(null); + setSelectedItemName(null); + setSelectedVersionId(null); + setVersions([]); + setDetails([]); + } + } + } + await fetchRegisteredItems(); + return true; + } + } catch (err) { + console.error("등록 품목 제거 실패", err); + } + return false; + }, + [selectedItemCode, items, fetchRegisteredItems] + ); + + // ──────────────────────────────────────── + // 라우팅 버전/공정 관련 (기존 동일) + // ──────────────────────────────────────── const fetchVersions = useCallback( async (itemCode: string) => { try { @@ -94,7 +263,6 @@ export function useItemRouting(configPartial: Partial) { [configKey] ); - // 공정 상세 목록 조회 (특정 버전의 공정들) - entity join 포함 const fetchDetails = useCallback( async (versionId: string) => { try { @@ -128,18 +296,14 @@ export function useItemRouting(configPartial: Partial) { [configKey] ); - // 품목 선택 const selectItem = useCallback( async (itemCode: string, itemName: string) => { setSelectedItemCode(itemCode); setSelectedItemName(itemName); setSelectedVersionId(null); setDetails([]); - const versionList = await fetchVersions(itemCode); - if (versionList.length > 0) { - // 기본 버전 우선, 없으면 첫번째 버전 선택 const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default); const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null); if (targetVersion) { @@ -151,7 +315,6 @@ export function useItemRouting(configPartial: Partial) { [fetchVersions, fetchDetails] ); - // 버전 선택 const selectVersion = useCallback( async (versionId: string) => { setSelectedVersionId(versionId); @@ -160,7 +323,6 @@ export function useItemRouting(configPartial: Partial) { [fetchDetails] ); - // 모달에서 데이터 변경 후 새로고침 const refreshVersions = useCallback(async () => { if (selectedItemCode) { const versionList = await fetchVersions(selectedItemCode); @@ -180,7 +342,6 @@ export function useItemRouting(configPartial: Partial) { } }, [selectedVersionId, fetchDetails]); - // 공정 삭제 const deleteDetail = useCallback( async (detailId: string) => { try { @@ -189,19 +350,13 @@ export function useItemRouting(configPartial: Partial) { `/table-management/tables/${ds.routingDetailTable}/delete`, { data: [{ id: detailId }] } ); - if (res.data?.success) { - await refreshDetails(); - return true; - } - } catch (err) { - console.error("공정 삭제 실패", err); - } + if (res.data?.success) { await refreshDetails(); return true; } + } catch (err) { console.error("공정 삭제 실패", err); } return false; }, [refreshDetails] ); - // 버전 삭제 const deleteVersion = useCallback( async (versionId: string) => { try { @@ -211,22 +366,16 @@ export function useItemRouting(configPartial: Partial) { { data: [{ id: versionId }] } ); if (res.data?.success) { - if (selectedVersionId === versionId) { - setSelectedVersionId(null); - setDetails([]); - } + if (selectedVersionId === versionId) { setSelectedVersionId(null); setDetails([]); } await refreshVersions(); return true; } - } catch (err) { - console.error("버전 삭제 실패", err); - } + } catch (err) { console.error("버전 삭제 실패", err); } return false; }, [selectedVersionId, refreshVersions] ); - // 기본 버전 설정 const setDefaultVersion = useCallback( async (versionId: string) => { try { @@ -236,20 +385,15 @@ export function useItemRouting(configPartial: Partial) { routingFkColumn: ds.routingVersionFkColumn, }); if (res.data?.success) { - if (selectedItemCode) { - await fetchVersions(selectedItemCode); - } + if (selectedItemCode) await fetchVersions(selectedItemCode); return true; } - } catch (err) { - console.error("기본 버전 설정 실패", err); - } + } catch (err) { console.error("기본 버전 설정 실패", err); } return false; }, [selectedItemCode, fetchVersions] ); - // 기본 버전 해제 const unsetDefaultVersion = useCallback( async (versionId: string) => { try { @@ -258,14 +402,10 @@ export function useItemRouting(configPartial: Partial) { routingVersionTable: ds.routingVersionTable, }); if (res.data?.success) { - if (selectedItemCode) { - await fetchVersions(selectedItemCode); - } + if (selectedItemCode) await fetchVersions(selectedItemCode); return true; } - } catch (err) { - console.error("기본 버전 해제 실패", err); - } + } catch (err) { console.error("기본 버전 해제 실패", err); } return false; }, [selectedItemCode, fetchVersions] @@ -274,13 +414,20 @@ export function useItemRouting(configPartial: Partial) { return { config, items, + allItems, versions, details, loading, selectedItemCode, selectedItemName, selectedVersionId, + isRegisteredMode, fetchItems, + fetchRegisteredItems, + fetchAllItems, + registerItem, + registerItemsBatch, + unregisterItem, selectItem, selectVersion, refreshVersions, diff --git a/frontend/lib/registry/components/v2-item-routing/types.ts b/frontend/lib/registry/components/v2-item-routing/types.ts index 06b108da..08fe73c2 100644 --- a/frontend/lib/registry/components/v2-item-routing/types.ts +++ b/frontend/lib/registry/components/v2-item-routing/types.ts @@ -10,10 +10,10 @@ export interface ItemRoutingDataSource { itemNameColumn: string; itemCodeColumn: string; routingVersionTable: string; - routingVersionFkColumn: string; // item_routing_version에서 item_code를 가리키는 FK + routingVersionFkColumn: string; routingVersionNameColumn: string; routingDetailTable: string; - routingDetailFkColumn: string; // item_routing_detail에서 routing_version_id를 가리키는 FK + routingDetailFkColumn: string; processTable: string; processNameColumn: string; processCodeColumn: string; @@ -26,14 +26,24 @@ export interface ItemRoutingModals { processEditScreenId?: number; } -// 공정 테이블 컬럼 정의 -export interface ProcessColumnDef { +// 컬럼 정의 (공정/품목 공용) +export interface ColumnDef { name: string; label: string; width?: number; align?: "left" | "center" | "right"; } +// 공정 테이블 컬럼 정의 (기존 호환) +export type ProcessColumnDef = ColumnDef; + +// 품목 필터 조건 +export interface ItemFilterCondition { + column: string; + operator: "equals" | "contains" | "not_equals"; + value: string; +} + // 전체 Config export interface ItemRoutingConfig { dataSource: ItemRoutingDataSource; @@ -46,6 +56,14 @@ export interface ItemRoutingConfig { autoSelectFirstVersion?: boolean; versionAddButtonText?: string; processAddButtonText?: string; + itemListMode?: "all" | "registered"; + screenCode?: string; + /** 좌측 품목 목록에 표시할 컬럼 */ + itemDisplayColumns?: ColumnDef[]; + /** 품목 추가 모달에 표시할 컬럼 */ + modalDisplayColumns?: ColumnDef[]; + /** 품목 조회 시 사전 필터 조건 */ + itemFilterConditions?: ItemFilterCondition[]; } // 컴포넌트 Props @@ -54,6 +72,7 @@ export interface ItemRoutingComponentProps { formData?: Record; isPreview?: boolean; tableName?: string; + screenId?: number | string; } // 데이터 모델 diff --git a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx index cf7b306f..ad169acd 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx @@ -17,25 +17,37 @@ interface ProcessWorkStandardComponentProps { formData?: Record; isPreview?: boolean; tableName?: string; + screenId?: number | string; } export function ProcessWorkStandardComponent({ config: configProp, isPreview, + screenId, }: ProcessWorkStandardComponentProps) { + const resolvedConfig = useMemo(() => { + const merged = { + ...configProp, + }; + if (merged.itemListMode === "registered" && !merged.screenCode && screenId) { + merged.screenCode = `screen_${screenId}`; + } + return merged; + }, [configProp, screenId]); + const config: ProcessWorkStandardConfig = useMemo( () => ({ ...defaultConfig, - ...configProp, - dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource }, - phases: configProp?.phases?.length - ? configProp.phases + ...resolvedConfig, + dataSource: { ...defaultConfig.dataSource, ...resolvedConfig?.dataSource }, + phases: resolvedConfig?.phases?.length + ? resolvedConfig.phases : defaultConfig.phases, - detailTypes: configProp?.detailTypes?.length - ? configProp.detailTypes + detailTypes: resolvedConfig?.detailTypes?.length + ? resolvedConfig.detailTypes : defaultConfig.detailTypes, }), - [configProp] + [resolvedConfig] ); const { @@ -46,7 +58,8 @@ export function ProcessWorkStandardComponent({ selectedDetailsByPhase, selection, loading, - fetchItems, + isRegisteredMode, + loadItems, selectItem, selectProcess, fetchWorkItemDetails, @@ -112,8 +125,8 @@ export function ProcessWorkStandardComponent({ ); const handleInit = useCallback(() => { - fetchItems(); - }, [fetchItems]); + loadItems(); + }, [loadItems]); const splitRatio = config.splitRatio || 30; @@ -144,7 +157,7 @@ export function ProcessWorkStandardComponent({ items={items} routings={routings} selection={selection} - onSearch={(keyword) => fetchItems(keyword)} + onSearch={(keyword) => loadItems(keyword)} onSelectItem={selectItem} onSelectProcess={selectProcess} onInit={handleInit} diff --git a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel.tsx b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel.tsx index 21a5d69f..27af18f4 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ProcessWorkStandardConfig, WorkPhaseDefinition, DetailTypeDefinition } from "./types"; import { defaultConfig } from "./config"; @@ -81,6 +82,30 @@ export function ProcessWorkStandardConfigPanel({

공정 작업기준 설정

+ {/* 품목 목록 모드 */} +
+

품목 목록 모드

+
+ +

+ {config.itemListMode === "registered" + ? "품목별 라우팅 탭에서 등록한 품목만 표시됩니다. screenCode는 화면 ID 기준으로 자동 설정됩니다." + : "모든 품목을 표시합니다."} +

+
+
+ {/* 데이터 소스 설정 */}

데이터 소스 설정

diff --git a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardRenderer.tsx b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardRenderer.tsx index cb1e0e85..aedfae37 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardRenderer.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardRenderer.tsx @@ -9,7 +9,7 @@ export class ProcessWorkStandardRenderer extends AutoRegisteringComponentRendere static componentDefinition = V2ProcessWorkStandardDefinition; render(): React.ReactElement { - const { formData, isPreview, config, tableName } = this.props as Record< + const { formData, isPreview, config, tableName, screenId } = this.props as Record< string, unknown >; @@ -20,6 +20,7 @@ export class ProcessWorkStandardRenderer extends AutoRegisteringComponentRendere formData={formData as Record} tableName={tableName as string} isPreview={isPreview as boolean} + screenId={screenId as number | string} /> ); } diff --git a/frontend/lib/registry/components/v2-process-work-standard/config.ts b/frontend/lib/registry/components/v2-process-work-standard/config.ts index 43ad60cd..22b36ea3 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/config.ts +++ b/frontend/lib/registry/components/v2-process-work-standard/config.ts @@ -31,4 +31,6 @@ export const defaultConfig: ProcessWorkStandardConfig = { splitRatio: 30, leftPanelTitle: "품목 및 공정 선택", readonly: false, + itemListMode: "all", + screenCode: "", }; diff --git a/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts b/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts index e909d291..194a7d95 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts +++ b/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts @@ -32,7 +32,9 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { processName: null, }); - // 품목 목록 조회 + const isRegisteredMode = config.itemListMode === "registered"; + + // 품목 목록 조회 (전체 모드) const fetchItems = useCallback( async (search?: string) => { try { @@ -59,6 +61,53 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { [config.dataSource] ); + // 등록 품목 조회 (등록 모드) + const fetchRegisteredItems = useCallback( + async (search?: string) => { + const screenCode = config.screenCode; + if (!screenCode) { + console.warn("screenCode가 설정되지 않았습니다"); + setItems([]); + return; + } + try { + setLoading(true); + const ds = config.dataSource; + const params = new URLSearchParams({ + tableName: ds.itemTable, + nameColumn: ds.itemNameColumn, + codeColumn: ds.itemCodeColumn, + routingTable: ds.routingVersionTable, + routingFkColumn: ds.routingFkColumn, + ...(search ? { search } : {}), + }); + const res = await apiClient.get( + `${API_BASE}/registered-items/${encodeURIComponent(screenCode)}?${params}` + ); + if (res.data?.success) { + setItems(res.data.data || []); + } + } catch (err) { + console.error("등록 품목 조회 실패", err); + } finally { + setLoading(false); + } + }, + [config.dataSource, config.screenCode] + ); + + // 모드에 따라 적절한 함수 호출 + const loadItems = useCallback( + async (search?: string) => { + if (isRegisteredMode) { + await fetchRegisteredItems(search); + } else { + await fetchItems(search); + } + }, + [isRegisteredMode, fetchItems, fetchRegisteredItems] + ); + // 라우팅 + 공정 조회 const fetchRoutings = useCallback( async (itemCode: string) => { @@ -340,7 +389,10 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { selection, loading, saving, + isRegisteredMode, fetchItems, + fetchRegisteredItems, + loadItems, selectItem, selectProcess, fetchWorkItems, diff --git a/frontend/lib/registry/components/v2-process-work-standard/types.ts b/frontend/lib/registry/components/v2-process-work-standard/types.ts index 6d2b0bea..7185429b 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/types.ts +++ b/frontend/lib/registry/components/v2-process-work-standard/types.ts @@ -37,6 +37,10 @@ export interface ProcessWorkStandardConfig { splitRatio?: number; leftPanelTitle?: string; readonly?: boolean; + /** 품목 목록 모드: all=전체, registered=등록된 품목만 */ + itemListMode?: "all" | "registered"; + /** 등록 모드 시 화면 코드 (자동 설정됨) */ + screenCode?: string; } // ============================================================ @@ -121,6 +125,7 @@ export interface ProcessWorkStandardComponentProps { formData?: Record; isPreview?: boolean; tableName?: string; + screenId?: number | string; } // 선택 상태 diff --git a/frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx b/frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx new file mode 100644 index 00000000..5a56364f --- /dev/null +++ b/frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx @@ -0,0 +1,203 @@ +"use client"; + +import React, { useMemo } from "react"; +import { GripVertical } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/lib/utils"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, + useSortable, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +import { FormatSegment } from "./types"; +import { SEGMENT_TYPE_LABELS, buildFormattedString, SAMPLE_VALUES } from "./config"; + +// 개별 세그먼트 행 +interface SortableSegmentRowProps { + segment: FormatSegment; + index: number; + onChange: (index: number, updates: Partial) => void; +} + +function SortableSegmentRow({ segment, index, onChange }: SortableSegmentRowProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: `${segment.type}-${index}` }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+
+ +
+ + + {SEGMENT_TYPE_LABELS[segment.type]} + + + + onChange(index, { showLabel: checked === true }) + } + className="h-3.5 w-3.5" + /> + + onChange(index, { label: e.target.value })} + placeholder="" + className={cn( + "h-6 px-1 text-xs", + !segment.showLabel && "text-gray-400 line-through", + )} + /> + + onChange(index, { separatorAfter: e.target.value })} + placeholder="" + className="h-6 px-1 text-center text-xs" + /> + + + onChange(index, { pad: parseInt(e.target.value) || 0 }) + } + disabled={segment.type !== "row" && segment.type !== "level"} + className={cn( + "h-6 px-1 text-center text-xs", + segment.type !== "row" && segment.type !== "level" && "bg-gray-100 opacity-50", + )} + /> +
+ ); +} + +// FormatSegmentEditor 메인 컴포넌트 +interface FormatSegmentEditorProps { + label: string; + segments: FormatSegment[]; + onChange: (segments: FormatSegment[]) => void; + sampleValues?: Record; +} + +export function FormatSegmentEditor({ + label, + segments, + onChange, + sampleValues = SAMPLE_VALUES, +}: FormatSegmentEditorProps) { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + useSensor(KeyboardSensor), + ); + + const preview = useMemo( + () => buildFormattedString(segments, sampleValues), + [segments, sampleValues], + ); + + const sortableIds = useMemo( + () => segments.map((seg, i) => `${seg.type}-${i}`), + [segments], + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const oldIndex = sortableIds.indexOf(active.id as string); + const newIndex = sortableIds.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1) return; + + onChange(arrayMove([...segments], oldIndex, newIndex)); + }; + + const handleSegmentChange = (index: number, updates: Partial) => { + const updated = segments.map((seg, i) => + i === index ? { ...seg, ...updates } : seg, + ); + onChange(updated); + }; + + return ( +
+
{label}
+ +
+ + + + 라벨 + 구분 + 자릿수 +
+ + + +
+ {segments.map((segment, index) => ( + + ))} +
+
+
+ +
+ 미리보기: + + {preview || "(빈 값)"} + +
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx index cef90668..627cf1fa 100644 --- a/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx +++ b/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx @@ -20,6 +20,7 @@ import { GeneratedLocation, RackStructureContext, } from "./types"; +import { defaultFormatConfig, buildFormattedString } from "./config"; // 기존 위치 데이터 타입 interface ExistingLocation { @@ -95,12 +96,12 @@ const ConditionCard: React.FC = ({ }; return ( -
+
{/* 헤더 */} -
+
조건 {index + 1} {!readonly && ( - )} @@ -111,7 +112,7 @@ const ConditionCard: React.FC = ({ {/* 열 범위 */}
-
-
{/* 계산 결과 */} -
+
{locationCount > 0 ? ( <> {localValues.startRow}열 ~ {localValues.endRow}열 x {localValues.levels}단 ={" "} @@ -288,11 +289,10 @@ export const RackStructureComponent: React.FC = ({ return ctx; }, [propContext, formData, fieldMapping, getCategoryLabel]); - // 필수 필드 검증 + // 필수 필드 검증 (층은 선택 입력) const missingFields = useMemo(() => { const missing: string[] = []; if (!context.warehouseCode) missing.push("창고 코드"); - if (!context.floor) missing.push("층"); if (!context.zone) missing.push("구역"); return missing; }, [context]); @@ -377,9 +377,8 @@ export const RackStructureComponent: React.FC = ({ // 기존 데이터 조회 (창고/층/구역이 변경될 때마다) useEffect(() => { const loadExistingLocations = async () => { - // 필수 조건이 충족되지 않으면 기존 데이터 초기화 - // DB에는 라벨 값(예: "1층", "A구역")으로 저장되어 있으므로 라벨 값 사용 - if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) { + // 창고 코드와 구역은 필수, 층은 선택 + if (!warehouseCodeForQuery || !zoneForQuery) { setExistingLocations([]); setDuplicateErrors([]); return; @@ -387,14 +386,13 @@ export const RackStructureComponent: React.FC = ({ setIsCheckingDuplicates(true); try { - // warehouse_location 테이블에서 해당 창고/층/구역의 기존 데이터 조회 - // DB에는 라벨 값으로 저장되어 있으므로 라벨 값으로 필터링 - // equals 연산자를 사용하여 정확한 일치 검색 (ILIKE가 아닌 = 연산자 사용) - const searchParams = { + const searchParams: Record = { warehouse_code: { value: warehouseCodeForQuery, operator: "equals" }, - floor: { value: floorForQuery, operator: "equals" }, zone: { value: zoneForQuery, operator: "equals" }, }; + if (floorForQuery) { + searchParams.floor = { value: floorForQuery, operator: "equals" }; + } // 직접 apiClient 사용하여 정확한 형식으로 요청 // 백엔드는 search를 객체로 받아서 각 필드를 WHERE 조건으로 처리 @@ -493,23 +491,26 @@ export const RackStructureComponent: React.FC = ({ return { totalLocations, totalRows, maxLevel }; }, [conditions]); - // 위치 코드 생성 + // 포맷 설정 (ConfigPanel에서 관리자가 설정한 값, 미설정 시 기본값) + const formatConfig = config.formatConfig || defaultFormatConfig; + + // 위치 코드 생성 (세그먼트 기반 - 순서/구분자/라벨/자릿수 모두 formatConfig에 따름) const generateLocationCode = useCallback( (row: number, level: number): { code: string; name: string } => { - const warehouseCode = context?.warehouseCode || "WH001"; - const floor = context?.floor || "1"; - const zone = context?.zone || "A"; + const values: Record = { + warehouseCode: context?.warehouseCode || "WH001", + floor: context?.floor || "", + zone: context?.zone || "A", + row: row.toString(), + level: level.toString(), + }; - // 코드 생성 (예: WH001-1층D구역-01-1) - const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; - - // 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용 - const zoneName = zone.includes("구역") ? zone : `${zone}구역`; - const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`; + const code = buildFormattedString(formatConfig.codeSegments, values); + const name = buildFormattedString(formatConfig.nameSegments, values); return { code, name }; }, - [context], + [context, formatConfig], ); // 미리보기 생성 @@ -626,7 +627,7 @@ export const RackStructureComponent: React.FC = ({ -
렉 라인 구조 설정 +
렉 라인 구조 설정 {!readonly && (
@@ -719,8 +720,8 @@ export const RackStructureComponent: React.FC = ({ {/* 기존 데이터 존재 알림 */} {!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && ( - - + + 해당 창고/층/구역에 {existingLocations.length}개의 위치가 이미 등록되어 있습니다. @@ -729,9 +730,9 @@ export const RackStructureComponent: React.FC = ({ {/* 현재 매핑된 값 표시 */} {(context.warehouseCode || context.warehouseName || context.floor || context.zone) && ( -
+
{(context.warehouseCode || context.warehouseName) && ( - + 창고: {context.warehouseName || context.warehouseCode} {context.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`} @@ -748,28 +749,28 @@ export const RackStructureComponent: React.FC = ({ )} {context.status && ( - 상태: {context.status} + 상태: {context.status} )}
)} {/* 안내 메시지 */} -
-
    +
    +
    1. - + 1 조건 추가 버튼을 클릭하여 렉 라인 조건을 생성하세요
    2. - + 2 각 조건마다 열 범위와 단 수를 입력하세요
    3. - + 3 예시: 조건1(1~3열, 3단), 조건2(4~6열, 5단) @@ -779,9 +780,9 @@ export const RackStructureComponent: React.FC = ({ {/* 조건 목록 또는 빈 상태 */} {conditions.length === 0 ? ( -
      -
      📦
      -

      조건을 추가하여 렉 구조를 설정하세요

      +
      +
      📦
      +

      조건을 추가하여 렉 구조를 설정하세요

      {!readonly && (