diff --git a/.cursor/agents/pipeline-common-rules.md b/.cursor/agents/pipeline-common-rules.md index 5baa0e97..57049ce6 100644 --- a/.cursor/agents/pipeline-common-rules.md +++ b/.cursor/agents/pipeline-common-rules.md @@ -12,13 +12,14 @@ - **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등 - **특징**: 하드코딩된 UI, 관리자만 접근 -### 사용자 메뉴 (User/Screen) +### 사용자 메뉴 (User/Screen) - 절대 하드코딩 금지!!! - **구현 방식**: 로우코드 기반 (DB에 JSON으로 화면 구성 저장) -- **데이터 저장**: `screen_layouts` 테이블에 JSON 형식 보관 +- **데이터 저장**: `screen_layouts_v2` 테이블에 V2 JSON 형식 보관 - **화면 디자이너**: 스크린 디자이너로 드래그앤드롭 구성 - **V2 컴포넌트**: `frontend/lib/registry/components/v2-*` 디렉토리 -- **대상**: 일반 업무 화면, BOM, 문서 관리 등 +- **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등 - **특징**: 코드 수정 없이 화면 구성 변경 가능 +- **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것! ### 판단 기준 @@ -26,8 +27,88 @@ |------|-------------|-------------| | 누가 쓰나? | 시스템 관리자 | 일반 사용자 | | 화면 구조 고정? | 고정 (코드) | 유동적 (JSON) | -| URL 패턴 | `/admin/*` | 스크린 디자이너 경유 | -| 메뉴 등록 | `menu_info` INSERT 필수 | 스크린 레이아웃 등록 | +| URL 패턴 | `/admin/*` | `/screen/{screen_code}` | +| 메뉴 등록 | `menu_info` INSERT | `screen_definitions` + `menu_info` INSERT | +| 프론트엔드 코드 | `frontend/app/(main)/admin/` 하위에 page.tsx 작성 | **코드 작성 금지!** DB에 스크린 정의만 등록 | + +### 사용자 메뉴 구현 방법 (반드시 이 방식으로!) + +**절대 규칙: 사용자 메뉴는 React 페이지(.tsx)를 직접 만들지 않는다!** +이미 `/screen/[screenCode]/page.tsx` → `/screens/[screenId]/page.tsx` 렌더링 시스템이 존재한다. +새 화면이 필요하면 DB에 등록만 하면 자동으로 렌더링된다. + +#### Step 1: screen_definitions에 화면 등록 + +```sql +INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, is_active) +VALUES ('포장/적재정보 관리', 'COMPANY_7_PKG', 'pkg_unit', 'COMPANY_7', 'Y') +RETURNING screen_id; +``` + +- `screen_code`: `{company_code}_{기능약어}` 형식 (예: COMPANY_7_PKG) +- `table_name`: 메인 테이블명 (V2 컴포넌트가 이 테이블 기준으로 동작) +- `company_code`: 대상 회사 코드 + +#### Step 2: screen_layouts_v2에 V2 레이아웃 JSON 등록 + +```sql +INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data) +VALUES ( + {screen_id}, + 'COMPANY_7', + 1, + '기본 레이어', + '{ + "version": "2.0", + "components": [ + { + "id": "comp_split_1", + "url": "@/lib/registry/components/v2-split-panel-layout", + "position": {"x": 0, "y": 0}, + "size": {"width": 1200, "height": 800}, + "displayOrder": 0, + "overrides": { + "leftTitle": "포장단위 목록", + "rightTitle": "상세 정보", + "splitRatio": 40, + "leftTableName": "pkg_unit", + "rightTableName": "pkg_unit", + "tabs": [ + {"id": "basic", "label": "기본정보"}, + {"id": "items", "label": "매칭품목"} + ] + } + } + ] + }'::jsonb +); +``` + +- V2 컴포넌트 목록: v2-split-panel-layout, v2-table-list, v2-table-search-widget, v2-repeater, v2-button-primary, v2-tabs-widget 등 +- 상세 컴포넌트 가이드: `.cursor/rules/component-development-guide.mdc` 참조 + +#### Step 3: menu_info에 메뉴 등록 + +```sql +-- 먼저 부모 메뉴 objid 조회 +-- SELECT objid, menu_name_kor FROM menu_info WHERE company_code = '{회사코드}' AND menu_name_kor LIKE '%물류%'; + +INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, screen_code, company_code, status) +VALUES ( + (SELECT COALESCE(MAX(objid), 0) + 1 FROM menu_info), + 2, -- 2 = 메뉴 항목 + {부모_objid}, -- 상위 메뉴의 objid + '포장/적재정보', + 10, -- 정렬 순서 + '/screen/COMPANY_7_PKG', -- /screen/{screen_code} 형식 (절대!) + 'COMPANY_7_PKG', -- screen_definitions.screen_code와 일치 + 'COMPANY_7', + 'Y' +); +``` + +**핵심**: `menu_url`은 반드시 `/screen/{screen_code}` 형식이어야 한다! +프론트엔드가 이 URL을 받아 `screen_definitions`에서 screen_id를 찾고, `screen_layouts_v2`에서 레이아웃을 로드한다. ## 2. 관리자 메뉴 등록 (코드 구현 후 필수!) @@ -35,11 +116,14 @@ ```sql -- 예시: 결재 템플릿 관리 메뉴 등록 -INSERT INTO menu_info (menu_id, menu_name, url, parent_id, menu_type, sort_order, is_active, company_code) -VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'approval', 'ADMIN', 40, 'Y', '대상회사코드'); +INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, company_code, status) +VALUES ( + (SELECT COALESCE(MAX(objid), 0) + 1 FROM menu_info), + 2, {부모_objid}, '결재 템플릿', 40, '/admin/approvalTemplate', '대상회사코드', 'Y' +); ``` -- 기존 메뉴 구조를 먼저 조회해서 parent_id, sort_order 등을 맞춰라 +- 기존 메뉴 구조를 먼저 조회해서 parent_obj_id, seq 등을 맞춰라 - company_code 별로 등록이 필요할 수 있다 - menu_auth_group 권한 매핑도 필요하면 추가 @@ -63,15 +147,25 @@ VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'appr 기능 하나를 "완성"이라고 말하려면 아래를 전부 충족해야 한다: +### 공통 - [ ] DB: 마이그레이션 작성 + 실행 완료 - [ ] DB: company_code 컬럼 + 인덱스 존재 -- [ ] BE: API 엔드포인트 구현 + 라우트 등록 +- [ ] BE: API 엔드포인트 구현 + 라우트 등록 (app.ts에 import + use 추가!) - [ ] BE: company_code 필터링 적용 -- [ ] FE: API 클라이언트 함수 작성 (lib/api/) -- [ ] FE: 화면 컴포넌트 구현 -- [ ] **메뉴 등록**: 관리자 메뉴면 menu_info INSERT, 사용자 메뉴면 스크린 레이아웃 등록 - [ ] 빌드 통과: 백엔드 tsc + 프론트엔드 tsc +### 관리자 메뉴인 경우 +- [ ] FE: `frontend/app/(main)/admin/{기능}/page.tsx` 작성 +- [ ] FE: API 클라이언트 함수 작성 (lib/api/) +- [ ] DB: `menu_info` INSERT (menu_url = `/admin/{기능}`) + +### 사용자 메뉴인 경우 (코드 작성 금지!) +- [ ] DB: `screen_definitions` INSERT (screen_code, table_name, company_code) +- [ ] DB: `screen_layouts_v2` INSERT (V2 레이아웃 JSON) +- [ ] DB: `menu_info` INSERT (menu_url = `/screen/{screen_code}`) +- [ ] BE: 필요한 경우 전용 API 작성 (범용 table-management API로 커버 안 되는 경우만) +- [ ] FE: .tsx 페이지 파일 만들지 않음 (이미 `/screens/[screenId]/page.tsx`가 렌더링) + ## 6. 절대 하지 말 것 1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!) @@ -80,3 +174,9 @@ VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'appr 4. 하드코딩 색상/URL/사용자ID 사용 5. Card 안에 Card 중첩 (중첩 박스 금지) 6. 백엔드 재실행하기 (nodemon이 자동 재시작) +7. **사용자 메뉴를 React 하드코딩(.tsx)으로 만들기 (절대 금지!!!)** + - `frontend/app/(main)/production/`, `frontend/app/(main)/warehouse/` 등에 page.tsx 파일을 만들어서 직접 UI를 코딩하는 것은 절대 금지 + - 사용자 메뉴는 반드시 `screen_definitions` + `screen_layouts_v2` + `menu_info` DB 등록 방식으로 구현 + - 이미 `/screen/[screenCode]` → `/screens/[screenId]` 렌더링 시스템이 존재함 + - 백엔드 API(controller/routes)와 프론트엔드 API 클라이언트(lib/api/)는 필요하면 코드로 작성 가능 + - 하지만 프론트엔드 화면 UI 자체는 DB의 V2 레이아웃 JSON으로만 구성 diff --git a/.cursor/agents/pipeline-frontend.md b/.cursor/agents/pipeline-frontend.md index 0eef5611..223b5b38 100644 --- a/.cursor/agents/pipeline-frontend.md +++ b/.cursor/agents/pipeline-frontend.md @@ -49,6 +49,35 @@ export async function getYourData(id: number) { } ``` +# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! + +**이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.** +사용자 업무 화면(포장관리, 금형관리, BOM, 문서관리 등)은 절대 React 페이지(.tsx)로 직접 UI를 하드코딩하지 않는다! + +## 금지 패턴 (절대 하지 말 것) +``` +frontend/app/(main)/production/packaging/page.tsx ← 이런 파일 만들지 마라! +frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 마라! +``` + +## 올바른 패턴 +사용자 화면은 DB에 등록만 하면 자동으로 렌더링된다: +1. `screen_definitions` 테이블에 화면 등록 (screen_code, table_name 등) +2. `screen_layouts_v2` 테이블에 V2 레이아웃 JSON 등록 (v2-split-panel-layout, v2-table-list 등) +3. `menu_info` 테이블에 메뉴 등록 (menu_url = `/screen/{screen_code}`) + +이미 존재하는 렌더링 시스템: +- `/screen/[screenCode]/page.tsx` → screenCode를 screenId로 변환 +- `/screens/[screenId]/page.tsx` → screen_layouts_v2에서 V2 레이아웃 로드 → DynamicComponentRenderer로 렌더링 + +## 프론트엔드 에이전트가 할 수 있는 것 +- `frontend/lib/api/` 하위에 API 클라이언트 함수 작성 (백엔드와 통신) +- V2 컴포넌트 자체를 수정/신규 개발 (`frontend/lib/registry/components/v2-*/`) +- 관리자 메뉴(`/admin/*`)는 React 페이지 코딩 가능 + +## 프론트엔드 에이전트가 할 수 없는 것 +- 사용자 메뉴 화면을 React 페이지로 직접 코딩하는 것 + # Your Domain - frontend/components/ - frontend/app/ diff --git a/.cursor/agents/pipeline-ui.md b/.cursor/agents/pipeline-ui.md index 44cf2daa..05d3359e 100644 --- a/.cursor/agents/pipeline-ui.md +++ b/.cursor/agents/pipeline-ui.md @@ -39,9 +39,23 @@ FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black - Use cn() for conditional classes - Use lucide-react for ALL icons +# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! + +사용자 업무 화면(포장관리, 금형관리, BOM 등)의 UI는 DB의 `screen_layouts_v2` 테이블에 V2 레이아웃 JSON으로 정의된다. +React 페이지(.tsx)로 직접 UI를 하드코딩하는 것은 절대 금지! + +UI 에이전트가 할 수 있는 것: +- V2 컴포넌트 자체의 스타일/UX 개선 (`frontend/lib/registry/components/v2-*/`) +- 관리자 메뉴(`/admin/*`) 페이지의 UI 개선 +- 공통 UI 컴포넌트(`frontend/components/ui/`) 스타일 개선 + +UI 에이전트가 할 수 없는 것: +- 사용자 메뉴 화면을 React 페이지로 직접 코딩 + # Your Domain - frontend/components/ (UI components) -- frontend/app/ (pages) +- frontend/app/ (pages - 관리자 메뉴만) +- frontend/lib/registry/components/v2-*/ (V2 컴포넌트) # Output Rules 1. TypeScript strict mode 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..b17484ce 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); + } +} + /** * 메뉴 정보 조회 */ @@ -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/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/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/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/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..0ab73e09 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -3105,3 +3105,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/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/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..9ac3e35e 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; 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/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..8fbe5e95 100644 --- a/frontend/app/(main)/admin/audit-log/page.tsx +++ b/frontend/app/(main)/admin/audit-log/page.tsx @@ -74,6 +74,7 @@ const RESOURCE_TYPE_CONFIG: Record< SCREEN_LAYOUT: { label: "레이아웃", icon: Monitor, color: "bg-purple-100 text-purple-700" }, FLOW: { label: "플로우", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" }, FLOW_STEP: { label: "플로우 스텝", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" }, + NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" }, USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" }, ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" }, PERMISSION: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" }, diff --git a/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx b/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx index 5930ecb9..f05b8164 100644 --- a/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx @@ -1,24 +1,7 @@ "use client"; -/** - * 제어 시스템 페이지 (리다이렉트) - * 이 페이지는 /admin/dataflow로 리다이렉트됩니다. - */ - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import DataFlowPage from "../page"; export default function NodeEditorPage() { - const router = useRouter(); - - useEffect(() => { - // /admin/dataflow 메인 페이지로 리다이렉트 - router.replace("/admin/systemMng/dataflow"); - }, [router]); - - return ( -
-
제어 관리 페이지로 이동중...
-
- ); + return ; } diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index ec16c27d..7fe11270 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react"; import { useParams, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react"; +import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw, LayoutGrid, Monitor } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { useRouter } from "next/navigation"; @@ -285,14 +285,23 @@ function PopScreenViewPage() {
)} + {/* 일반 모드 네비게이션 바 */} + {!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/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 836f3bcd..f2236b80 100644 --- a/frontend/components/layout/TabContent.tsx +++ b/frontend/components/layout/TabContent.tsx @@ -238,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/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] ? ( { + updateConfig("dataBinding", { + ...config.dataBinding, + sourceComponentId: e.target.value, + }); + }} + placeholder="예: tbl_items" + className="h-7 text-xs" + /> +

+ 같은 화면 내 v2-table-list 컴포넌트의 ID +

+
+
+ + { + updateConfig("dataBinding", { + ...config.dataBinding, + sourceColumn: e.target.value, + }); + }} + placeholder="예: item_number" + className="h-7 text-xs" + /> +

+ 선택된 행에서 가져올 컬럼명 +

+
+
+ )} +
); }; diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index e3f5f6cc..92efdcd8 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -375,12 +375,15 @@ 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); @@ -388,34 +391,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 조인에 특정 컬럼이 설정되어 있는지 확인 @@ -604,9 +622,9 @@ 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 정보 포함 @@ -651,7 +669,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) => { @@ -761,10 +779,9 @@ export const V2RepeaterConfigPanel: React.FC = ({ return (
- + 기본 컬럼 - Entity 조인 {/* 기본 설정 탭 */} @@ -1365,6 +1382,84 @@ export const V2RepeaterConfigPanel: React.FC = ({ )}
+ {/* ===== 🆕 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} + +
+ ); + })} +
+
+ ); + })} +
+ )} +
+ {/* 선택된 컬럼 상세 설정 - 🆕 모든 컬럼 통합, 순서 변경 가능 */} {config.columns.length > 0 && ( <> @@ -1381,7 +1476,7 @@ export const V2RepeaterConfigPanel: React.FC = ({
= ({ {/* 확장/축소 버튼 (입력 컬럼만) */} - {!col.isSourceDisplay && ( + {(!col.isSourceDisplay && !col.isJoinColumn) && (
{/* 확장된 상세 설정 (입력 컬럼만) */} - {!col.isSourceDisplay && expandedColumn === col.key && ( + {(!col.isSourceDisplay && !col.isJoinColumn) && expandedColumn === col.key && (
{/* 자동 입력 설정 */}
@@ -1812,120 +1916,6 @@ export const V2RepeaterConfigPanel: React.FC = ({ )} - {/* Entity 조인 설정 탭 */} - -
-
-

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); - const displayField = matchingCol?.key || column.columnName; - - return ( -
- toggleEntityJoinColumn( - joinTable.tableName, - sourceColumn, - column.columnName, - column.columnLabel, - displayField, - ) - } - > - - - {column.columnLabel} - - {column.inputType || column.dataType} - -
- ); - })} -
-
- ); - })} -
- )} - - {/* 현재 설정된 Entity 조인 목록 */} - {config.entityJoins && config.entityJoins.length > 0 && ( -
-

설정된 조인

-
- {config.entityJoins.map((join, idx) => ( -
- - {join.sourceColumn} - - {join.referenceTable} - - ({join.columns.map((c) => c.referenceField).join(", ")}) - - -
- ))} -
-
- )} -
-
-
); diff --git a/frontend/hooks/pop/executePopAction.ts b/frontend/hooks/pop/executePopAction.ts index ada6ad77..ad0981b6 100644 --- a/frontend/hooks/pop/executePopAction.ts +++ b/frontend/hooks/pop/executePopAction.ts @@ -322,7 +322,9 @@ export async function executeTaskList( } case "custom-event": - if (task.eventName) { + if (task.flowId) { + await apiClient.post(`/dataflow/node-flows/${task.flowId}/execute`, {}); + } else if (task.eventName) { publish(task.eventName, task.eventPayload ?? {}); } break; diff --git a/frontend/hooks/pop/useConnectionResolver.ts b/frontend/hooks/pop/useConnectionResolver.ts index 14bd321a..4aa03be3 100644 --- a/frontend/hooks/pop/useConnectionResolver.ts +++ b/frontend/hooks/pop/useConnectionResolver.ts @@ -20,7 +20,6 @@ import { usePopEvent } from "./usePopEvent"; import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout"; import { PopComponentRegistry, - type ConnectionMetaItem, } from "@/lib/registry/PopComponentRegistry"; interface UseConnectionResolverOptions { @@ -29,14 +28,21 @@ interface UseConnectionResolverOptions { componentTypes?: Map; } +interface AutoMatchPair { + sourceKey: string; + targetKey: string; + isFilter: boolean; +} + /** - * 소스/타겟의 connectionMeta에서 자동 매칭 가능한 이벤트 쌍을 찾는다. - * 규칙: category="event"이고 key가 동일한 쌍 + * 소스/타겟의 connectionMeta에서 자동 매칭 가능한 쌍을 찾는다. + * 규칙 1: category="event"이고 key가 동일한 쌍 (이벤트 매칭) + * 규칙 2: 소스 type="filter_value" + 타겟 type="filter_value" (필터 매칭) */ function getAutoMatchPairs( sourceType: string, targetType: string -): { sourceKey: string; targetKey: string }[] { +): AutoMatchPair[] { const sourceDef = PopComponentRegistry.getComponent(sourceType); const targetDef = PopComponentRegistry.getComponent(targetType); @@ -44,14 +50,18 @@ function getAutoMatchPairs( return []; } - const pairs: { sourceKey: string; targetKey: string }[] = []; + const pairs: AutoMatchPair[] = []; for (const s of sourceDef.connectionMeta.sendable) { - if (s.category !== "event") continue; for (const r of targetDef.connectionMeta.receivable) { - if (r.category !== "event") continue; - if (s.key === r.key) { - pairs.push({ sourceKey: s.key, targetKey: r.key }); + if (s.category === "event" && r.category === "event" && s.key === r.key) { + pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false }); + } + if (s.type === "filter_value" && r.type === "filter_value") { + pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: true }); + } + if (s.type === "all_rows" && r.type === "all_rows") { + pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false }); } } } @@ -93,10 +103,30 @@ export function useConnectionResolver({ const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`; const unsub = subscribe(sourceEvent, (payload: unknown) => { - publish(targetEvent, { - value: payload, - _connectionId: conn.id, - }); + if (pair.isFilter) { + const data = payload as Record | null; + const fieldName = data?.fieldName as string | undefined; + const filterColumns = data?.filterColumns as string[] | undefined; + const filterMode = (data?.filterMode as string) || "contains"; + // conn.filterConfig에 targetColumn이 명시되어 있으면 우선 사용 + const effectiveColumn = conn.filterConfig?.targetColumn || fieldName; + const effectiveMode = conn.filterConfig?.filterMode || filterMode; + const baseFilterConfig = effectiveColumn + ? { targetColumn: effectiveColumn, targetColumns: conn.filterConfig?.targetColumns || (filterColumns?.length ? filterColumns : [effectiveColumn]), filterMode: effectiveMode } + : conn.filterConfig; + publish(targetEvent, { + value: payload, + filterConfig: conn.filterConfig?.isSubTable + ? { ...baseFilterConfig, isSubTable: true } + : baseFilterConfig, + _connectionId: conn.id, + }); + } else { + publish(targetEvent, { + value: payload, + _connectionId: conn.id, + }); + } }); unsubscribers.push(unsub); } @@ -121,13 +151,22 @@ export function useConnectionResolver({ const unsub = subscribe(sourceEvent, (payload: unknown) => { const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`; - const enrichedPayload = { - value: payload, - filterConfig: conn.filterConfig, - _connectionId: conn.id, - }; + let resolvedFilterConfig = conn.filterConfig; + if (!resolvedFilterConfig) { + const data = payload as Record | null; + const fieldName = data?.fieldName as string | undefined; + const filterColumns = data?.filterColumns as string[] | undefined; + if (fieldName) { + const filterMode = (data?.filterMode as string) || "contains"; + resolvedFilterConfig = { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode: filterMode as "equals" | "contains" | "starts_with" | "range" }; + } + } - publish(targetEvent, enrichedPayload); + publish(targetEvent, { + value: payload, + filterConfig: resolvedFilterConfig, + _connectionId: conn.id, + }); }); unsubscribers.push(unsub); } diff --git a/frontend/hooks/useLogin.ts b/frontend/hooks/useLogin.ts index 09c32d5f..bd0cf9a2 100644 --- a/frontend/hooks/useLogin.ts +++ b/frontend/hooks/useLogin.ts @@ -20,6 +20,21 @@ export const useLogin = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(""); const [showPassword, setShowPassword] = useState(false); + const [isPopMode, setIsPopMode] = useState(false); + + // localStorage에서 POP 모드 상태 복원 + useEffect(() => { + const saved = localStorage.getItem("popLoginMode"); + if (saved === "true") setIsPopMode(true); + }, []); + + const togglePopMode = useCallback(() => { + setIsPopMode((prev) => { + const next = !prev; + localStorage.setItem("popLoginMode", String(next)); + return next; + }); + }, []); /** * 폼 입력값 변경 처리 @@ -141,17 +156,22 @@ export const useLogin = () => { // 쿠키에도 저장 (미들웨어에서 사용) document.cookie = `authToken=${result.data.token}; path=/; max-age=86400; SameSite=Lax`; - // 로그인 성공 - 첫 번째 접근 가능한 메뉴로 리다이렉트 - const firstMenuPath = result.data?.firstMenuPath; - - if (firstMenuPath) { - // 접근 가능한 메뉴가 있으면 해당 메뉴로 이동 - console.log("첫 번째 접근 가능한 메뉴로 이동:", firstMenuPath); - router.push(firstMenuPath); + if (isPopMode) { + const popPath = result.data?.popLandingPath; + if (popPath) { + router.push(popPath); + } else { + setError("POP 화면이 설정되어 있지 않습니다. 관리자에게 메뉴 관리에서 POP 화면을 설정해달라고 요청하세요."); + setIsLoading(false); + return; + } } else { - // 접근 가능한 메뉴가 없으면 메인 페이지로 이동 - console.log("접근 가능한 메뉴가 없어 메인 페이지로 이동"); - router.push(AUTH_CONFIG.ROUTES.MAIN); + const firstMenuPath = result.data?.firstMenuPath; + if (firstMenuPath) { + router.push(firstMenuPath); + } else { + router.push(AUTH_CONFIG.ROUTES.MAIN); + } } } else { // 로그인 실패 @@ -165,7 +185,7 @@ export const useLogin = () => { setIsLoading(false); } }, - [formData, validateForm, apiCall, router], + [formData, validateForm, apiCall, router, isPopMode], ); // 컴포넌트 마운트 시 기존 인증 상태 확인 @@ -179,10 +199,12 @@ export const useLogin = () => { isLoading, error, showPassword, + isPopMode, // 액션 handleInputChange, handleLogin, togglePasswordVisibility, + togglePopMode, }; }; diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index 8611aeda..7ddafaa0 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -42,6 +42,8 @@ export interface MenuItem { TRANSLATED_DESC?: string; menu_icon?: string; MENU_ICON?: string; + screen_code?: string; + SCREEN_CODE?: string; } export interface MenuFormData { @@ -79,6 +81,23 @@ export interface ApiResponse { errorCode?: string; } +export interface PopMenuItem { + objid: string; + menu_name_kor: string; + menu_url: string; + menu_desc: string; + seq: number; + company_code: string; + status: string; + screenId?: number; +} + +export interface PopMenuResponse { + parentMenu: PopMenuItem | null; + childMenus: PopMenuItem[]; + landingMenu: PopMenuItem | null; +} + export const menuApi = { // 관리자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시) getAdminMenus: async (): Promise> => { @@ -94,6 +113,12 @@ export const menuApi = { return response.data; }, + // POP 메뉴 목록 조회 ([POP] 태그 L1 하위 active 메뉴) + getPopMenus: async (): Promise> => { + const response = await apiClient.get("/admin/pop-menus"); + return response.data; + }, + // 관리자 메뉴 목록 조회 (메뉴 관리 화면용 - 모든 상태 표시) getAdminMenusForManagement: async (): Promise> => { const response = await apiClient.get("/admin/menus", { params: { menuType: "0", includeInactive: "true" } }); diff --git a/frontend/lib/api/tableManagement.ts b/frontend/lib/api/tableManagement.ts index 24ef25a0..50824d7b 100644 --- a/frontend/lib/api/tableManagement.ts +++ b/frontend/lib/api/tableManagement.ts @@ -372,3 +372,30 @@ export const getTableColumns = (tableName: string) => tableManagementApi.getColu export const updateColumnType = (tableName: string, columnName: string, settings: ColumnSettings) => tableManagementApi.updateColumnSettings(tableName, columnName, settings); export const checkTableExists = (tableName: string) => tableManagementApi.checkTableExists(tableName); + +// 엑셀 업로드 전 데이터 검증 API +export interface ExcelValidationResult { + isValid: boolean; + notNullErrors: { row: number; column: string; label: string }[]; + uniqueInExcelErrors: { rows: number[]; column: string; label: string; value: string }[]; + uniqueInDbErrors: { row: number; column: string; label: string; value: string }[]; + summary: { notNull: number; uniqueInExcel: number; uniqueInDb: number }; +} + +export async function validateExcelData( + tableName: string, + data: Record[] +): Promise> { + try { + const response = await apiClient.post>( + "/table-management/validate-excel", + { tableName, data } + ); + return response.data; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || "데이터 검증 실패", + }; + } +} diff --git a/frontend/lib/registry/PopComponentRegistry.ts b/frontend/lib/registry/PopComponentRegistry.ts index 3793bc2d..2fe44592 100644 --- a/frontend/lib/registry/PopComponentRegistry.ts +++ b/frontend/lib/registry/PopComponentRegistry.ts @@ -35,6 +35,7 @@ export interface PopComponentDefinition { preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용 defaultProps?: Record; connectionMeta?: ComponentConnectionMeta; + getDynamicConnectionMeta?: (config: Record) => ComponentConnectionMeta; // POP 전용 속성 touchOptimized?: boolean; minTouchArea?: number; diff --git a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx index 63a4288a..4c3d3112 100644 --- a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx +++ b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx @@ -20,6 +20,7 @@ import { GeneratedLocation, RackStructureContext, } from "./types"; +import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils"; // 기존 위치 데이터 타입 interface ExistingLocation { @@ -512,23 +513,27 @@ export const RackStructureComponent: React.FC = ({ return { totalLocations, totalRows, maxLevel }; }, [conditions]); - // 위치 코드 생성 + // 위치 코드 생성 (패턴 기반) 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 vars = { + warehouse: context?.warehouseCode || "WH001", + warehouseName: context?.warehouseName || "", + floor: context?.floor || "1", + zone: context?.zone || "A", + row, + level, + }; - // 코드 생성 (예: WH001-1층D구역-01-1) - const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; + const codePattern = config.codePattern || DEFAULT_CODE_PATTERN; + const namePattern = config.namePattern || DEFAULT_NAME_PATTERN; - // 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용 - const zoneName = zone.includes("구역") ? zone : `${zone}구역`; - const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`; - - return { code, name }; + return { + code: applyLocationPattern(codePattern, vars), + name: applyLocationPattern(namePattern, vars), + }; }, - [context], + [context, config.codePattern, config.namePattern], ); // 미리보기 생성 diff --git a/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx b/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx index 17e1a781..ddaebfa2 100644 --- a/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx +++ b/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; @@ -12,6 +12,47 @@ import { SelectValue, } from "@/components/ui/select"; import { RackStructureComponentConfig, FieldMapping } from "./types"; +import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN, PATTERN_VARIABLES } from "./patternUtils"; + +// 패턴 미리보기 서브 컴포넌트 +const PatternPreview: React.FC<{ + codePattern?: string; + namePattern?: string; +}> = ({ codePattern, namePattern }) => { + const sampleVars = { + warehouse: "WH002", + warehouseName: "2창고", + floor: "2층", + zone: "A구역", + row: 1, + level: 3, + }; + + const previewCode = useMemo( + () => applyLocationPattern(codePattern || DEFAULT_CODE_PATTERN, sampleVars), + [codePattern], + ); + const previewName = useMemo( + () => applyLocationPattern(namePattern || DEFAULT_NAME_PATTERN, sampleVars), + [namePattern], + ); + + return ( +
+
미리보기 (2창고 / 2층 / A구역 / 1열 / 3단)
+
+
+ 위치코드: + {previewCode} +
+
+ 위치명: + {previewName} +
+
+
+ ); +}; interface RackStructureConfigPanelProps { config: RackStructureComponentConfig; @@ -205,6 +246,61 @@ export const RackStructureConfigPanel: React.FC =
+ {/* 위치코드 패턴 설정 */} +
+
위치코드/위치명 패턴
+

+ 변수를 조합하여 위치코드와 위치명 생성 규칙을 설정하세요 +

+ + {/* 위치코드 패턴 */} +
+ + handleChange("codePattern", e.target.value || undefined)} + placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}" + className="h-8 font-mono text-xs" + /> +

+ 비워두면 기본값: {"{warehouse}-{floor}{zone}-{row:02}-{level}"} +

+
+ + {/* 위치명 패턴 */} +
+ + handleChange("namePattern", e.target.value || undefined)} + placeholder="{zone}-{row:02}열-{level}단" + className="h-8 font-mono text-xs" + /> +

+ 비워두면 기본값: {"{zone}-{row:02}열-{level}단"} +

+
+ + {/* 실시간 미리보기 */} + + + {/* 사용 가능한 변수 목록 */} +
+
사용 가능한 변수
+
+ {PATTERN_VARIABLES.map((v) => ( +
+ {v.token} + {v.description} +
+ ))} +
+
+
+ {/* 제한 설정 */}
제한 설정
diff --git a/frontend/lib/registry/components/rack-structure/patternUtils.ts b/frontend/lib/registry/components/rack-structure/patternUtils.ts new file mode 100644 index 00000000..b5139c0b --- /dev/null +++ b/frontend/lib/registry/components/rack-structure/patternUtils.ts @@ -0,0 +1,7 @@ +// rack-structure는 v2-rack-structure의 patternUtils를 재사용 +export { + applyLocationPattern, + DEFAULT_CODE_PATTERN, + DEFAULT_NAME_PATTERN, + PATTERN_VARIABLES, +} from "../v2-rack-structure/patternUtils"; diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index f3ed2145..f5d5666b 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 () => { diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx index 90c4f801..b6f929be 100644 --- a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -1,10 +1,78 @@ "use client"; -import React from "react"; +import React, { useEffect, useRef } from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { V2InputDefinition } from "./index"; import { V2Input } from "@/components/v2/V2Input"; import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer"; +import { v2EventBus, V2_EVENTS } from "@/lib/v2-core"; + +/** + * dataBinding이 설정된 v2-input을 위한 wrapper + * v2-table-list의 TABLE_DATA_CHANGE 이벤트를 구독하여 + * 선택된 행의 특정 컬럼 값을 자동으로 formData에 반영 + */ +function DataBindingWrapper({ + dataBinding, + columnName, + onFormDataChange, + isInteractive, + children, +}: { + dataBinding: { sourceComponentId: string; sourceColumn: string }; + columnName: string; + onFormDataChange?: (field: string, value: any) => void; + isInteractive?: boolean; + children: React.ReactNode; +}) { + const lastBoundValueRef = useRef(null); + + useEffect(() => { + if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return; + + console.log("[DataBinding] 구독 시작:", { + sourceComponentId: dataBinding.sourceComponentId, + sourceColumn: dataBinding.sourceColumn, + targetColumn: columnName, + isInteractive, + hasOnFormDataChange: !!onFormDataChange, + }); + + const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_DATA_CHANGE, (payload: any) => { + console.log("[DataBinding] TABLE_DATA_CHANGE 수신:", { + payloadSource: payload.source, + expectedSource: dataBinding.sourceComponentId, + dataLength: payload.data?.length, + match: payload.source === dataBinding.sourceComponentId, + }); + + if (payload.source !== dataBinding.sourceComponentId) return; + + const selectedData = payload.data; + if (selectedData && selectedData.length > 0) { + const value = selectedData[0][dataBinding.sourceColumn]; + console.log("[DataBinding] 바인딩 값:", { column: dataBinding.sourceColumn, value, columnName }); + if (value !== lastBoundValueRef.current) { + lastBoundValueRef.current = value; + if (onFormDataChange && columnName) { + onFormDataChange(columnName, value ?? ""); + } + } + } else { + if (lastBoundValueRef.current !== null) { + lastBoundValueRef.current = null; + if (onFormDataChange && columnName) { + onFormDataChange(columnName, ""); + } + } + } + }); + + return () => unsubscribe(); + }, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName, onFormDataChange, isInteractive]); + + return <>{children}; +} /** * V2Input 렌더러 @@ -16,41 +84,37 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer { render(): React.ReactElement { const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; - // 컴포넌트 설정 추출 const config = component.componentConfig || component.config || {}; const columnName = component.columnName; const tableName = component.tableName || this.props.tableName; - // formData에서 현재 값 가져오기 const currentValue = formData?.[columnName] ?? component.value ?? ""; - // 값 변경 핸들러 const handleChange = (value: any) => { - console.log("🔄 [V2InputRenderer] handleChange 호출:", { - columnName, - value, - isInteractive, - hasOnFormDataChange: !!onFormDataChange, - }); if (isInteractive && onFormDataChange && columnName) { onFormDataChange(columnName, value); - } else { - console.warn("⚠️ [V2InputRenderer] onFormDataChange 호출 스킵:", { - isInteractive, - hasOnFormDataChange: !!onFormDataChange, - columnName, - }); } }; - // 라벨: style.labelText 우선, 없으면 component.label 사용 - // 🔧 style.labelDisplay를 먼저 체크 (속성 패널에서 style 객체로 업데이트하므로) const style = component.style || {}; const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay; - // labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김) const effectiveLabel = labelDisplay === true ? style.labelText || component.label : undefined; - return ( + const dataBinding = config.dataBinding || (component as any).dataBinding || config.componentConfig?.dataBinding; + + if (dataBinding || (config as any).dataBinding || (component as any).dataBinding) { + console.log("[V2InputRenderer] dataBinding 탐색:", { + componentId: component.id, + columnName, + configKeys: Object.keys(config), + configDataBinding: config.dataBinding, + componentDataBinding: (component as any).dataBinding, + nestedDataBinding: config.componentConfig?.dataBinding, + finalDataBinding: dataBinding, + }); + } + + const inputElement = ( ); + + // dataBinding이 있으면 wrapper로 감싸서 이벤트 구독 + if (dataBinding?.sourceComponentId && dataBinding?.sourceColumn) { + return ( + + {inputElement} + + ); + } + + return inputElement; } } diff --git a/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx index 67587110..ee73fa07 100644 --- a/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; @@ -222,6 +222,61 @@ export const RackStructureConfigPanel: React.FC =
+ {/* 위치코드 패턴 설정 */} +
+
위치코드/위치명 패턴
+

+ 변수를 조합하여 위치코드와 위치명 생성 규칙을 설정하세요 +

+ + {/* 위치코드 패턴 */} +
+ + handleChange("codePattern", e.target.value || undefined)} + placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}" + className="h-8 font-mono text-xs" + /> +

+ 비워두면 기본값: {"{warehouse}-{floor}{zone}-{row:02}-{level}"} +

+
+ + {/* 위치명 패턴 */} +
+ + handleChange("namePattern", e.target.value || undefined)} + placeholder="{zone}-{row:02}열-{level}단" + className="h-8 font-mono text-xs" + /> +

+ 비워두면 기본값: {"{zone}-{row:02}열-{level}단"} +

+
+ + {/* 실시간 미리보기 */} + + + {/* 사용 가능한 변수 목록 */} +
+
사용 가능한 변수
+
+ {PATTERN_VARIABLES.map((v) => ( +
+ {v.token} + {v.description} +
+ ))} +
+
+
+ {/* 제한 설정 */}
제한 설정
diff --git a/frontend/lib/registry/components/v2-rack-structure/patternUtils.ts b/frontend/lib/registry/components/v2-rack-structure/patternUtils.ts new file mode 100644 index 00000000..d226db82 --- /dev/null +++ b/frontend/lib/registry/components/v2-rack-structure/patternUtils.ts @@ -0,0 +1,81 @@ +/** + * 위치코드/위치명 패턴 변환 유틸리티 + * + * 사용 가능한 변수: + * {warehouse} - 창고 코드 (예: WH002) + * {warehouseName} - 창고명 (예: 2창고) + * {floor} - 층 (예: 2층) + * {zone} - 구역 (예: A구역) + * {row} - 열 번호 (예: 1) + * {row:02} - 열 번호 2자리 (예: 01) + * {row:03} - 열 번호 3자리 (예: 001) + * {level} - 단 번호 (예: 1) + * {level:02} - 단 번호 2자리 (예: 01) + * {level:03} - 단 번호 3자리 (예: 001) + */ + +interface PatternVariables { + warehouse?: string; + warehouseName?: string; + floor?: string; + zone?: string; + row: number; + level: number; +} + +// 기본 패턴 (하드코딩 대체) +export const DEFAULT_CODE_PATTERN = "{warehouse}-{floor}{zone}-{row:02}-{level}"; +export const DEFAULT_NAME_PATTERN = "{zone}-{row:02}열-{level}단"; + +/** + * 패턴 문자열에서 변수를 치환하여 결과 문자열 반환 + */ +export function applyLocationPattern(pattern: string, vars: PatternVariables): string { + let result = pattern; + + // zone에 "구역" 포함 여부에 따른 처리 없이 있는 그대로 치환 + const simpleVars: Record = { + warehouse: vars.warehouse, + warehouseName: vars.warehouseName, + floor: vars.floor, + zone: vars.zone, + }; + + // 단순 문자열 변수 치환 + for (const [key, value] of Object.entries(simpleVars)) { + result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value || ""); + } + + // 숫자 변수 (row, level) - zero-pad 지원 + const numericVars: Record = { + row: vars.row, + level: vars.level, + }; + + for (const [key, value] of Object.entries(numericVars)) { + // {row:02}, {level:03} 같은 zero-pad 패턴 + const padRegex = new RegExp(`\\{${key}:(\\d+)\\}`, "g"); + result = result.replace(padRegex, (_, padWidth) => { + return value.toString().padStart(parseInt(padWidth), "0"); + }); + + // {row}, {level} 같은 단순 패턴 + result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value.toString()); + } + + return result; +} + +// 패턴에서 사용 가능한 변수 목록 +export const PATTERN_VARIABLES = [ + { token: "{warehouse}", description: "창고 코드", example: "WH002" }, + { token: "{warehouseName}", description: "창고명", example: "2창고" }, + { token: "{floor}", description: "층", example: "2층" }, + { token: "{zone}", description: "구역", example: "A구역" }, + { token: "{row}", description: "열 번호", example: "1" }, + { token: "{row:02}", description: "열 번호 (2자리)", example: "01" }, + { token: "{row:03}", description: "열 번호 (3자리)", example: "001" }, + { token: "{level}", description: "단 번호", example: "1" }, + { token: "{level:02}", description: "단 번호 (2자리)", example: "01" }, + { token: "{level:03}", description: "단 번호 (3자리)", example: "001" }, +]; diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 94ab366a..0d6c2c3f 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1001,23 +1001,24 @@ export const SplitPanelLayoutComponent: React.FC return formatNumberValue(value, format); } - // 🆕 카테고리 매핑 찾기 (여러 키 형태 시도) + // 카테고리 매핑 찾기 (여러 키 형태 시도) // 1. 전체 컬럼명 (예: "item_info.material") // 2. 컬럼명만 (예: "material") + // 3. 전역 폴백: 모든 매핑에서 value 검색 let mapping = categoryMappings[columnName]; if (!mapping && columnName.includes(".")) { - // 조인된 컬럼의 경우 컬럼명만으로 다시 시도 const simpleColumnName = columnName.split(".").pop() || columnName; mapping = categoryMappings[simpleColumnName]; } - if (mapping && mapping[String(value)]) { - const categoryData = mapping[String(value)]; - const displayLabel = categoryData.label || String(value); + const strValue = String(value); + + if (mapping && mapping[strValue]) { + const categoryData = mapping[strValue]; + const displayLabel = categoryData.label || strValue; const displayColor = categoryData.color || "#64748b"; - // 배지로 표시 return ( ); } + // 전역 폴백: 컬럼명으로 매핑을 못 찾았을 때, 전체 매핑에서 값 검색 + if (!mapping && (strValue.startsWith("CAT_") || strValue.startsWith("CATEGORY_"))) { + for (const key of Object.keys(categoryMappings)) { + const m = categoryMappings[key]; + if (m && m[strValue]) { + const categoryData = m[strValue]; + const displayLabel = categoryData.label || strValue; + const displayColor = categoryData.color || "#64748b"; + return ( + + {displayLabel} + + ); + } + } + } + // 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체) if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) { return formatDateValue(value, "YYYY-MM-DD"); @@ -1150,10 +1174,44 @@ export const SplitPanelLayoutComponent: React.FC console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]); } + // 좌측 패널 dataFilter 클라이언트 사이드 적용 + let filteredLeftData = result.data || []; + const leftDataFilter = componentConfig.leftPanel?.dataFilter; + if (leftDataFilter?.enabled && leftDataFilter.filters?.length > 0) { + const matchFn = leftDataFilter.matchType === "any" ? "some" : "every"; + filteredLeftData = filteredLeftData.filter((item: any) => { + return leftDataFilter.filters[matchFn]((cond: any) => { + const val = item[cond.columnName]; + switch (cond.operator) { + case "equals": + return val === cond.value; + case "not_equals": + return val !== cond.value; + case "in": { + const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; + return arr.includes(val); + } + case "not_in": { + const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; + return !arr.includes(val); + } + case "contains": + return String(val || "").includes(String(cond.value)); + case "is_null": + return val === null || val === undefined || val === ""; + case "is_not_null": + return val !== null && val !== undefined && val !== ""; + default: + return true; + } + }); + }); + } + // 가나다순 정렬 (좌측 패널의 표시 컬럼 기준) const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; - if (leftColumn && result.data.length > 0) { - result.data.sort((a, b) => { + if (leftColumn && filteredLeftData.length > 0) { + filteredLeftData.sort((a, b) => { const aValue = String(a[leftColumn] || ""); const bValue = String(b[leftColumn] || ""); return aValue.localeCompare(bValue, "ko-KR"); @@ -1161,7 +1219,7 @@ export const SplitPanelLayoutComponent: React.FC } // 계층 구조 빌드 - const hierarchicalData = buildHierarchy(result.data); + const hierarchicalData = buildHierarchy(filteredLeftData); setLeftData(hierarchicalData); } catch (error) { console.error("좌측 데이터 로드 실패:", error); @@ -1220,7 +1278,16 @@ export const SplitPanelLayoutComponent: React.FC case "equals": return value === cond.value; case "notEquals": + case "not_equals": return value !== cond.value; + case "in": { + const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; + return arr.includes(value); + } + case "not_in": { + const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; + return !arr.includes(value); + } case "contains": return String(value || "").includes(String(cond.value)); case "is_null": @@ -1537,7 +1604,16 @@ export const SplitPanelLayoutComponent: React.FC case "equals": return value === cond.value; case "notEquals": + case "not_equals": return value !== cond.value; + case "in": { + const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; + return arr.includes(value); + } + case "not_in": { + const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; + return !arr.includes(value); + } case "contains": return String(value || "").includes(String(cond.value)); case "is_null": @@ -1929,43 +2005,59 @@ export const SplitPanelLayoutComponent: React.FC loadRightTableColumns(); }, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]); - // 좌측 테이블 카테고리 매핑 로드 + // 좌측 테이블 카테고리 매핑 로드 (조인된 테이블 포함) useEffect(() => { const loadLeftCategoryMappings = async () => { const leftTableName = componentConfig.leftPanel?.tableName; if (!leftTableName || isDesignMode) return; try { - // 1. 컬럼 메타 정보 조회 - const columnsResponse = await tableTypeApi.getColumns(leftTableName); - const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category"); - - if (categoryColumns.length === 0) { - setLeftCategoryMappings({}); - return; - } - - // 2. 각 카테고리 컬럼에 대한 값 조회 const mappings: Record> = {}; + const tablesToLoad = new Set([leftTableName]); - for (const col of categoryColumns) { - const columnName = col.columnName || col.column_name; + // 좌측 패널 컬럼 설정에서 조인된 테이블 추출 + const leftColumns = componentConfig.leftPanel?.columns || []; + leftColumns.forEach((col: any) => { + const colName = col.name || col.columnName; + if (colName && colName.includes(".")) { + const joinTableName = colName.split(".")[0]; + tablesToLoad.add(joinTableName); + } + }); + + // 각 테이블에 대해 카테고리 매핑 로드 + for (const tableName of tablesToLoad) { try { - const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values?includeInactive=true`); + const columnsResponse = await tableTypeApi.getColumns(tableName); + const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category"); - if (response.data.success && response.data.data) { - const valueMap: Record = {}; - response.data.data.forEach((item: any) => { - valueMap[item.value_code || item.valueCode] = { - label: item.value_label || item.valueLabel, - color: item.color, - }; - }); - mappings[columnName] = valueMap; - console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap); + for (const col of categoryColumns) { + const columnName = col.columnName || col.column_name; + try { + const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`); + + if (response.data.success && response.data.data) { + const valueMap: Record = {}; + response.data.data.forEach((item: any) => { + valueMap[item.value_code || item.valueCode] = { + label: item.value_label || item.valueLabel, + color: item.color, + }; + }); + + // 조인된 테이블은 "테이블명.컬럼명" 형태로도 저장 + const mappingKey = tableName === leftTableName ? columnName : `${tableName}.${columnName}`; + mappings[mappingKey] = valueMap; + + // 컬럼명만으로도 접근 가능하도록 추가 저장 + mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap }; + } + } catch (error) { + console.error(`좌측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error); + } } } catch (error) { - console.error(`좌측 카테고리 값 조회 실패 [${columnName}]:`, error); + console.error(`좌측 카테고리 테이블 컬럼 조회 실패 [${tableName}]:`, error); } } @@ -1976,7 +2068,7 @@ export const SplitPanelLayoutComponent: React.FC }; loadLeftCategoryMappings(); - }, [componentConfig.leftPanel?.tableName, isDesignMode]); + }, [componentConfig.leftPanel?.tableName, componentConfig.leftPanel?.columns, isDesignMode]); // 우측 테이블 카테고리 매핑 로드 (조인된 테이블 포함) useEffect(() => { @@ -3668,9 +3760,22 @@ export const SplitPanelLayoutComponent: React.FC displayFields = configuredColumns.slice(0, 2).map((col: any) => { const colName = typeof col === "string" ? col : col.name || col.columnName; const colLabel = typeof col === "object" ? col.label : leftColumnLabels[colName] || colName; + const rawValue = getEntityJoinValue(item, colName); + // 카테고리 매핑이 있으면 라벨로 변환 + let displayValue = rawValue; + if (rawValue != null && rawValue !== "") { + const strVal = String(rawValue); + let mapping = leftCategoryMappings[colName]; + if (!mapping && colName.includes(".")) { + mapping = leftCategoryMappings[colName.split(".").pop() || colName]; + } + if (mapping && mapping[strVal]) { + displayValue = mapping[strVal].label; + } + } return { label: colLabel, - value: item[colName], + value: displayValue, }; }); @@ -3682,10 +3787,21 @@ export const SplitPanelLayoutComponent: React.FC const keys = Object.keys(item).filter( (k) => k !== "id" && k !== "ID" && k !== "children" && k !== "level" && shouldShowField(k), ); - displayFields = keys.slice(0, 2).map((key) => ({ - label: leftColumnLabels[key] || key, - value: item[key], - })); + displayFields = keys.slice(0, 2).map((key) => { + const rawValue = item[key]; + let displayValue = rawValue; + if (rawValue != null && rawValue !== "") { + const strVal = String(rawValue); + const mapping = leftCategoryMappings[key]; + if (mapping && mapping[strVal]) { + displayValue = mapping[strVal].label; + } + } + return { + label: leftColumnLabels[key] || key, + value: displayValue, + }; + }); if (index === 0) { console.log(" ⚠️ 설정된 컬럼 없음, 자동 선택:", displayFields); @@ -5103,6 +5219,7 @@ export const SplitPanelLayoutComponent: React.FC }} /> )} +
); }; diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 4516c197..cfdd2572 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -1932,7 +1932,7 @@ export const SplitPanelLayoutConfigPanel: React.FC !open && setActiveModal(null)}> - + 기본 설정 패널 관계 타입 및 레이아웃을 설정합니다 @@ -2010,7 +2010,7 @@ export const SplitPanelLayoutConfigPanel: React.FC !open && setActiveModal(null)}> - + 좌측 패널 설정 마스터 데이터 표시 및 필터링을 설정합니다 @@ -2680,7 +2680,7 @@ export const SplitPanelLayoutConfigPanel: React.FC !open && setActiveModal(null)}> - + 우측 패널 설정 @@ -3604,7 +3604,7 @@ export const SplitPanelLayoutConfigPanel: React.FC !open && setActiveModal(null)}> - + 추가 탭 설정 diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index b738d317..ed41f578 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -118,9 +118,9 @@ export interface AdditionalTabConfig { // 추가 버튼 설정 (모달 화면 연결 지원) addButton?: { enabled: boolean; - mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 - modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) - buttonLabel?: string; // 버튼 라벨 (기본: "추가") + mode: "auto" | "modal"; + modalScreenId?: number; + buttonLabel?: string; }; deleteButton?: { @@ -161,9 +161,9 @@ export interface SplitPanelLayoutConfig { // 추가 버튼 설정 (모달 화면 연결 지원) addButton?: { enabled: boolean; - mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 - modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) - buttonLabel?: string; // 버튼 라벨 (기본: "추가") + mode: "auto" | "modal"; + modalScreenId?: number; + buttonLabel?: string; }; columns?: Array<{ @@ -334,10 +334,10 @@ export interface SplitPanelLayoutConfig { // 🆕 추가 버튼 설정 (모달 화면 연결 지원) addButton?: { - enabled: boolean; // 추가 버튼 표시 여부 (기본: true) - mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 - modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) - buttonLabel?: string; // 버튼 라벨 (기본: "추가") + enabled: boolean; + mode: "auto" | "modal"; + modalScreenId?: number; + buttonLabel?: string; }; // 🆕 삭제 버튼 설정 diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 63cdc3f2..7843cb4b 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -11,6 +11,7 @@ import { getFullImageUrl } from "@/lib/api/client"; import { getFilePreviewUrl } from "@/lib/api/file"; import { Button } from "@/components/ui/button"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; +import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor"; import { useTabId } from "@/contexts/TabIdContext"; // 🖼️ 테이블 셀 이미지 썸네일 컴포넌트 @@ -407,7 +408,7 @@ export const TableListComponent: React.FC = ({ const currentTabId = useTabId(); - const buttonColor = component.style?.labelColor || "#212121"; + const buttonColor = getAdaptiveLabelColor(component.style?.labelColor); const buttonTextColor = component.config?.buttonTextColor || "#ffffff"; const gridColumns = component.gridColumns || 1; @@ -728,7 +729,7 @@ export const TableListComponent: React.FC = ({ const [categoryMappings, setCategoryMappings] = useState< Record> >({}); - const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용 + const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); const [searchValues, setSearchValues] = useState>({}); const [selectedRows, setSelectedRows] = useState>(new Set()); const [columnWidths, setColumnWidths] = useState>({}); @@ -1063,9 +1064,14 @@ export const TableListComponent: React.FC = ({ const getColumnUniqueValues = async (columnName: string) => { const { apiClient } = await import("@/lib/api/client"); + // 최고관리자가 특정 회사 프리뷰 시 해당 회사 카테고리만 필터링 + const filterParam = companyCode && companyCode !== "*" + ? `?filterCompanyCode=${encodeURIComponent(companyCode)}` + : ""; + // 1단계: 카테고리 API 시도 (columnMeta 무관하게 항상 시도) try { - const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`); + const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values${filterParam}`); if (response.data.success && response.data.data && response.data.data.length > 0) { return response.data.data.map((item: any) => ({ value: item.valueCode, @@ -1170,15 +1176,13 @@ export const TableListComponent: React.FC = ({ tableConfig.selectedTable, tableConfig.columns, columnLabels, - columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요) - categoryMappings, // 카테고리 매핑 변경 시 재등록 (필터 라벨 변환용) + columnMeta, + categoryMappings, columnWidths, tableLabel, - data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용) - totalItems, // 전체 항목 수가 변경되면 재등록 + data, + totalItems, registerTable, - // unregisterTable은 의존성에서 제외 - 무한 루프 방지 - // unregisterTable 함수는 의존성이 없어 안정적임 ]); // 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 (없으면 defaultSort 적용) @@ -1422,7 +1426,13 @@ export const TableListComponent: React.FC = ({ const mappings: Record> = {}; const apiClient = (await import("@/lib/api/client")).apiClient; + // 최고관리자가 특정 회사 프리뷰 시 해당 회사 카테고리만 필터링 + const filterCompanyParam = companyCode && companyCode !== "*" + ? `&filterCompanyCode=${encodeURIComponent(companyCode)}` + : ""; + // 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용) + // valueCode만 키로 사용 (valueId까지 넣으면 같은 라벨이 2번 나옴) const flattenTree = (items: any[], mapping: Record) => { items.forEach((item: any) => { if (item.valueCode) { @@ -1431,12 +1441,6 @@ export const TableListComponent: React.FC = ({ color: item.color, }; } - if (item.valueId !== undefined && item.valueId !== null) { - mapping[String(item.valueId)] = { - label: item.valueLabel, - color: item.color, - }; - } if (item.children && Array.isArray(item.children) && item.children.length > 0) { flattenTree(item.children, mapping); } @@ -1464,7 +1468,7 @@ export const TableListComponent: React.FC = ({ } // 비활성화된 카테고리도 라벨로 표시하기 위해 includeInactive=true - const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values?includeInactive=true`); + const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values?includeInactive=true${filterCompanyParam}`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; @@ -1547,7 +1551,7 @@ export const TableListComponent: React.FC = ({ // inputType이 category인 경우 카테고리 매핑 로드 if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) { try { - const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values?includeInactive=true`); + const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values?includeInactive=true${filterCompanyParam}`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; @@ -1617,6 +1621,7 @@ export const TableListComponent: React.FC = ({ JSON.stringify(categoryColumns), JSON.stringify(tableConfig.columns), columnMeta, + companyCode, ]); // ======================================== @@ -2108,11 +2113,19 @@ export const TableListComponent: React.FC = ({ }; const handleRowSelection = (rowKey: string, checked: boolean) => { - const newSelectedRows = new Set(selectedRows); - if (checked) { - newSelectedRows.add(rowKey); + const isMultiSelect = tableConfig.checkbox?.multiple !== false; + let newSelectedRows: Set; + + if (isMultiSelect) { + newSelectedRows = new Set(selectedRows); + if (checked) { + newSelectedRows.add(rowKey); + } else { + newSelectedRows.delete(rowKey); + } } else { - newSelectedRows.delete(rowKey); + // 단일 선택: 기존 선택 해제 후 새 항목만 선택 + newSelectedRows = checked ? new Set([rowKey]) : new Set(); } setSelectedRows(newSelectedRows); @@ -4182,6 +4195,7 @@ export const TableListComponent: React.FC = ({ const renderCheckboxHeader = () => { if (!tableConfig.checkbox?.selectAll) return null; + if (tableConfig.checkbox?.multiple === false) return null; return ; }; diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx index 759c57dd..c5f1ad54 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx @@ -1508,7 +1508,38 @@ export const TableListConfigPanel: React.FC = ({ /> {column.columnLabel} - + {isAlreadyAdded && ( + + )} + {column.inputType || column.dataType} diff --git a/frontend/lib/registry/components/v2-table-list/config-panels/ColumnsConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/config-panels/ColumnsConfigPanel.tsx index 22817ee0..d8905f76 100644 --- a/frontend/lib/registry/components/v2-table-list/config-panels/ColumnsConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/config-panels/ColumnsConfigPanel.tsx @@ -457,7 +457,38 @@ export const ColumnsConfigPanel: React.FC = ({ /> {column.columnLabel} - + {isAlreadyAdded && ( + + )} + {column.inputType || column.dataType} diff --git a/frontend/lib/registry/pop-components/index.ts b/frontend/lib/registry/pop-components/index.ts index 85d07e83..351d6700 100644 --- a/frontend/lib/registry/pop-components/index.ts +++ b/frontend/lib/registry/pop-components/index.ts @@ -16,12 +16,13 @@ import "./pop-text"; import "./pop-icon"; import "./pop-dashboard"; import "./pop-card-list"; +import "./pop-card-list-v2"; import "./pop-button"; import "./pop-string-list"; import "./pop-search"; +import "./pop-status-bar"; import "./pop-field"; - -// 향후 추가될 컴포넌트들: -// import "./pop-list"; +import "./pop-scanner"; +import "./pop-profile"; diff --git a/frontend/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx index 616f0a6b..3746272c 100644 --- a/frontend/lib/registry/pop-components/pop-button.tsx +++ b/frontend/lib/registry/pop-components/pop-button.tsx @@ -17,6 +17,19 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; import { usePopAction } from "@/hooks/pop/usePopAction"; import { executeTaskList, type CollectedPayload } from "@/hooks/pop/executePopAction"; @@ -45,6 +58,7 @@ import { PackageCheck, ChevronRight, GripVertical, + ChevronsUpDown, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -215,9 +229,11 @@ export interface ButtonTask { apiEndpoint?: string; apiMethod?: "GET" | "POST" | "PUT" | "DELETE"; - // custom-event + // custom-event (제어 실행) eventName?: string; eventPayload?: Record; + flowId?: number; + flowName?: string; } /** 빠른 시작 템플릿 */ @@ -333,7 +349,7 @@ export const TASK_TYPE_LABELS: Record = { "close-modal": "모달 닫기", refresh: "새로고침", "api-call": "API 호출", - "custom-event": "커스텀 이벤트", + "custom-event": "제어 실행", }; /** 빠른 시작 템플릿별 기본 작업 목록 + 외형 */ @@ -1257,8 +1273,97 @@ interface PopButtonConfigPanelProps { componentId?: string; } -export function PopButtonConfigPanel({ config, onUpdate }: PopButtonConfigPanelProps) { +/** 연결된 컴포넌트에서 사용 가능한 필드 목록 추출 (연결 기반) */ +function extractConnectedFields( + componentId?: string, + connections?: PopButtonConfigPanelProps["connections"], + allComponents?: PopButtonConfigPanelProps["allComponents"], +): { value: string; label: string; source: string }[] { + if (!componentId || !connections || !allComponents) return []; + + const targetIds = connections + .filter((c) => c.sourceComponent === componentId || c.targetComponent === componentId) + .map((c) => (c.sourceComponent === componentId ? c.targetComponent : c.sourceComponent)); + const uniqueIds = [...new Set(targetIds)]; + if (uniqueIds.length === 0) return []; + + const fields: { value: string; label: string; source: string }[] = []; + + for (const tid of uniqueIds) { + const comp = allComponents.find((c) => c.id === tid); + if (!comp?.config) continue; + const cfg = comp.config as Record; + const compLabel = (comp as Record).label as string || comp.type || tid; + + if (comp.type === "pop-card-list") { + const tpl = cfg.cardTemplate as + | { header?: Record; body?: { fields?: { id?: string; label?: string; valueType?: string; columnName?: string }[] } } + | undefined; + if (tpl) { + if (tpl.header?.codeField) { + fields.push({ value: String(tpl.header.codeField), label: String(tpl.header.codeField), source: compLabel }); + } + if (tpl.header?.titleField) { + fields.push({ value: String(tpl.header.titleField), label: String(tpl.header.titleField), source: compLabel }); + } + for (const f of tpl.body?.fields ?? []) { + if (f.valueType === "column" && f.columnName) { + fields.push({ value: f.columnName, label: f.label || f.columnName, source: compLabel }); + } else if (f.valueType === "formula" && f.label) { + fields.push({ value: `__formula_${f.id || f.label}`, label: f.label, source: `${compLabel} (수식)` }); + } + } + } + fields.push({ value: "__cart_quantity", label: "사용자 입력 수량", source: `${compLabel} (장바구니)` }); + fields.push({ value: "__cart_row_key", label: "선택한 카드의 원본 키", source: `${compLabel} (장바구니)` }); + fields.push({ value: "__cart_id", label: "장바구니 항목 ID", source: `${compLabel} (장바구니)` }); + } + + if (comp.type === "pop-field") { + const sections = cfg.sections as Array<{ + fields?: Array<{ id: string; fieldName?: string; labelText?: string }>; + }> | undefined; + if (Array.isArray(sections)) { + for (const section of sections) { + for (const f of section.fields ?? []) { + if (f.fieldName) { + fields.push({ value: f.fieldName, label: f.labelText || f.fieldName, source: compLabel }); + } + } + } + } + } + + if (comp.type === "pop-search") { + const filterCols = cfg.filterColumns as string[] | undefined; + const modalCfg = cfg.modalConfig as { valueField?: string } | undefined; + if (Array.isArray(filterCols) && filterCols.length > 0) { + for (const col of filterCols) { + fields.push({ value: col, label: col, source: compLabel }); + } + } else if (modalCfg?.valueField) { + fields.push({ value: modalCfg.valueField, label: modalCfg.valueField, source: compLabel }); + } else if (cfg.fieldName && typeof cfg.fieldName === "string") { + fields.push({ value: cfg.fieldName, label: (cfg.placeholder as string) || cfg.fieldName, source: compLabel }); + } + } + } + + return fields; +} + +export function PopButtonConfigPanel({ + config, + onUpdate, + allComponents, + connections, + componentId, +}: PopButtonConfigPanelProps) { const v2 = useMemo(() => migrateButtonConfig(config), [config]); + const cardFields = useMemo( + () => extractConnectedFields(componentId, connections, allComponents), + [componentId, connections, allComponents], + ); const updateV2 = useCallback( (partial: Partial) => { @@ -1424,9 +1529,9 @@ export function PopButtonConfigPanel({ config, onUpdate }: PopButtonConfigPanelP {/* 작업 목록 */} -
+
{v2.tasks.length === 0 && ( -

+

작업이 없습니다. 빠른 시작 또는 아래 버튼으로 추가하세요.

)} @@ -1440,6 +1545,7 @@ export function PopButtonConfigPanel({ config, onUpdate }: PopButtonConfigPanelP onUpdate={(partial) => updateTask(task.id, partial)} onRemove={() => removeTask(task.id)} onMove={(dir) => moveTask(task.id, dir)} + cardFields={cardFields} /> ))} @@ -1465,6 +1571,41 @@ export function PopButtonConfigPanel({ config, onUpdate }: PopButtonConfigPanelP // 작업 항목 에디터 (접힘/펼침) // ======================================== +/** 작업 항목의 요약 텍스트 생성 */ +function buildTaskSummary(task: ButtonTask): string { + switch (task.type) { + case "data-update": { + if (!task.targetTable) return ""; + const col = task.targetColumn ? `.${task.targetColumn}` : ""; + const opLabels: Record = { + assign: "값 지정", + add: "더하기", + subtract: "빼기", + multiply: "곱하기", + divide: "나누기", + conditional: "조건 분기", + "db-conditional": "조건 비교", + }; + const op = opLabels[task.operationType || "assign"] || ""; + return `${task.targetTable}${col} ${op}`; + } + case "data-delete": + return task.targetTable || ""; + case "navigate": + return task.targetScreenId ? `화면 ${task.targetScreenId}` : ""; + case "modal-open": + return task.modalTitle || task.modalScreenId || ""; + case "cart-save": + return task.cartScreenId ? `화면 ${task.cartScreenId}` : ""; + case "api-call": + return task.apiEndpoint || ""; + case "custom-event": + return task.flowName || task.eventName || ""; + default: + return ""; + } +} + function TaskItemEditor({ task, index, @@ -1472,6 +1613,7 @@ function TaskItemEditor({ onUpdate, onRemove, onMove, + cardFields, }: { task: ButtonTask; index: number; @@ -1479,68 +1621,61 @@ function TaskItemEditor({ onUpdate: (partial: Partial) => void; onRemove: () => void; onMove: (direction: "up" | "down") => void; + cardFields: { value: string; label: string; source: string }[]; }) { const [expanded, setExpanded] = useState(false); const designerCtx = usePopDesignerContext(); + const summary = buildTaskSummary(task); return ( -
- {/* 헤더: 타입 + 순서 + 삭제 */} +
setExpanded(!expanded)} > - - - - {index + 1}. {TASK_TYPE_LABELS[task.type]} - - {task.label && ({task.label})} -
+ +
+
+ + {index + 1}. {TASK_TYPE_LABELS[task.type]} + + {summary && ( + + - {summary} + + )} +
+
+
{index > 0 && ( - )} {index < totalCount - 1 && ( - )}
- {/* 펼침: 타입별 설정 폼 */} {expanded && ( -
- +
+
)}
@@ -1555,10 +1690,12 @@ function TaskDetailForm({ task, onUpdate, designerCtx, + cardFields, }: { task: ButtonTask; onUpdate: (partial: Partial) => void; designerCtx: ReturnType; + cardFields: { value: string; label: string; source: string }[]; }) { // 테이블/컬럼 조회 (data-update, data-delete용) const [tables, setTables] = useState([]); @@ -1580,18 +1717,26 @@ function TaskDetailForm({ switch (task.type) { case "data-save": return ( -

+

연결된 입력 컴포넌트의 저장 매핑을 사용합니다. 별도 설정 불필요.

); case "data-update": - return ; + return ( + + ); case "data-delete": return ( -
- +
+ - +
+ onUpdate({ cartScreenId: e.target.value })} placeholder="비워두면 이동 없이 저장만" - className="h-7 text-xs" + className="h-8 text-xs" />
); case "modal-open": return ( -
-
- +
+
+
{task.modalMode === "screen-ref" && ( -
- +
+ onUpdate({ modalScreenId: e.target.value })} placeholder="화면 ID" - className="h-7 text-xs" + className="h-8 text-xs" />
)} -
- +
+ onUpdate({ modalTitle: e.target.value })} placeholder="모달 제목 (선택)" - className="h-7 text-xs" + className="h-8 text-xs" />
{task.modalMode === "fullscreen" && designerCtx && (
{task.modalScreenId ? ( - ) : ( +
+
+ updateCondition(cIdx, { whenColumn: v })} - placeholder="컬럼" + placeholder="컬럼 선택" /> - - updateCondition(cIdx, { whenValue: e.target.value })} - className="h-7 w-16 text-[10px]" - placeholder="값" - /> -
-
- 이면 -> +
+ +
+ + updateCondition(cIdx, { whenValue: e.target.value })} + className="h-8 flex-1 text-xs" + placeholder="비교할 값" + /> +
+
+
+ updateCondition(cIdx, { thenValue: e.target.value })} - className="h-7 text-[10px]" + className="h-8 text-xs" placeholder="변경할 값" />
))} - -
- 그 외 -> + +
+ onUpdate({ conditionalValue: { conditions, defaultValue: e.target.value } })} - className="h-7 text-[10px]" - placeholder="기본값" + className="h-8 text-xs" + placeholder="기본값 입력" />
)} - {/* 조회 키 */} -
-
- - -
- {task.lookupMode === "manual" && ( -
- - -> - onUpdate({ manualPkColumn: v })} - placeholder="대상 PK 컬럼" - /> + {/* 7. 고급 설정 (조회 키) */} +
+ + {showAdvanced && ( +
+
+ + +

+ {task.lookupMode === "manual" + ? "카드 항목의 필드를 직접 지정하여 대상 행을 찾습니다" + : "카드 항목과 테이블 PK를 자동으로 매칭합니다"} +

+
+ {task.lookupMode === "manual" && ( +
+
+ + +
+
+ + onUpdate({ manualPkColumn: v })} + placeholder="PK 컬럼 선택" + /> +
+
+ )}
)}
+ + {/* 8. 설정 요약 */} + {summaryText && ( +
+

설정 요약

+

{summaryText}

+
+ )} )}
@@ -2361,10 +2597,10 @@ function PopButtonPreviewComponent({ config }: { config?: PopButtonConfig }) { // ======================================== const KNOWN_ITEM_FIELDS = [ - { value: "__cart_id", label: "__cart_id (카드 항목 ID)" }, - { value: "__cart_row_key", label: "__cart_row_key (원본 PK 값)" }, - { value: "id", label: "id" }, - { value: "row_key", label: "row_key" }, + { value: "__cart_row_key", label: "카드 항목의 원본 키", desc: "DB에서 가져온 데이터의 PK (가장 일반적)" }, + { value: "__cart_id", label: "카드 항목 ID", desc: "장바구니 내부 고유 ID" }, + { value: "id", label: "id", desc: "데이터의 id 컬럼" }, + { value: "row_key", label: "row_key", desc: "데이터의 row_key 컬럼" }, ]; function StatusChangeRuleEditor({ @@ -2687,6 +2923,107 @@ function SingleRuleEditor({ ); } +// ======================================== +// 제어 실행 작업 폼 (custom-event -> 제어 플로우) +// ======================================== + +function ControlFlowTaskForm({ + task, + onUpdate, +}: { + task: ButtonTask; + onUpdate: (partial: Partial) => void; +}) { + const [flows, setFlows] = useState<{ flowId: number; flowName: string; flowDescription?: string }[]>([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + + useEffect(() => { + setLoading(true); + apiClient + .get("/dataflow/node-flows") + .then((res) => { + const data = res.data?.data ?? res.data ?? []; + if (Array.isArray(data)) { + setFlows(data); + } + }) + .catch(() => setFlows([])) + .finally(() => setLoading(false)); + }, []); + + const selectedFlow = flows.find((f) => f.flowId === task.flowId); + + return ( +
+ + {loading ? ( +

목록 불러오는 중...

+ ) : ( + + + + + + + + + + 검색 결과 없음 + + + {flows.map((f) => ( + { + onUpdate({ + flowId: f.flowId, + flowName: f.flowName, + eventName: `__node_flow_${f.flowId}`, + }); + setOpen(false); + }} + className="text-xs" + > + +
+ {f.flowName} + {f.flowDescription && ( + + {f.flowDescription} + + )} +
+
+ ))} +
+
+
+
+
+ )} +
+ ); +} + // 레지스트리 등록 PopComponentRegistry.registerComponent({ id: "pop-button", diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx new file mode 100644 index 00000000..55829efb --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -0,0 +1,1442 @@ +"use client"; + +/** + * pop-card-list-v2 런타임 컴포넌트 + * + * pop-card-list의 데이터 로딩/필터링/페이징/장바구니 로직을 재활용하되, + * 카드 내부 렌더링은 CSS Grid + 셀 타입별 렌더러(cell-renderers.tsx)로 대체. + */ + +import React, { useEffect, useState, useRef, useMemo, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { + Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Trash2, Check, X, +} from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import type { + PopCardListV2Config, + CardGridConfigV2, + CardCellDefinitionV2, + CardInputFieldConfig, + CardCartActionConfig, + CardPackageConfig, + CardPresetSpec, + CartItem, + PackageEntry, + CollectDataRequest, + CollectedDataResponse, + TimelineProcessStep, + TimelineDataSource, + ActionButtonUpdate, + ActionButtonClickAction, + StatusValueMapping, + SelectModeConfig, + SelectModeButtonConfig, +} from "../types"; +import { + CARD_PRESET_SPECS, DEFAULT_CARD_IMAGE, + VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC, VIRTUAL_SUB_PROCESS, VIRTUAL_SUB_SEQ, +} from "../types"; +import { dataApi } from "@/lib/api/data"; +import { screenApi } from "@/lib/api/screen"; +import { apiClient } from "@/lib/api/client"; +import { usePopEvent } from "@/hooks/pop/usePopEvent"; +import { useCartSync } from "@/hooks/pop/useCartSync"; +import { NumberInputModal } from "../pop-card-list/NumberInputModal"; +import { renderCellV2 } from "./cell-renderers"; +import type { PopLayoutDataV5 } from "@/components/pop/designer/types/pop-layout"; +import { isV5Layout, detectGridMode } from "@/components/pop/designer/types/pop-layout"; +import dynamic from "next/dynamic"; +const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopViewerWithModals"), { ssr: false }); + +type RowData = Record; + +// cart_items 행 파싱 (pop-card-list에서 그대로 차용) +function parseCartRow(dbRow: Record): Record { + let rowData: Record = {}; + try { + const raw = dbRow.row_data; + if (typeof raw === "string" && raw.trim()) rowData = JSON.parse(raw); + else if (typeof raw === "object" && raw !== null) rowData = raw as Record; + } catch { rowData = {}; } + + return { + ...rowData, + __cart_id: dbRow.id, + __cart_quantity: Number(dbRow.quantity) || 0, + __cart_package_unit: dbRow.package_unit || "", + __cart_package_entries: dbRow.package_entries, + __cart_status: dbRow.status || "in_cart", + __cart_memo: dbRow.memo || "", + __cart_row_key: dbRow.row_key || "", + __cart_modified: false, + }; +} + +// 레거시 statusValues(고정 4키 객체) → statusMappings(동적 배열) 자동 변환 +function resolveStatusMappings(src: TimelineDataSource): StatusValueMapping[] { + if (src.statusMappings && src.statusMappings.length > 0) return src.statusMappings; + + // 레거시 호환: 기존 statusValues 객체가 있으면 변환 + const sv = (src as Record).statusValues as Record | undefined; + return [ + { dbValue: sv?.waiting || "waiting", label: "대기", semantic: "pending" as const }, + { dbValue: sv?.accepted || "accepted", label: "접수", semantic: "active" as const }, + { dbValue: sv?.inProgress || "in_progress", label: "진행중", semantic: "active" as const }, + { dbValue: sv?.completed || "completed", label: "완료", semantic: "done" as const }, + ]; +} + +interface PopCardListV2ComponentProps { + config?: PopCardListV2Config; + className?: string; + screenId?: string; + componentId?: string; + currentRowSpan?: number; + currentColSpan?: number; + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; +} + +export function PopCardListV2Component({ + config, + className, + screenId, + componentId, + currentRowSpan, + currentColSpan, + onRequestResize, +}: PopCardListV2ComponentProps) { + const { subscribe, publish } = usePopEvent(screenId || "default"); + const router = useRouter(); + + const isCartListMode = config?.cartListMode?.enabled === true; + const [inheritedConfig, setInheritedConfig] = useState | null>(null); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + + const effectiveConfig = useMemo(() => { + if (!isCartListMode || !inheritedConfig) return config; + return { + ...config, + ...inheritedConfig, + cartListMode: config?.cartListMode, + dataSource: config?.dataSource, + } as PopCardListV2Config; + }, [config, inheritedConfig, isCartListMode]); + + const isHorizontalMode = (effectiveConfig?.scrollDirection || "vertical") === "horizontal"; + const maxGridColumns = effectiveConfig?.gridColumns || 2; + const configGridRows = effectiveConfig?.gridRows || 3; + const dataSource = effectiveConfig?.dataSource; + const cardGrid = effectiveConfig?.cardGrid; + + const sourceTableName = (!isCartListMode && dataSource?.tableName) || ""; + const cart = useCartSync(screenId || "", sourceTableName); + + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 외부 필터 + const [externalFilters, setExternalFilters] = useState< + Map + >(new Map()); + + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__filter_condition`, + (payload: unknown) => { + const data = payload as { + value?: { fieldName?: string; value?: unknown; _source?: string }; + filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean }; + _connectionId?: string; + }; + const connId = data?._connectionId || "default"; + setExternalFilters((prev) => { + const next = new Map(prev); + if (data?.value?.value) { + next.set(connId, { + fieldName: data.value.fieldName || "", + value: data.value.value, + filterConfig: data.filterConfig, + _source: data.value._source, + }); + } else { + next.delete(connId); + } + return next; + }); + }, + ); + return unsub; + }, [componentId, subscribe]); + + const cartRef = useRef(cart); + cartRef.current = cart; + + // 저장 요청 수신 + useEffect(() => { + if (!componentId || isCartListMode) return; + const unsub = subscribe( + `__comp_input__${componentId}__cart_save_trigger`, + async (payload: unknown) => { + const data = payload as { value?: { selectedColumns?: string[] } } | undefined; + const ok = await cartRef.current.saveToDb(data?.value?.selectedColumns); + publish(`__comp_output__${componentId}__cart_save_completed`, { success: ok }); + }, + ); + return unsub; + }, [componentId, subscribe, publish, isCartListMode]); + + // 초기 장바구니 상태 전달 + useEffect(() => { + if (!componentId || cart.loading || isCartListMode) return; + publish(`__comp_output__${componentId}__cart_updated`, { + count: cart.cartCount, + isDirty: cart.isDirty, + }); + }, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish, isCartListMode]); + + // ===== 선택 모드 ===== + const [selectMode, setSelectMode] = useState(false); + const [selectModeStatus, setSelectModeStatus] = useState(""); + const [selectModeConfig, setSelectModeConfig] = useState(null); + const [selectedRowIds, setSelectedRowIds] = useState>(new Set()); + const [selectProcessing, setSelectProcessing] = useState(false); + + // ===== 모달 열기 (POP 화면) ===== + const [popModalOpen, setPopModalOpen] = useState(false); + const [popModalLayout, setPopModalLayout] = useState(null); + const [popModalScreenId, setPopModalScreenId] = useState(""); + const [popModalRow, setPopModalRow] = useState(null); + + const openPopModal = useCallback(async (screenIdStr: string, row: RowData) => { + try { + const sid = parseInt(screenIdStr, 10); + if (isNaN(sid)) { + toast.error("올바른 화면 ID가 아닙니다."); + return; + } + const popLayout = await screenApi.getLayoutPop(sid); + if (popLayout && isV5Layout(popLayout)) { + setPopModalLayout(popLayout); + setPopModalScreenId(String(sid)); + setPopModalRow(row); + setPopModalOpen(true); + } else { + toast.error("해당 POP 화면을 찾을 수 없습니다."); + } + } catch { + toast.error("POP 화면을 불러오는데 실패했습니다."); + } + }, []); + + const handleCardSelect = useCallback((row: RowData) => { + + if (effectiveConfig?.cardClickAction === "modal-open" && effectiveConfig?.cardClickModalConfig?.screenId) { + const mc = effectiveConfig.cardClickModalConfig; + if (mc.condition && mc.condition.type !== "always") { + const processFlow = row.__processFlow__ as { isCurrent: boolean; status?: string }[] | undefined; + const currentProcess = processFlow?.find((s) => s.isCurrent); + if (mc.condition.type === "timeline-status") { + if (currentProcess?.status !== mc.condition.value) return; + } else if (mc.condition.type === "column-value") { + if (String(row[mc.condition.column || ""] ?? "") !== mc.condition.value) return; + } + } + openPopModal(mc.screenId, row); + return; + } + if (!componentId) return; + publish(`__comp_output__${componentId}__selected_row`, row); + }, [componentId, publish, effectiveConfig, openPopModal]); + + const enterSelectMode = useCallback((whenStatus: string, buttonConfig: Record) => { + const smConfig = buttonConfig.selectModeConfig as SelectModeConfig | undefined; + if (!smConfig) return; + setSelectMode(true); + setSelectModeStatus(smConfig.filterStatus || whenStatus); + setSelectModeConfig(smConfig); + setSelectedRowIds(new Set()); + }, []); + + const exitSelectMode = useCallback(() => { + setSelectMode(false); + setSelectModeStatus(""); + setSelectModeConfig(null); + setSelectedRowIds(new Set()); + }, []); + + const toggleRowSelection = useCallback((row: RowData) => { + const rowId = String(row.id ?? row.pk ?? ""); + if (!rowId) return; + setSelectedRowIds((prev) => { + const next = new Set(prev); + if (next.has(rowId)) next.delete(rowId); else next.add(rowId); + return next; + }); + }, []); + + const isRowSelectable = useCallback((row: RowData) => { + if (!selectMode) return false; + const subStatus = row[VIRTUAL_SUB_STATUS]; + if (subStatus !== undefined) return String(subStatus) === selectModeStatus; + return true; + }, [selectMode, selectModeStatus]); + + // 확장/페이지네이션 + const [isExpanded, setIsExpanded] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [originalRowSpan, setOriginalRowSpan] = useState(null); + + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); + const baseContainerHeight = useRef(0); + + useEffect(() => { + if (!containerRef.current) return; + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const { width, height } = entry.contentRect; + if (width > 0) setContainerWidth(width); + if (height > 0) setContainerHeight(height); + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + const cardSizeKey = effectiveConfig?.cardSize || "large"; + const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large; + + const maxAllowedColumns = useMemo(() => { + if (!currentColSpan) return maxGridColumns; + if (currentColSpan >= 8) return maxGridColumns; + return 1; + }, [currentColSpan, maxGridColumns]); + + const minCardWidth = Math.round(spec.height * 1.6); + const autoColumns = containerWidth > 0 + ? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap))) + : maxGridColumns; + const gridColumns = Math.max(1, Math.min(autoColumns, maxGridColumns, maxAllowedColumns)); + const gridRows = configGridRows; + + // 셀 설정에서 timelineSource 탐색 (timeline/status-badge/action-buttons 중 하나에 설정됨) + const timelineSource = useMemo(() => { + const cells = cardGrid?.cells || []; + for (const c of cells) { + if ((c.type === "timeline" || c.type === "status-badge" || c.type === "action-buttons") && c.timelineSource?.processTable) { + return c.timelineSource; + } + } + return undefined; + }, [cardGrid?.cells]); + + // 외부 필터 (메인 테이블 + 하위 테이블 분기) + const filteredRows = useMemo(() => { + if (externalFilters.size === 0) return rows; + + const allFilters = [...externalFilters.values()]; + const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable); + const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable); + + // 1단계: 하위 테이블 필터 → __subStatus__ 주입 + const afterSubFilter = subFilters.length === 0 + ? rows + : rows + .map((row) => { + const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; + if (!processFlow || processFlow.length === 0) return null; + + const matchingSteps = processFlow.filter((step) => + subFilters.every((filter) => { + const searchValue = String(filter.value).toLowerCase(); + if (!searchValue) return true; + const fc = filter.filterConfig; + const col = fc?.targetColumn || filter.fieldName || ""; + if (!col) return true; + const cellValue = String(step.rawData?.[col] ?? "").toLowerCase(); + const mode = fc?.filterMode || "contains"; + switch (mode) { + case "equals": return cellValue === searchValue; + case "starts_with": return cellValue.startsWith(searchValue); + default: return cellValue.includes(searchValue); + } + }), + ); + + if (matchingSteps.length === 0) return null; + + const matched = matchingSteps[0]; + // 매칭된 공정을 타임라인에서 강조 + const updatedFlow = processFlow.map((s) => ({ + ...s, + isCurrent: s.seqNo === matched.seqNo, + })); + return { + ...row, + __processFlow__: updatedFlow, + [VIRTUAL_SUB_STATUS]: matched.status, + [VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending", + [VIRTUAL_SUB_PROCESS]: matched.processName, + [VIRTUAL_SUB_SEQ]: matched.seqNo, + }; + }) + .filter((row): row is RowData => row !== null); + + // 2단계: 메인 테이블 필터 (__subStatus__ 주입된 데이터 기반) + if (mainFilters.length === 0) return afterSubFilter; + + return afterSubFilter.filter((row) => + mainFilters.every((filter) => { + const searchValue = String(filter.value).toLowerCase(); + if (!searchValue) return true; + const fc = filter.filterConfig; + const columns: string[] = + fc?.targetColumns?.length ? fc.targetColumns + : fc?.targetColumn ? [fc.targetColumn] + : filter.fieldName ? [filter.fieldName] : []; + if (columns.length === 0) return true; + const mode = fc?.filterMode || "contains"; + + // 하위 필터 활성 시: 상태 컬럼(status 등)을 __subStatus__로 대체 + const subCol = subFilters.length > 0 ? VIRTUAL_SUB_STATUS : null; + const statusCol = timelineSource?.statusColumn || "status"; + const effectiveColumns = subCol + ? columns.map((col) => col === statusCol || col === "status" ? subCol : col) + : columns; + + return effectiveColumns.some((col) => { + const cellValue = String(row[col] ?? "").toLowerCase(); + switch (mode) { + case "equals": return cellValue === searchValue; + case "starts_with": return cellValue.startsWith(searchValue); + default: return cellValue.includes(searchValue); + } + }); + }), + ); + }, [rows, externalFilters, timelineSource]); + + // 하위 필터 활성 여부 + const hasActiveSubFilter = useMemo(() => { + if (externalFilters.size === 0) return false; + return [...externalFilters.values()].some((f) => f.filterConfig?.isSubTable); + }, [externalFilters]); + + // 선택 모드 일괄 처리 + const handleSelectModeAction = useCallback(async (btnConfig: SelectModeButtonConfig) => { + if (btnConfig.clickMode === "cancel-select") { + exitSelectMode(); + return; + } + + if (btnConfig.clickMode === "status-change" && btnConfig.updates && btnConfig.targetTable) { + if (selectedRowIds.size === 0) { + toast.error("선택된 항목이 없습니다."); + return; + } + if (btnConfig.confirmMessage && !window.confirm(btnConfig.confirmMessage)) return; + + setSelectProcessing(true); + try { + const selectedRows = filteredRows.filter((r) => { + const rowId = String(r.id ?? r.pk ?? ""); + return selectedRowIds.has(rowId); + }); + + let successCount = 0; + for (const row of selectedRows) { + const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; + const currentProcess = processFlow?.find((s) => s.isCurrent); + const targetId = currentProcess?.processId ?? row.id ?? row.pk; + if (!targetId) continue; + + const tasks = btnConfig.updates.map((u, idx) => ({ + id: `sel-update-${idx}`, + type: "data-update" as const, + targetTable: btnConfig.targetTable!, + targetColumn: u.column, + operationType: "assign" as const, + valueSource: "fixed" as const, + fixedValue: u.valueType === "static" ? (u.value ?? "") : + u.valueType === "currentUser" ? "__CURRENT_USER__" : + u.valueType === "currentTime" ? "__CURRENT_TIME__" : + (u.value ?? ""), + lookupMode: "manual" as const, + manualItemField: "id", + manualPkColumn: "id", + })); + + const result = await apiClient.post("/pop/execute-action", { + tasks, + data: { items: [{ ...row, id: targetId }], fieldValues: {} }, + mappings: {}, + }); + if (result.data?.success) successCount++; + } + + if (successCount > 0) { + toast.success(`${successCount}건 처리 완료`); + exitSelectMode(); + fetchDataRef.current(); + } else { + toast.error("처리에 실패했습니다."); + } + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + } finally { + setSelectProcessing(false); + } + return; + } + + if (btnConfig.clickMode === "modal-open" && btnConfig.modalScreenId) { + const selectedRows = filteredRows.filter((r) => { + const rowId = String(r.id ?? r.pk ?? ""); + return selectedRowIds.has(rowId); + }); + openPopModal(btnConfig.modalScreenId, selectedRows[0] || {}); + return; + } + }, [selectedRowIds, filteredRows, exitSelectMode]); + + // status-bar 필터를 제외한 rows (카운트 집계용) + // status-bar에서 "접수가능" 등 선택해도 전체 카운트가 유지되어야 함 + const rowsForStatusCount = useMemo(() => { + const hasStatusBarFilter = [...externalFilters.values()].some((f) => f._source === "status-bar"); + if (!hasStatusBarFilter) return filteredRows; + + // status-bar 필터를 제외한 필터만 적용 + const nonStatusFilters = new Map( + [...externalFilters.entries()].filter(([, f]) => f._source !== "status-bar") + ); + if (nonStatusFilters.size === 0) return rows; + + const allFilters = [...nonStatusFilters.values()]; + const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable); + const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable); + + const afterSubFilter = subFilters.length === 0 + ? rows + : rows + .map((row) => { + const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; + if (!processFlow || processFlow.length === 0) return null; + const matchingSteps = processFlow.filter((step) => + subFilters.every((filter) => { + const searchValue = String(filter.value).toLowerCase(); + if (!searchValue) return true; + const fc = filter.filterConfig; + const col = fc?.targetColumn || filter.fieldName || ""; + if (!col) return true; + const cellValue = String(step.rawData?.[col] ?? "").toLowerCase(); + const mode = fc?.filterMode || "contains"; + switch (mode) { + case "equals": return cellValue === searchValue; + case "starts_with": return cellValue.startsWith(searchValue); + default: return cellValue.includes(searchValue); + } + }), + ); + if (matchingSteps.length === 0) return null; + const matched = matchingSteps[0]; + const updatedFlow = processFlow.map((s) => ({ + ...s, + isCurrent: s.seqNo === matched.seqNo, + })); + return { + ...row, + __processFlow__: updatedFlow, + [VIRTUAL_SUB_STATUS]: matched.status, + [VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending", + [VIRTUAL_SUB_PROCESS]: matched.processName, + [VIRTUAL_SUB_SEQ]: matched.seqNo, + }; + }) + .filter((row): row is RowData => row !== null); + + if (mainFilters.length === 0) return afterSubFilter; + + return afterSubFilter.filter((row) => + mainFilters.every((filter) => { + const searchValue = String(filter.value).toLowerCase(); + if (!searchValue) return true; + const fc = filter.filterConfig; + const columns: string[] = + fc?.targetColumns?.length ? fc.targetColumns + : fc?.targetColumn ? [fc.targetColumn] + : filter.fieldName ? [filter.fieldName] : []; + if (columns.length === 0) return true; + const mode = fc?.filterMode || "contains"; + const subCol = subFilters.length > 0 ? VIRTUAL_SUB_STATUS : null; + const statusCol = timelineSource?.statusColumn || "status"; + const effectiveColumns = subCol + ? columns.map((col) => col === statusCol || col === "status" ? subCol : col) + : columns; + return effectiveColumns.some((col) => { + const cellValue = String(row[col] ?? "").toLowerCase(); + switch (mode) { + case "equals": return cellValue === searchValue; + case "starts_with": return cellValue.startsWith(searchValue); + default: return cellValue.includes(searchValue); + } + }); + }), + ); + }, [rows, filteredRows, externalFilters, timelineSource]); + + // 카운트 집계용 rows 발행 (status-bar 필터 제외) + useEffect(() => { + if (!componentId || loading) return; + publish(`__comp_output__${componentId}__all_rows`, { + rows: rowsForStatusCount, + subStatusColumn: hasActiveSubFilter ? VIRTUAL_SUB_STATUS : null, + }); + }, [componentId, rowsForStatusCount, loading, publish, hasActiveSubFilter]); + + const overflowCfg = effectiveConfig?.overflow; + const baseVisibleCount = overflowCfg?.visibleCount ?? gridColumns * gridRows; + const visibleCardCount = useMemo(() => Math.max(1, baseVisibleCount), [baseVisibleCount]); + const hasMoreCards = filteredRows.length > visibleCardCount; + const expandedCardsPerPage = useMemo(() => { + if (overflowCfg?.mode === "pagination" && overflowCfg.pageSize) return overflowCfg.pageSize; + if (overflowCfg?.mode === "loadMore" && overflowCfg.loadMoreCount) return overflowCfg.loadMoreCount + visibleCardCount; + return Math.max(1, visibleCardCount * 2 + gridColumns); + }, [visibleCardCount, gridColumns, overflowCfg]); + + const scrollAreaRef = useRef(null); + + const displayCards = useMemo(() => { + if (!isExpanded) return filteredRows.slice(0, visibleCardCount); + const start = (currentPage - 1) * expandedCardsPerPage; + return filteredRows.slice(start, start + expandedCardsPerPage); + }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]); + + const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1; + const needsPagination = isExpanded && totalPages > 1; + + const toggleExpand = () => { + if (isExpanded) { + if (!isHorizontalMode && originalRowSpan !== null && componentId && onRequestResize) { + onRequestResize(componentId, originalRowSpan); + } + setCurrentPage(1); + setOriginalRowSpan(null); + baseContainerHeight.current = 0; + setIsExpanded(false); + } else { + baseContainerHeight.current = containerHeight; + if (!isHorizontalMode && componentId && onRequestResize && currentRowSpan !== undefined) { + setOriginalRowSpan(currentRowSpan); + onRequestResize(componentId, currentRowSpan * 2); + } + setIsExpanded(true); + } + }; + + useEffect(() => { + if (scrollAreaRef.current && isExpanded) { + scrollAreaRef.current.scrollTop = 0; + scrollAreaRef.current.scrollLeft = 0; + } + }, [currentPage, isExpanded]); + + // 데이터 조회 + const dataSourceKey = useMemo(() => JSON.stringify(dataSource || null), [dataSource]); + const cartListModeKey = useMemo(() => JSON.stringify(config?.cartListMode || null), [config?.cartListMode]); + + // 하위 데이터 조회 + __processFlow__ 가상 컬럼 주입 + const injectProcessFlow = useCallback(async ( + fetchedRows: RowData[], + src: TimelineDataSource, + ): Promise => { + if (fetchedRows.length === 0) return fetchedRows; + const rowIds = fetchedRows.map((r) => String(r.id)).filter(Boolean); + if (rowIds.length === 0) return fetchedRows; + + // statusMappings 동적 배열 → dbValue-to-내부키 맵 구축 + // 레거시 statusValues 객체도 자동 변환 + const mappings = resolveStatusMappings(src); + const dbToInternal = new Map(); + const dbToSemantic = new Map(); + for (const m of mappings) { + dbToInternal.set(m.dbValue, m.dbValue); + dbToSemantic.set(m.dbValue, m.semantic); + } + + const processResult = await dataApi.getTableData(src.processTable, { + page: 1, + size: 1000, + sortBy: src.seqColumn || "seq_no", + sortOrder: "asc", + }); + const allProcesses = processResult.data || []; + + // isDerived 매핑: DB에 없는 자동 판별 상태 + // 같은 시맨틱의 DB 원본 상태를 자동으로 찾아 변환 조건 구축 + const derivedRules: { sourceStatus: string; targetDbValue: string; targetSemantic: string }[] = []; + for (const dm of mappings.filter((m) => m.isDerived)) { + const source = mappings.find((m) => !m.isDerived && m.semantic === dm.semantic); + if (source) { + derivedRules.push({ sourceStatus: source.dbValue, targetDbValue: dm.dbValue, targetSemantic: dm.semantic }); + } + } + + const processMap = new Map(); + for (const p of allProcesses) { + const fkValue = String(p[src.foreignKey] || ""); + if (!fkValue || !rowIds.includes(fkValue)) continue; + if (!processMap.has(fkValue)) processMap.set(fkValue, []); + + const rawStatus = String(p[src.statusColumn] || ""); + const normalizedStatus = dbToInternal.get(rawStatus) || rawStatus; + const semantic = dbToSemantic.get(rawStatus) || "pending"; + + processMap.get(fkValue)!.push({ + seqNo: parseInt(String(p[src.seqColumn] || "0"), 10), + processName: String(p[src.nameColumn] || ""), + status: normalizedStatus, + semantic: semantic as "pending" | "active" | "done", + isCurrent: semantic === "active", + processId: p.id as string | number | undefined, + rawData: p as Record, + }); + } + + // 파생 상태 자동 변환: 이전 공정이 완료된 경우 변환 + if (derivedRules.length > 0) { + for (const [, steps] of processMap) { + steps.sort((a, b) => a.seqNo - b.seqNo); + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const prevStep = i > 0 ? steps[i - 1] : null; + for (const rule of derivedRules) { + if (step.status !== rule.sourceStatus) continue; + const prevIsDone = prevStep ? prevStep.semantic === "done" : true; + if (prevIsDone) { + step.status = rule.targetDbValue; + step.semantic = rule.targetSemantic as "pending" | "active" | "done"; + } + } + } + } + } + + // isCurrent 결정: "기준" 체크된 상태와 일치하는 공정을 강조 + // 기준 상태가 없으면 기존 로직 (active → 첫 pending) 폴백 + const pivotDbValues = mappings.filter((m) => m.isDerived).map((m) => m.dbValue); + for (const [, steps] of processMap) { + steps.sort((a, b) => a.seqNo - b.seqNo); + steps.forEach((s) => { s.isCurrent = false; }); + + if (pivotDbValues.length > 0) { + const pivotStep = steps.find((s) => pivotDbValues.includes(s.status)); + if (pivotStep) { + pivotStep.isCurrent = true; + continue; + } + } + // 폴백: active가 있으면 첫 active, 없으면 첫 pending + const firstActive = steps.find((s) => s.semantic === "active"); + if (firstActive) { firstActive.isCurrent = true; continue; } + const firstPending = steps.find((s) => s.semantic === "pending"); + if (firstPending) { firstPending.isCurrent = true; } + } + + return fetchedRows.map((row) => ({ + ...row, + __processFlow__: processMap.get(String(row.id)) || [], + })); + }, []); + + const fetchData = useCallback(async () => { + if (!dataSource?.tableName) { setLoading(false); setRows([]); return; } + + setLoading(true); + setError(null); + try { + const filters: Record = {}; + if (dataSource.filters?.length) { + dataSource.filters.forEach((f) => { + if (f.column && f.value && (!f.operator || f.operator === "=")) filters[f.column] = f.value; + }); + } + const sortArray = Array.isArray(dataSource.sort) + ? dataSource.sort + : dataSource.sort && typeof dataSource.sort === "object" + ? [dataSource.sort as { column: string; direction: "asc" | "desc" }] + : []; + const primarySort = sortArray[0]; + const size = dataSource.limit?.mode === "limited" && dataSource.limit?.count ? dataSource.limit.count : 100; + + const result = await dataApi.getTableData(dataSource.tableName, { + page: 1, + size, + sortBy: primarySort?.column || undefined, + sortOrder: primarySort?.direction, + filters: Object.keys(filters).length > 0 ? filters : undefined, + }); + + let fetchedRows = result.data || []; + const clientFilters = (dataSource.filters || []).filter( + (f) => f.column && f.value && f.operator && f.operator !== "=", + ); + if (clientFilters.length > 0) { + fetchedRows = fetchedRows.filter((row) => + clientFilters.every((f) => { + const cellVal = row[f.column]; + const filterVal = f.value; + switch (f.operator) { + case "!=": return String(cellVal ?? "") !== filterVal; + case ">": return Number(cellVal) > Number(filterVal); + case ">=": return Number(cellVal) >= Number(filterVal); + case "<": return Number(cellVal) < Number(filterVal); + case "<=": return Number(cellVal) <= Number(filterVal); + case "like": return String(cellVal ?? "").toLowerCase().includes(filterVal.toLowerCase()); + default: return true; + } + }), + ); + } + + // timelineSource 설정이 있으면 공정 데이터 조회하여 __processFlow__ 주입 + if (timelineSource) { + try { + fetchedRows = await injectProcessFlow(fetchedRows, timelineSource); + } catch { + // 공정 데이터 조회 실패 시 무시 (메인 데이터는 정상 표시) + } + } + + setRows(fetchedRows); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 조회 실패"); + setRows([]); + } finally { setLoading(false); } + }, [dataSource, timelineSource, injectProcessFlow]); + + const fetchDataRef = useRef(fetchData); + fetchDataRef.current = fetchData; + + useEffect(() => { + if (isCartListMode) { + const cartListMode = config!.cartListMode!; + if (!cartListMode.sourceScreenId) { setLoading(false); setRows([]); return; } + + const fetchCartData = async () => { + setLoading(true); + setError(null); + try { + try { + const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId!); + const componentsMap = layoutJson?.components || {}; + const componentList = Object.values(componentsMap) as any[]; + const matched = cartListMode.sourceComponentId + ? componentList.find((c: any) => c.id === cartListMode.sourceComponentId) + : componentList.find((c: any) => c.type === "pop-card-list-v2" || c.type === "pop-card-list"); + if (matched?.config) setInheritedConfig(matched.config); + } catch { /* 레이아웃 로드 실패 시 자체 config 폴백 */ } + + const cartFilters: Record = { status: cartListMode.statusFilter || "in_cart" }; + if (cartListMode.sourceScreenId) cartFilters.screen_id = String(cartListMode.sourceScreenId); + const result = await dataApi.getTableData("cart_items", { size: 500, filters: cartFilters }); + setRows((result.data || []).map(parseCartRow)); + } catch (err) { + setError(err instanceof Error ? err.message : "장바구니 데이터 조회 실패"); + setRows([]); + } finally { setLoading(false); } + }; + fetchCartData(); + return; + } + + fetchData(); + }, [dataSourceKey, isCartListMode, cartListModeKey, fetchData]); // eslint-disable-line react-hooks/exhaustive-deps + + // 장바구니 목록 모드 콜백 + const handleDeleteItem = useCallback((cartId: string) => { + setRows((prev) => prev.filter((r) => String(r.__cart_id) !== cartId)); + setSelectedKeys((prev) => { const next = new Set(prev); next.delete(cartId); return next; }); + }, []); + + const handleUpdateQuantity = useCallback((cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => { + setRows((prev) => prev.map((r) => { + if (String(r.__cart_id) !== cartId) return r; + return { ...r, __cart_quantity: quantity, __cart_package_unit: unit || r.__cart_package_unit, __cart_package_entries: entries || r.__cart_package_entries, __cart_modified: true }; + })); + }, []); + + // 데이터 수집 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__collect_data`, + (payload: unknown) => { + const request = (payload as Record)?.value as CollectDataRequest | undefined; + const selectedItems = isCartListMode + ? filteredRows.filter((r) => selectedKeys.has(String(r.__cart_id ?? ""))) + : rows; + const sm = config?.saveMapping; + const mapping = sm?.targetTable && sm.mappings.length > 0 + ? { targetTable: sm.targetTable, columnMapping: Object.fromEntries(sm.mappings.filter((m) => m.sourceField && m.targetColumn).map((m) => [m.sourceField, m.targetColumn])) } + : null; + const cartChanges = cart.isDirty ? cart.getChanges() : undefined; + const response: CollectedDataResponse = { + requestId: request?.requestId ?? "", + componentId: componentId, + componentType: "pop-card-list-v2", + data: { items: selectedItems, cartChanges } as any, + mapping, + }; + publish(`__comp_output__${componentId}__collected_data`, response); + }, + ); + return unsub; + }, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys, cart]); + + // 선택 항목 이벤트 + useEffect(() => { + if (!componentId || !isCartListMode) return; + const selectedItems = filteredRows.filter((r) => selectedKeys.has(String(r.__cart_id ?? ""))); + publish(`__comp_output__${componentId}__selected_items`, selectedItems); + }, [selectedKeys, filteredRows, componentId, isCartListMode, publish]); + + // 카드 영역 스타일 + const cardGap = effectiveConfig?.cardGap ?? spec.gap; + const cardMinHeight = spec.height; + const cardAreaStyle: React.CSSProperties = { + gap: `${cardGap}px`, + ...(isHorizontalMode + ? { + gridTemplateRows: `repeat(${gridRows}, minmax(${cardMinHeight}px, auto))`, + gridAutoFlow: "column", + gridAutoColumns: `${Math.round(cardMinHeight * 1.6)}px`, + } + : { + gridTemplateColumns: `repeat(${gridColumns}, 1fr)`, + gridAutoRows: `minmax(${cardMinHeight}px, auto)`, + }), + }; + + const scrollClassName = isHorizontalMode + ? "overflow-x-auto overflow-y-hidden" + : isExpanded + ? "overflow-y-auto overflow-x-hidden" + : "overflow-hidden"; + + return ( +
+ {isCartListMode && !config?.cartListMode?.sourceScreenId ? ( +
+

원본 화면을 선택해주세요.

+
+ ) : !isCartListMode && !dataSource?.tableName ? ( +
+

데이터 소스를 설정해주세요.

+
+ ) : effectiveConfig?.hideUntilFiltered && externalFilters.size === 0 ? ( +
+

필터를 선택하면 데이터가 표시됩니다.

+
+ ) : loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : rows.length === 0 ? ( +
+

데이터가 없습니다.

+
+ ) : ( + <> + {/* 선택 모드 상단 바 */} + {selectMode && ( +
+
+
+ {selectedRowIds.size} +
+ + {selectedRowIds.size > 0 ? `${selectedRowIds.size}개 선택됨` : "카드를 선택하세요"} + +
+ +
+ )} + + {/* 장바구니 모드 상단 바 */} + {!selectMode && isCartListMode && ( +
+ 0} + onChange={(e) => { + if (e.target.checked) { + setSelectedKeys(new Set(filteredRows.map((r) => String(r.__cart_id ?? "")))); + } else { + setSelectedKeys(new Set()); + } + }} + className="h-4 w-4 rounded border-input" + /> + + {selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"} + +
+ )} + +
+ {displayCards.map((row, index) => ( + { + const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; + if (!cartId) return; + setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; }); + }} + onDeleteItem={handleDeleteItem} + onUpdateQuantity={handleUpdateQuantity} + onRefresh={fetchData} + selectMode={selectMode} + isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))} + isSelectable={isRowSelectable(row)} + onToggleRowSelect={() => toggleRowSelection(row)} + onEnterSelectMode={enterSelectMode} + onOpenPopModal={openPopModal} + /> + ))} +
+ + {/* 선택 모드 하단 액션 바 */} + {selectMode && selectModeConfig && ( +
+
+ {selectModeConfig.buttons.map((btn, idx) => ( + + ))} +
+
+ )} + + {/* 더보기/페이지네이션 */} + {!selectMode && hasMoreCards && ( +
+
+
+ + + {filteredRows.length}건{externalFilters.size > 0 && filteredRows.length !== rows.length ? ` / ${rows.length}건` : ""} + +
+ {isExpanded && needsPagination && ( +
+ + {currentPage} / {totalPages} + +
+ )} +
+
+ )} + + )} + + {/* POP 화면 모달 (풀스크린) */} + { + setPopModalOpen(open); + if (!open) { + setPopModalLayout(null); + setPopModalRow(null); + } + }}> + + + {effectiveConfig?.cardClickModalConfig?.modalTitle || "상세 작업"} + +
+ {popModalLayout && ( + + )} +
+
+
+
+ ); +} + +// ===== 카드 V2 ===== + +interface CardV2Props { + row: RowData; + cardGrid?: CardGridConfigV2; + spec: CardPresetSpec; + config?: PopCardListV2Config; + onSelect?: (row: RowData) => void; + cart: ReturnType; + publish: (eventName: string, payload?: unknown) => void; + parentComponentId?: string; + isCartListMode?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; + onDeleteItem?: (cartId: string) => void; + onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void; + onRefresh?: () => void; + selectMode?: boolean; + isSelectModeSelected?: boolean; + isSelectable?: boolean; + onToggleRowSelect?: () => void; + onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; + onOpenPopModal?: (screenId: string, row: RowData) => void; +} + +function CardV2({ + row, cardGrid, spec, config, onSelect, cart, publish, + parentComponentId, isCartListMode, isSelected, onToggleSelect, + onDeleteItem, onUpdateQuantity, onRefresh, + selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode, + onOpenPopModal, +}: CardV2Props) { + const inputField = config?.inputField; + const cartAction = config?.cartAction; + const packageConfig = config?.packageConfig; + const keyColumnName = cartAction?.keyColumn || "id"; + + const [inputValue, setInputValue] = useState(0); + const [packageUnit, setPackageUnit] = useState(undefined); + const [packageEntries, setPackageEntries] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + + const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : ""; + const isCarted = cart.isItemInCart(rowKey); + const existingCartItem = cart.getCartItem(rowKey); + + // DB 장바구니 복원 + useEffect(() => { + if (isCartListMode) return; + if (existingCartItem && existingCartItem._origin === "db") { + setInputValue(existingCartItem.quantity); + setPackageUnit(existingCartItem.packageUnit); + setPackageEntries(existingCartItem.packageEntries || []); + } + }, [isCartListMode, existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]); + + // 장바구니 목록 모드 초기값 + useEffect(() => { + if (!isCartListMode) return; + setInputValue(Number(row.__cart_quantity) || 0); + setPackageUnit(row.__cart_package_unit ? String(row.__cart_package_unit) : undefined); + }, [isCartListMode, row.__cart_quantity, row.__cart_package_unit]); + + // 제한 컬럼 자동 초기화 + const limitCol = inputField?.limitColumn || inputField?.maxColumn; + const effectiveMax = useMemo(() => { + if (limitCol) { const v = Number(row[limitCol]); if (!isNaN(v) && v > 0) return v; } + return 999999; + }, [limitCol, row]); + + useEffect(() => { + if (isCartListMode) return; + if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) { + setInputValue(effectiveMax); + } + }, [effectiveMax, inputField?.enabled, limitCol, isCartListMode]); + + const handleInputClick = (e: React.MouseEvent) => { e.stopPropagation(); setIsModalOpen(true); }; + const handleInputConfirm = (value: number, unit?: string, entries?: PackageEntry[]) => { + setInputValue(value); + setPackageUnit(unit); + setPackageEntries(entries || []); + if (isCartListMode) onUpdateQuantity?.(String(row.__cart_id), value, unit, entries); + }; + + const handleCartAdd = () => { + if (!rowKey) return; + cart.addItem({ row, quantity: inputValue, packageUnit, packageEntries: packageEntries.length > 0 ? packageEntries : undefined }, rowKey); + if (parentComponentId) publish(`__comp_output__${parentComponentId}__cart_updated`, { count: cart.cartCount + 1, isDirty: true }); + }; + + const handleCartCancel = () => { + if (!rowKey) return; + cart.removeItem(rowKey); + if (parentComponentId) publish(`__comp_output__${parentComponentId}__cart_updated`, { count: Math.max(0, cart.cartCount - 1), isDirty: true }); + }; + + const handleCartDelete = async (e: React.MouseEvent) => { + e.stopPropagation(); + const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; + if (!cartId) return; + if (!window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?")) return; + try { + await dataApi.updateRecord("cart_items", cartId, { status: "cancelled" }); + onDeleteItem?.(cartId); + } catch { toast.error("삭제에 실패했습니다."); } + }; + + const borderClass = selectMode + ? isSelectModeSelected + ? "border-primary border-2 bg-primary/5" + : isSelectable + ? "hover:border-2 hover:border-primary/50" + : "opacity-40 pointer-events-none" + : isCartListMode + ? isSelected ? "border-primary border-2 hover:border-primary/80" : "hover:border-2 hover:border-blue-500" + : isCarted ? "border-emerald-500 border-2 hover:border-emerald-600" : "hover:border-2 hover:border-blue-500"; + + if (!cardGrid || cardGrid.cells.length === 0) { + return ( +
+ 카드 레이아웃을 설정하세요 +
+ ); + } + + const gridStyle: React.CSSProperties = { + display: "grid", + gridTemplateColumns: cardGrid.colWidths.length > 0 + ? cardGrid.colWidths.map((w) => `minmax(30px, ${w || "1fr"})`).join(" ") + : "1fr", + gridTemplateRows: cardGrid.rowHeights?.length + ? cardGrid.rowHeights.map((h) => { + if (!h) return "minmax(24px, auto)"; + if (h.endsWith("px")) return `minmax(${h}, auto)`; + const px = Math.round(parseFloat(h) * 24) || 24; + return `minmax(${px}px, auto)`; + }).join(" ") + : `repeat(${cardGrid.rows || 1}, minmax(24px, auto))`, + gap: `${cardGrid.gap || 0}px`, + }; + + const justifyMap = { left: "flex-start", center: "center", right: "flex-end" } as const; + const alignItemsMap = { top: "flex-start", middle: "center", bottom: "flex-end" } as const; + + return ( +
{ + if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } + if (!selectMode) onSelect?.(row); + }} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } + if (!selectMode) onSelect?.(row); + } + }} + > + {/* 선택 모드: 체크 인디케이터 */} + {selectMode && isSelectable && ( +
+
{ e.stopPropagation(); onToggleRowSelect?.(); }} + > + {isSelectModeSelected && } +
+
+ )} + + {/* 장바구니 목록 모드: 체크박스 + 삭제 */} + {!selectMode && isCartListMode && ( +
+ { e.stopPropagation(); onToggleSelect?.(); }} + onClick={(e) => e.stopPropagation()} + className="h-4 w-4 rounded border-input" + /> + +
+ )} + + {/* CSS Grid 기반 셀 렌더링 */} +
+ {cardGrid.cells.map((cell) => ( +
+ {renderCellV2({ + cell, + row, + inputValue, + isCarted, + onInputClick: handleInputClick, + onCartAdd: handleCartAdd, + onCartCancel: handleCartCancel, + onEnterSelectMode, + onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => { + const cfg = buttonConfig as Record | undefined; + const allActions = (cfg?.__allActions as ActionButtonClickAction[] | undefined) || []; + const processId = cfg?.__processId as string | number | undefined; + + // 단일 액션 폴백 (기존 구조 호환) + const actionsToRun = allActions.length > 0 + ? allActions + : cfg?.type + ? [cfg as unknown as ActionButtonClickAction] + : []; + + if (actionsToRun.length === 0) { + if (parentComponentId) { + publish(`__comp_output__${parentComponentId}__action`, { taskPreset, row: actionRow }); + } + return; + } + + for (const action of actionsToRun) { + if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) { + if (action.confirmMessage) { + if (!window.confirm(action.confirmMessage)) return; + } + try { + const rowId = processId ?? actionRow.id ?? actionRow.pk; + if (!rowId) { toast.error("대상 레코드의 ID를 찾을 수 없습니다."); return; } + const lookupValue = action.joinConfig + ? String(actionRow[action.joinConfig.sourceColumn] ?? rowId) + : rowId; + const lookupColumn = action.joinConfig?.targetColumn || "id"; + const tasks = action.updates.map((u, idx) => ({ + id: `btn-update-${idx}`, + type: "data-update" as const, + targetTable: action.targetTable!, + targetColumn: u.column, + operationType: "assign" as const, + valueSource: "fixed" as const, + fixedValue: u.valueType === "static" ? (u.value ?? "") : + u.valueType === "currentUser" ? "__CURRENT_USER__" : + u.valueType === "currentTime" ? "__CURRENT_TIME__" : + u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") : + (u.value ?? ""), + lookupMode: "manual" as const, + manualItemField: lookupColumn, + manualPkColumn: lookupColumn, + })); + const targetRow = action.joinConfig + ? { ...actionRow, [lookupColumn]: lookupValue } + : processId ? { ...actionRow, id: processId } : actionRow; + const result = await apiClient.post("/pop/execute-action", { + tasks, + data: { items: [targetRow], fieldValues: {} }, + mappings: {}, + }); + if (result.data?.success) { + toast.success(result.data.message || "처리 완료"); + onRefresh?.(); + } else { + toast.error(result.data?.message || "처리 실패"); + return; + } + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + return; + } + } else if (action.type === "modal-open" && action.modalScreenId) { + onOpenPopModal?.(action.modalScreenId, actionRow); + } + } + }, + packageEntries, + inputUnit: inputField?.unit, + })} +
+ ))} +
+ + {inputField?.enabled && ( + + )} + +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx new file mode 100644 index 00000000..79d8a31e --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx @@ -0,0 +1,2963 @@ +"use client"; + +/** + * pop-card-list-v2 설정 패널 (3탭) + * + * 탭 1: 데이터 — 테이블/컬럼 선택, 조인, 정렬 + * 탭 2: 카드 디자인 — 열 수, 시각적 그리드 디자이너, 셀 클릭 시 타입별 상세 인라인 + * 탭 3: 동작 — 카드 선택 동작, 오버플로우, 카트 + */ + +import { useState, useEffect, useRef, useCallback, useMemo, Fragment } from "react"; +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, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Check, ChevronsUpDown, Plus, Minus, Trash2, ChevronDown, ChevronRight } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { cn } from "@/lib/utils"; +import type { + PopCardListV2Config, + CardGridConfigV2, + CardCellDefinitionV2, + CardCellType, + CardListDataSource, + CardColumnJoin, + CardSortConfig, + V2OverflowConfig, + V2CardClickAction, + V2CardClickModalConfig, + ActionButtonUpdate, + TimelineDataSource, + StatusValueMapping, + TimelineStatusSemantic, + SelectModeButtonConfig, + ActionButtonDef, + ActionButtonShowCondition, + ActionButtonClickAction, +} from "../types"; +import type { ButtonVariant } from "../pop-button"; +import { + fetchTableList, + fetchTableColumns, + type TableInfo, + type ColumnInfo, +} from "../pop-dashboard/utils/dataFetcher"; + +// ===== Props ===== + +interface ConfigPanelProps { + config: PopCardListV2Config | undefined; + onUpdate: (config: PopCardListV2Config) => void; +} + +// ===== 기본 설정값 ===== + +const V2_DEFAULT_CONFIG: PopCardListV2Config = { + dataSource: { tableName: "" }, + cardGrid: { + rows: 1, + cols: 1, + colWidths: ["1fr"], + rowHeights: ["32px"], + gap: 4, + showCellBorder: true, + cells: [], + }, + gridColumns: 3, + cardGap: 8, + scrollDirection: "vertical", + overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 }, + cardClickAction: "none", +}; + +// ===== 탭 정의 ===== + +type V2ConfigTab = "data" | "design" | "actions"; + +const TAB_LABELS: { id: V2ConfigTab; label: string }[] = [ + { id: "data", label: "데이터" }, + { id: "design", label: "카드 디자인" }, + { id: "actions", label: "동작" }, +]; + +// ===== 셀 타입 라벨 ===== + +const V2_CELL_TYPE_LABELS: Record = { + text: { label: "텍스트", group: "기본" }, + field: { label: "필드 (라벨+값)", group: "기본" }, + image: { label: "이미지", group: "기본" }, + badge: { label: "배지", group: "기본" }, + button: { label: "버튼", group: "동작" }, + "number-input": { label: "숫자 입력", group: "입력" }, + "cart-button": { label: "담기 버튼", group: "입력" }, + "package-summary": { label: "포장 요약", group: "요약" }, + "status-badge": { label: "상태 배지", group: "표시" }, + timeline: { label: "타임라인", group: "표시" }, + "footer-status": { label: "하단 상태", group: "표시" }, + "action-buttons": { label: "액션 버튼", group: "동작" }, +}; + +const CELL_TYPE_GROUPS = ["기본", "표시", "입력", "동작", "요약"] as const; + +// ===== 그리드 유틸 ===== + +const parseFr = (v: string): number => { + const num = parseFloat(v); + return isNaN(num) || num <= 0 ? 1 : num; +}; + +const GRID_LIMITS = { + cols: { min: 1, max: 6 }, + rows: { min: 1, max: 6 }, + gap: { min: 0, max: 16 }, + minFr: 0.3, +} as const; + +const DEFAULT_ROW_HEIGHT = 32; +const MIN_ROW_HEIGHT = 24; + +const parsePx = (v: string): number => { + const num = parseInt(v); + return isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num; +}; + +const migrateRowHeight = (v: string): string => { + if (!v || v.endsWith("fr")) { + return `${Math.round(parseFr(v) * DEFAULT_ROW_HEIGHT)}px`; + } + if (v.endsWith("px")) return v; + const num = parseInt(v); + return `${isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num}px`; +}; + +const shortType = (t: string): string => { + const lower = t.toLowerCase(); + if (lower.includes("character varying") || lower === "varchar") return "varchar"; + if (lower === "text") return "text"; + if (lower.includes("timestamp")) return "ts"; + if (lower === "integer" || lower === "int4") return "int"; + if (lower === "bigint" || lower === "int8") return "bigint"; + if (lower === "numeric" || lower === "decimal") return "num"; + if (lower === "boolean" || lower === "bool") return "bool"; + if (lower === "date") return "date"; + if (lower === "jsonb" || lower === "json") return "json"; + return t.length > 8 ? t.slice(0, 6) + ".." : t; +}; + +// ===== 메인 컴포넌트 ===== + +export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) { + const [tab, setTab] = useState("data"); + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [selectedColumns, setSelectedColumns] = useState([]); + + const cfg: PopCardListV2Config = { + ...V2_DEFAULT_CONFIG, + ...config, + dataSource: { ...V2_DEFAULT_CONFIG.dataSource, ...config?.dataSource }, + cardGrid: { ...V2_DEFAULT_CONFIG.cardGrid, ...config?.cardGrid }, + overflow: { ...V2_DEFAULT_CONFIG.overflow, ...config?.overflow } as V2OverflowConfig, + }; + + const update = (partial: Partial) => { + onUpdate({ ...cfg, ...partial }); + }; + + useEffect(() => { + fetchTableList() + .then(setTables) + .catch(() => setTables([])); + }, []); + + useEffect(() => { + if (!cfg.dataSource.tableName) { + setColumns([]); + return; + } + fetchTableColumns(cfg.dataSource.tableName) + .then(setColumns) + .catch(() => setColumns([])); + }, [cfg.dataSource.tableName]); + + useEffect(() => { + if (cfg.selectedColumns && cfg.selectedColumns.length > 0) { + setSelectedColumns(cfg.selectedColumns); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cfg.dataSource.tableName]); + + return ( +
+ {/* 탭 바 */} +
+ {TAB_LABELS.map((t) => ( + + ))} +
+ + {/* 탭 컨텐츠 */} + {tab === "data" && ( + { + setSelectedColumns([]); + update({ + dataSource: { ...cfg.dataSource, tableName }, + selectedColumns: [], + cardGrid: { ...cfg.cardGrid, cells: [] }, + }); + }} + onColumnsChange={(cols) => { + setSelectedColumns(cols); + update({ selectedColumns: cols }); + }} + onDataSourceChange={(dataSource) => update({ dataSource })} + onSortChange={(sort) => + update({ dataSource: { ...cfg.dataSource, sort } }) + } + /> + )} + + {tab === "design" && ( + update({ cardGrid })} + onGridColumnsChange={(gridColumns) => update({ gridColumns })} + onCardGapChange={(cardGap) => update({ cardGap })} + /> + )} + + {tab === "actions" && ( + + )} +
+ ); +} + +// ===== 탭 1: 데이터 ===== + +function TabData({ + cfg, + tables, + columns, + selectedColumns, + onTableChange, + onColumnsChange, + onDataSourceChange, + onSortChange, +}: { + cfg: PopCardListV2Config; + tables: TableInfo[]; + columns: ColumnInfo[]; + selectedColumns: string[]; + onTableChange: (tableName: string) => void; + onColumnsChange: (cols: string[]) => void; + onDataSourceChange: (ds: CardListDataSource) => void; + onSortChange: (sort: CardSortConfig[] | undefined) => void; +}) { + const [tableOpen, setTableOpen] = useState(false); + const ds = cfg.dataSource; + + const selectedDisplay = ds.tableName + ? tables.find((t) => t.tableName === ds.tableName)?.displayName || ds.tableName + : ""; + + const toggleColumn = (colName: string) => { + if (selectedColumns.includes(colName)) { + onColumnsChange(selectedColumns.filter((c) => c !== colName)); + } else { + onColumnsChange([...selectedColumns, colName]); + } + }; + + const sort = ds.sort?.[0]; + + return ( +
+ {/* 테이블 선택 */} +
+ + + + + + + + + + + 검색 결과가 없습니다 + + + { onTableChange(""); setTableOpen(false); }} + className="text-xs" + > + + 선택 안 함 + + {tables.map((t) => ( + { onTableChange(t.tableName); setTableOpen(false); }} + className="text-xs" + > + +
+ {t.displayName || t.tableName} + {t.displayName && t.displayName !== t.tableName && ( + {t.tableName} + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 컬럼 선택 */} + {ds.tableName && columns.length > 0 && ( +
+ +
+ {columns.map((col) => ( + + ))} +
+
+ )} + + {/* 조인 설정 (접이식) */} + {ds.tableName && ( + + )} + + {/* 정렬 */} + {ds.tableName && columns.length > 0 && ( +
+ +
+ + {sort?.column && ( + + )} +
+
+ )} +
+ ); +} + +// ===== 조인 섹션 ===== + +function JoinSection({ + dataSource, + tables, + mainColumns, + onChange, +}: { + dataSource: CardListDataSource; + tables: TableInfo[]; + mainColumns: ColumnInfo[]; + onChange: (ds: CardListDataSource) => void; +}) { + const [expanded, setExpanded] = useState((dataSource.joins?.length || 0) > 0); + const joins = dataSource.joins || []; + + const addJoin = () => { + const newJoin: CardColumnJoin = { + targetTable: "", + joinType: "LEFT", + sourceColumn: "", + targetColumn: "", + }; + onChange({ ...dataSource, joins: [...joins, newJoin] }); + setExpanded(true); + }; + + const removeJoin = (index: number) => { + onChange({ ...dataSource, joins: joins.filter((_, i) => i !== index) }); + }; + + const updateJoin = (index: number, partial: Partial) => { + onChange({ + ...dataSource, + joins: joins.map((j, i) => (i === index ? { ...j, ...partial } : j)), + }); + }; + + return ( +
+ + {expanded && ( +
+

+ 다른 테이블의 데이터를 연결하여 함께 표시 (선택사항) +

+ {joins.map((join, i) => ( + updateJoin(i, partial)} + onRemove={() => removeJoin(i)} + /> + ))} + +
+ )} +
+ ); +} + +// ===== 조인 아이템 ===== + +function JoinItemV2({ + join, + index, + tables, + mainColumns, + mainTableName, + onUpdate, + onRemove, +}: { + join: CardColumnJoin; + index: number; + tables: TableInfo[]; + mainColumns: ColumnInfo[]; + mainTableName: string; + onUpdate: (partial: Partial) => void; + onRemove: () => void; +}) { + const [targetColumns, setTargetColumns] = useState([]); + const [tableOpen, setTableOpen] = useState(false); + + useEffect(() => { + if (!join.targetTable) { setTargetColumns([]); return; } + fetchTableColumns(join.targetTable) + .then(setTargetColumns) + .catch(() => setTargetColumns([])); + }, [join.targetTable]); + + const autoMatches = mainColumns.filter((mc) => + targetColumns.some((tc) => tc.name === mc.name && tc.type === mc.type) + ); + + const selectableTables = tables.filter((t) => t.tableName !== mainTableName); + const hasJoinCondition = join.sourceColumn !== "" && join.targetColumn !== ""; + const selectedTargetCols = join.selectedTargetColumns || []; + const pickableTargetCols = targetColumns.filter((tc) => tc.name !== join.targetColumn); + + const toggleTargetCol = (colName: string) => { + const next = selectedTargetCols.includes(colName) + ? selectedTargetCols.filter((c) => c !== colName) + : [...selectedTargetCols, colName]; + onUpdate({ selectedTargetColumns: next }); + }; + + return ( +
+
+ 연결 #{index + 1} + +
+ + {/* 대상 테이블 */} + + + + + + + + + 없음 + + {selectableTables.map((t) => ( + { + onUpdate({ targetTable: t.tableName, sourceColumn: "", targetColumn: "", selectedTargetColumns: [] }); + setTableOpen(false); + }} + className="text-[10px]" + > + + {t.tableName} + + ))} + + + + + + + {/* 자동 매칭 */} + {join.targetTable && autoMatches.length > 0 && ( +
+ 연결 조건 + {autoMatches.map((mc) => { + const isSelected = join.sourceColumn === mc.name && join.targetColumn === mc.name; + return ( + + ); + })} +
+ )} + + {/* 수동 매칭 */} + {join.targetTable && autoMatches.length === 0 && ( +
+ + = + +
+ )} + + {/* 표시 방식 */} + {join.targetTable && ( +
+ {(["LEFT", "INNER"] as const).map((jt) => ( + + ))} +
+ )} + + {/* 가져올 컬럼 */} + {hasJoinCondition && pickableTargetCols.length > 0 && ( +
+ 가져올 컬럼 ({selectedTargetCols.length}개) +
+ {pickableTargetCols.map((tc) => { + const isChecked = selectedTargetCols.includes(tc.name); + return ( + + ); + })} +
+
+ )} +
+ ); +} + +// ===== 탭 2: 카드 디자인 ===== + +function TabCardDesign({ + cfg, + columns, + selectedColumns, + tables, + onGridChange, + onGridColumnsChange, + onCardGapChange, +}: { + cfg: PopCardListV2Config; + columns: ColumnInfo[]; + selectedColumns: string[]; + tables: TableInfo[]; + onGridChange: (g: CardGridConfigV2) => void; + onGridColumnsChange: (n: number) => void; + onCardGapChange: (n: number) => void; +}) { + const availableColumns = columns.filter((c) => selectedColumns.includes(c.name)); + const joinedColumns = (cfg.dataSource.joins || []).flatMap((j) => + (j.selectedTargetColumns || []).map((col) => ({ + name: `${j.targetTable}.${col}`, + displayName: col, + sourceTable: j.targetTable, + })) + ); + const allColumnOptions = [ + ...availableColumns.map((c) => ({ value: c.name, label: c.name })), + ...joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })), + ]; + + const [selectedCellId, setSelectedCellId] = useState(null); + const [mergeMode, setMergeMode] = useState(false); + const [mergeCellKeys, setMergeCellKeys] = useState>(new Set()); + const widthBarRef = useRef(null); + const gridRef = useRef(null); + const gridConfigRef = useRef(undefined); + const isDraggingRef = useRef(false); + const [gridLines, setGridLines] = useState<{ colLines: number[]; rowLines: number[] }>({ colLines: [], rowLines: [] }); + + // 그리드 정규화 + const rawGrid = cfg.cardGrid; + const migratedRowHeights = (rawGrid.rowHeights || Array(rawGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)).map(migrateRowHeight); + const safeColWidths = rawGrid.colWidths || []; + const normalizedColWidths = safeColWidths.length >= rawGrid.cols + ? safeColWidths.slice(0, rawGrid.cols) + : [...safeColWidths, ...Array(rawGrid.cols - safeColWidths.length).fill("1fr")]; + const normalizedRowHeights = migratedRowHeights.length >= rawGrid.rows + ? migratedRowHeights.slice(0, rawGrid.rows) + : [...migratedRowHeights, ...Array(rawGrid.rows - migratedRowHeights.length).fill(`${DEFAULT_ROW_HEIGHT}px`)]; + + const grid: CardGridConfigV2 = { + ...rawGrid, + colWidths: normalizedColWidths, + rowHeights: normalizedRowHeights, + }; + gridConfigRef.current = grid; + + const updateGrid = (partial: Partial) => { + onGridChange({ ...grid, ...partial }); + }; + + // 점유 맵 + const buildOccupationMap = (): Record => { + const map: Record = {}; + grid.cells.forEach((cell) => { + const rs = Number(cell.rowSpan) || 1; + const cs = Number(cell.colSpan) || 1; + for (let r = cell.row; r < cell.row + rs; r++) { + for (let c = cell.col; c < cell.col + cs; c++) { + map[`${r}-${c}`] = cell.id; + } + } + }); + return map; + }; + const occupationMap = buildOccupationMap(); + const getCellByOrigin = (r: number, c: number) => grid.cells.find((cell) => cell.row === r && cell.col === c); + + // 셀 CRUD + const addCellAt = (row: number, col: number) => { + const newCell: CardCellDefinitionV2 = { + id: `cell-${Date.now()}`, + row, col, rowSpan: 1, colSpan: 1, + type: "text", + }; + updateGrid({ cells: [...grid.cells, newCell] }); + setSelectedCellId(newCell.id); + }; + + const removeCell = (id: string) => { + updateGrid({ cells: grid.cells.filter((c) => c.id !== id) }); + if (selectedCellId === id) setSelectedCellId(null); + }; + + const updateCell = (id: string, partial: Partial) => { + updateGrid({ cells: grid.cells.map((c) => (c.id === id ? { ...c, ...partial } : c)) }); + }; + + // 병합 + const toggleMergeMode = () => { + if (mergeMode) { setMergeMode(false); setMergeCellKeys(new Set()); } + else { setMergeMode(true); setMergeCellKeys(new Set()); setSelectedCellId(null); } + }; + + const toggleMergeCell = (row: number, col: number) => { + const key = `${row}-${col}`; + if (occupationMap[key]) return; + const next = new Set(mergeCellKeys); + if (next.has(key)) next.delete(key); else next.add(key); + setMergeCellKeys(next); + }; + + const validateMerge = (): { minRow: number; maxRow: number; minCol: number; maxCol: number } | null => { + if (mergeCellKeys.size < 2) return null; + const positions = Array.from(mergeCellKeys).map((k) => { const [r, c] = k.split("-").map(Number); return { row: r, col: c }; }); + const minRow = Math.min(...positions.map((p) => p.row)); + const maxRow = Math.max(...positions.map((p) => p.row)); + const minCol = Math.min(...positions.map((p) => p.col)); + const maxCol = Math.max(...positions.map((p) => p.col)); + if (mergeCellKeys.size !== (maxRow - minRow + 1) * (maxCol - minCol + 1)) return null; + for (const key of mergeCellKeys) { if (occupationMap[key]) return null; } + return { minRow, maxRow, minCol, maxCol }; + }; + + const confirmMerge = () => { + const bbox = validateMerge(); + if (!bbox) return; + const newCell: CardCellDefinitionV2 = { + id: `cell-${Date.now()}`, + row: bbox.minRow, col: bbox.minCol, + rowSpan: bbox.maxRow - bbox.minRow + 1, + colSpan: bbox.maxCol - bbox.minCol + 1, + type: "text", + }; + updateGrid({ cells: [...grid.cells, newCell] }); + setSelectedCellId(newCell.id); + setMergeMode(false); + setMergeCellKeys(new Set()); + }; + + // 셀 분할 + const splitCellHorizontally = (cell: CardCellDefinitionV2) => { + const cs = Number(cell.colSpan) || 1; + const rs = Number(cell.rowSpan) || 1; + if (cs >= 2) { + const leftSpan = Math.ceil(cs / 2); + const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: cell.row, col: cell.col + leftSpan, rowSpan: rs, colSpan: cs - leftSpan, type: "text" }; + const updatedCells = grid.cells.map((c) => c.id === cell.id ? { ...c, colSpan: leftSpan } : c); + updateGrid({ cells: [...updatedCells, newCell] }); + setSelectedCellId(newCell.id); + } else { + if (grid.cols >= GRID_LIMITS.cols.max) return; + const insertPos = cell.col + 1; + const updatedCells = grid.cells.map((c) => { + if (c.id === cell.id) return c; + const cEnd = c.col + (Number(c.colSpan) || 1) - 1; + if (c.col >= insertPos) return { ...c, col: c.col + 1 }; + if (cEnd >= insertPos) return { ...c, colSpan: (Number(c.colSpan) || 1) + 1 }; + return c; + }); + const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: cell.row, col: insertPos, rowSpan: rs, colSpan: 1, type: "text" }; + const colIdx = cell.col - 1; + if (colIdx < 0 || colIdx >= grid.colWidths.length) return; + const currentFr = parseFr(grid.colWidths[colIdx]); + const halfFr = Math.max(GRID_LIMITS.minFr, currentFr / 2); + const frStr = `${Math.round(halfFr * 10) / 10}fr`; + const newWidths = [...grid.colWidths]; + newWidths[colIdx] = frStr; + newWidths.splice(colIdx + 1, 0, frStr); + updateGrid({ cols: grid.cols + 1, colWidths: newWidths, cells: [...updatedCells, newCell] }); + setSelectedCellId(newCell.id); + } + }; + + const splitCellVertically = (cell: CardCellDefinitionV2) => { + const rs = Number(cell.rowSpan) || 1; + const cs = Number(cell.colSpan) || 1; + const heights = grid.rowHeights || Array(grid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`); + if (rs >= 2) { + const topSpan = Math.ceil(rs / 2); + const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: cell.row + topSpan, col: cell.col, rowSpan: rs - topSpan, colSpan: cs, type: "text" }; + const updatedCells = grid.cells.map((c) => c.id === cell.id ? { ...c, rowSpan: topSpan } : c); + updateGrid({ cells: [...updatedCells, newCell] }); + setSelectedCellId(newCell.id); + } else { + if (grid.rows >= GRID_LIMITS.rows.max) return; + const insertPos = cell.row + 1; + const updatedCells = grid.cells.map((c) => { + if (c.id === cell.id) return c; + const cEnd = c.row + (Number(c.rowSpan) || 1) - 1; + if (c.row >= insertPos) return { ...c, row: c.row + 1 }; + if (cEnd >= insertPos) return { ...c, rowSpan: (Number(c.rowSpan) || 1) + 1 }; + return c; + }); + const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: insertPos, col: cell.col, rowSpan: 1, colSpan: cs, type: "text" }; + const newHeights = [...heights]; + newHeights.splice(cell.row - 1 + 1, 0, `${DEFAULT_ROW_HEIGHT}px`); + updateGrid({ rows: grid.rows + 1, rowHeights: newHeights, cells: [...updatedCells, newCell] }); + setSelectedCellId(newCell.id); + } + }; + + // 클릭 핸들러 + const handleEmptyCellClick = (row: number, col: number) => { + if (mergeMode) toggleMergeCell(row, col); + else addCellAt(row, col); + }; + const handleCellClick = (cell: CardCellDefinitionV2) => { + if (mergeMode) return; + setSelectedCellId(selectedCellId === cell.id ? null : cell.id); + }; + + // 열 너비 드래그 + const handleColDragStart = useCallback((e: React.MouseEvent, dividerIndex: number) => { + e.preventDefault(); + isDraggingRef.current = true; + const startX = e.clientX; + const bar = widthBarRef.current; + if (!bar) return; + const barWidth = bar.offsetWidth; + if (barWidth === 0) return; + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + const startFrs = (currentGrid.colWidths || []).map(parseFr); + const totalFr = startFrs.reduce((a, b) => a + b, 0); + + const onMove = (me: MouseEvent) => { + const delta = me.clientX - startX; + const frDelta = (delta / barWidth) * totalFr; + const newFrs = [...startFrs]; + newFrs[dividerIndex] = Math.max(GRID_LIMITS.minFr, startFrs[dividerIndex] + frDelta); + newFrs[dividerIndex + 1] = Math.max(GRID_LIMITS.minFr, startFrs[dividerIndex + 1] - frDelta); + onGridChange({ ...currentGrid, colWidths: newFrs.map((fr) => `${Math.round(fr * 10) / 10}fr`) }); + }; + const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, [onGridChange]); + + // 행 높이 드래그 + const handleRowDragStart = useCallback((e: React.MouseEvent, dividerIndex: number) => { + e.preventDefault(); + isDraggingRef.current = true; + const startY = e.clientY; + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + const heights = (currentGrid.rowHeights || Array(currentGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)).map(parsePx); + if (dividerIndex < 0 || dividerIndex + 1 >= heights.length) return; + + const onMove = (me: MouseEvent) => { + const delta = me.clientY - startY; + const newH = [...heights]; + newH[dividerIndex] = Math.max(MIN_ROW_HEIGHT, heights[dividerIndex] + delta); + newH[dividerIndex + 1] = Math.max(MIN_ROW_HEIGHT, heights[dividerIndex + 1] - delta); + onGridChange({ ...currentGrid, rowHeights: newH.map((h) => `${Math.round(h)}px`) }); + }; + const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, [onGridChange]); + + // 내부 셀 경계 드래그 + useEffect(() => { + const gridEl = gridRef.current; + if (!gridEl) return; + const measure = () => { + if (isDraggingRef.current) return; + const style = window.getComputedStyle(gridEl); + const colSizes = style.gridTemplateColumns.split(" ").map(parseFloat).filter((v) => !isNaN(v)); + const rowSizes = style.gridTemplateRows.split(" ").map(parseFloat).filter((v) => !isNaN(v)); + const gapSize = parseFloat(style.gap) || 0; + const colLines: number[] = []; + let x = 0; + for (let i = 0; i < colSizes.length - 1; i++) { x += colSizes[i] + gapSize; colLines.push(x - gapSize / 2); } + const rowLines: number[] = []; + let y = 0; + for (let i = 0; i < rowSizes.length - 1; i++) { y += rowSizes[i] + gapSize; rowLines.push(y - gapSize / 2); } + setGridLines({ colLines, rowLines }); + }; + const observer = new ResizeObserver(measure); + observer.observe(gridEl); + measure(); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [grid.colWidths.join(","), grid.rowHeights?.join(","), grid.gap, grid.cols, grid.rows]); + + const handleInternalColDrag = useCallback((e: React.MouseEvent, lineIdx: number) => { + e.preventDefault(); e.stopPropagation(); + isDraggingRef.current = true; + const startX = e.clientX; + const gridEl = gridRef.current; + if (!gridEl) return; + const gridWidth = gridEl.offsetWidth; + if (gridWidth === 0) return; + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + const startFrs = (currentGrid.colWidths || []).map(parseFr); + const totalFr = startFrs.reduce((a, b) => a + b, 0); + const onMove = (me: MouseEvent) => { + const delta = me.clientX - startX; + const frDelta = (delta / gridWidth) * totalFr; + const newFrs = [...startFrs]; + newFrs[lineIdx] = Math.max(GRID_LIMITS.minFr, startFrs[lineIdx] + frDelta); + newFrs[lineIdx + 1] = Math.max(GRID_LIMITS.minFr, startFrs[lineIdx + 1] - frDelta); + onGridChange({ ...currentGrid, colWidths: newFrs.map((fr) => `${Math.round(fr * 10) / 10}fr`) }); + }; + const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; + document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); + }, [onGridChange]); + + const handleInternalRowDrag = useCallback((e: React.MouseEvent, lineIdx: number) => { + e.preventDefault(); e.stopPropagation(); + isDraggingRef.current = true; + const startY = e.clientY; + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + const heights = (currentGrid.rowHeights || Array(currentGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)).map(parsePx); + if (lineIdx < 0 || lineIdx + 1 >= heights.length) return; + const onMove = (me: MouseEvent) => { + const delta = me.clientY - startY; + const newH = [...heights]; + newH[lineIdx] = Math.max(MIN_ROW_HEIGHT, heights[lineIdx] + delta); + newH[lineIdx + 1] = Math.max(MIN_ROW_HEIGHT, heights[lineIdx + 1] - delta); + onGridChange({ ...currentGrid, rowHeights: newH.map((h) => `${Math.round(h)}px`) }); + }; + const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; + document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); + }, [onGridChange]); + + // 경계선 가시성 + const isColLineVisible = (lineIdx: number): boolean => { + const leftCol = lineIdx + 1, rightCol = lineIdx + 2; + for (let r = 1; r <= grid.rows; r++) { + const left = occupationMap[`${r}-${leftCol}`], right = occupationMap[`${r}-${rightCol}`]; + if (left !== right || (!left && !right)) return true; + } + return false; + }; + const isRowLineVisible = (lineIdx: number): boolean => { + const topRow = lineIdx + 1, bottomRow = lineIdx + 2; + for (let c = 1; c <= grid.cols; c++) { + const top = occupationMap[`${topRow}-${c}`], bottom = occupationMap[`${bottomRow}-${c}`]; + if (top !== bottom || (!top && !bottom)) return true; + } + return false; + }; + + const selectedCell = selectedCellId ? grid.cells.find((c) => c.id === selectedCellId) : null; + useEffect(() => { + if (selectedCellId && !grid.cells.find((c) => c.id === selectedCellId)) setSelectedCellId(null); + }, [grid.cells, selectedCellId]); + + const mergeValid = validateMerge(); + + const gridPositions: { row: number; col: number }[] = []; + for (let r = 1; r <= grid.rows; r++) { + for (let c = 1; c <= grid.cols; c++) { + gridPositions.push({ row: r, col: c }); + } + } + const rowHeightsArr = grid.rowHeights || Array(grid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`); + + // 바 그룹핑 + type BarGroup = { startIdx: number; count: number; totalFr: number }; + const colGroups: BarGroup[] = (() => { + const groups: BarGroup[] = []; + if (grid.colWidths.length === 0) return groups; + let cur: BarGroup = { startIdx: 0, count: 1, totalFr: parseFr(grid.colWidths[0]) }; + for (let i = 0; i < grid.cols - 1; i++) { + if (isColLineVisible(i)) { groups.push(cur); cur = { startIdx: i + 1, count: 1, totalFr: parseFr(grid.colWidths[i + 1]) }; } + else { cur.count++; cur.totalFr += parseFr(grid.colWidths[i + 1]); } + } + groups.push(cur); + return groups; + })(); + + const rowGroups: BarGroup[] = (() => { + const groups: BarGroup[] = []; + if (rowHeightsArr.length === 0) return groups; + let cur: BarGroup = { startIdx: 0, count: 1, totalFr: parsePx(rowHeightsArr[0]) }; + for (let i = 0; i < grid.rows - 1; i++) { + if (isRowLineVisible(i)) { groups.push(cur); cur = { startIdx: i + 1, count: 1, totalFr: parsePx(rowHeightsArr[i + 1]) }; } + else { cur.count++; cur.totalFr += parsePx(rowHeightsArr[i + 1]); } + } + groups.push(cur); + return groups; + })(); + + return ( +
+ {/* 카드 배치 */} +
+
+ 열 수 + + {cfg.gridColumns || 3} + +
+
+ 카드 간격 + + {cfg.cardGap || 8}px + +
+
+ + {/* 인라인 툴바 */} +
+ +
+ 간격 + + {grid.gap}px + +
+ +
+ + +
+ + {/* 병합 모드 안내 */} + {mergeMode && ( +
+ + {mergeCellKeys.size > 0 ? `${mergeCellKeys.size}칸 선택됨${mergeValid ? " (병합 가능)" : " (직사각형으로 선택)"}` : "빈 셀을 클릭하여 선택"} + + + +
+ )} + + {/* 열 너비 드래그 바 */} +
+
+
+ {colGroups.map((group, gi) => ( + +
+ {group.count > 1 ? `${Math.round(group.totalFr * 10) / 10}fr` : grid.colWidths[group.startIdx]} +
+ {gi < colGroups.length - 1 && ( +
handleColDragStart(e, group.startIdx + group.count - 1)} /> + )} + + ))} +
+
+ + {/* 행 높이 바 + 그리드 */} +
+
+ {rowGroups.map((group, gi) => ( + +
{Math.round(group.totalFr)}
+ {gi < rowGroups.length - 1 && ( +
handleRowDragStart(e, group.startIdx + group.count - 1)} /> + )} + + ))} +
+
+
0 ? grid.colWidths.map((w) => `minmax(30px, ${w})`).join(" ") : "1fr", + gridTemplateRows: rowHeightsArr.join(" "), + gap: `${Number(grid.gap) || 0}px`, + }} + > + {gridPositions.map(({ row, col }) => { + const cellAtOrigin = getCellByOrigin(row, col); + const occupiedBy = occupationMap[`${row}-${col}`]; + const isMergeSelected = mergeCellKeys.has(`${row}-${col}`); + if (occupiedBy && !cellAtOrigin) return null; + if (cellAtOrigin) { + const isSelected = selectedCellId === cellAtOrigin.id; + return ( +
handleCellClick(cellAtOrigin)} + > +
+ {cellAtOrigin.columnName || cellAtOrigin.label || "미지정"} + {V2_CELL_TYPE_LABELS[cellAtOrigin.type]?.label || cellAtOrigin.type} +
+
+ ); + } + return ( +
handleEmptyCellClick(row, col)} + > + {isMergeSelected ? : } +
+ ); + })} +
+ {/* 내부 경계 드래그 오버레이 */} +
+ {gridLines.colLines.map((x, i) => { + if (!isColLineVisible(i)) return null; + return
handleInternalColDrag(e, i)} />; + })} + {gridLines.rowLines.map((y, i) => { + if (!isRowLineVisible(i)) return null; + return
handleInternalRowDrag(e, i)} />; + })} +
+
+
+ +

+ {grid.cols}열 x {grid.rows}행 (최대 {GRID_LIMITS.cols.max}x{GRID_LIMITS.rows.max}) +

+ + {/* 선택된 셀 설정 패널 */} + {selectedCell && !mergeMode && ( + updateCell(selectedCell.id, partial)} + onRemove={() => removeCell(selectedCell.id)} + /> + )} +
+ ); +} + +// ===== 셀 상세 에디터 (타입별 인라인) ===== + +function CellDetailEditor({ + cell, + allCells, + allColumnOptions, + columns, + selectedColumns, + tables, + dataSource, + onUpdate, + onRemove, +}: { + cell: CardCellDefinitionV2; + allCells: CardCellDefinitionV2[]; + allColumnOptions: { value: string; label: string }[]; + columns: ColumnInfo[]; + selectedColumns: string[]; + tables: TableInfo[]; + dataSource: CardListDataSource; + onUpdate: (partial: Partial) => void; + onRemove: () => void; +}) { + const availableTableOptions = useMemo(() => { + const opts: { value: string; label: string }[] = []; + if (dataSource.tableName) { + opts.push({ value: dataSource.tableName, label: `${dataSource.tableName} (메인)` }); + } + for (const j of dataSource.joins || []) { + if (j.targetTable) { + opts.push({ value: j.targetTable, label: `${j.targetTable} (조인)` }); + } + } + const added = new Set(opts.map((o) => o.value)); + for (const c of allCells) { + const pt = c.timelineSource?.processTable; + if (pt && !added.has(pt)) { + opts.push({ value: pt, label: `${pt} (타임라인)` }); + added.add(pt); + } + } + return opts; + }, [dataSource, allCells]); + + return ( +
+
+ + 셀 (행{cell.row} 열{cell.col} + {((Number(cell.colSpan) || 1) > 1 || (Number(cell.rowSpan) || 1) > 1) && `, ${Number(cell.colSpan) || 1}x${Number(cell.rowSpan) || 1}`}) + + +
+ + {/* 컬럼 + 타입 */} +
+ {cell.type !== "action-buttons" && ( + + )} + +
+ + {/* 라벨 + 위치 */} +
+ onUpdate({ label: e.target.value })} placeholder="라벨 (선택)" className="h-7 flex-1 text-[10px]" /> + +
+ + {/* 크기 + 정렬 */} +
+ + + +
+ + {/* 타입별 상세 설정 */} + {cell.type === "status-badge" && } + {cell.type === "timeline" && } + {cell.type === "action-buttons" && } + {cell.type === "footer-status" && } + {cell.type === "field" && } + {cell.type === "number-input" && ( +
+ 숫자 입력 설정 +
+ onUpdate({ inputUnit: e.target.value })} placeholder="단위 (EA)" className="h-7 flex-1 text-[10px]" /> + +
+
+ )} + {cell.type === "cart-button" && ( +
+ 담기 버튼 설정 +
+ onUpdate({ cartLabel: e.target.value })} placeholder="담기" className="h-7 flex-1 text-[10px]" /> + onUpdate({ cartCancelLabel: e.target.value })} placeholder="취소" className="h-7 flex-1 text-[10px]" /> +
+
+ )} +
+ ); +} + +// ===== 상태 배지 매핑 에디터 ===== + +const SEMANTIC_COLORS: Record = { + pending: "#64748b", active: "#3b82f6", done: "#10b981", +}; + +function StatusMappingEditor({ + cell, + allCells, + onUpdate, +}: { + cell: CardCellDefinitionV2; + allCells: CardCellDefinitionV2[]; + onUpdate: (partial: Partial) => void; +}) { + const statusMap = cell.statusMap || []; + + const timelineCell = allCells.find( + (c) => c.type === "timeline" && c.timelineSource?.statusMappings?.length, + ); + const hasTimeline = !!timelineCell; + + const loadFromTimeline = () => { + const src = timelineCell?.timelineSource; + if (!src?.statusMappings) return; + const partial: Partial = { + statusMap: src.statusMappings.map((m) => ({ + value: m.dbValue, + label: m.label, + color: SEMANTIC_COLORS[m.semantic] || "#6b7280", + })), + }; + if (src.statusColumn) { + partial.column = src.statusColumn; + } + onUpdate(partial); + }; + + const addMapping = () => { + onUpdate({ statusMap: [...statusMap, { value: "", label: "", color: "#6b7280" }] }); + }; + + const updateMapping = (index: number, partial: Partial<{ value: string; label: string; color: string }>) => { + onUpdate({ statusMap: statusMap.map((m, i) => (i === index ? { ...m, ...partial } : m)) }); + }; + + const removeMapping = (index: number) => { + onUpdate({ statusMap: statusMap.filter((_, i) => i !== index) }); + }; + + return ( +
+
+ 상태값-색상 매핑 +
+ {hasTimeline && ( + + )} + +
+
+ {statusMap.map((m, i) => ( +
+ updateMapping(i, { value: e.target.value })} placeholder="값" className="h-6 flex-1 text-[10px]" /> + updateMapping(i, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" /> + updateMapping(i, { color: e.target.value })} className="h-6 w-8 cursor-pointer rounded border" /> + +
+ ))} +
+ ); +} + +// ===== 타임라인 설정 ===== + +function TimelineConfigEditor({ + cell, + allColumnOptions, + tables, + onUpdate, +}: { + cell: CardCellDefinitionV2; + allColumnOptions: { value: string; label: string }[]; + tables: TableInfo[]; + onUpdate: (partial: Partial) => void; +}) { + const src = cell.timelineSource || { processTable: "", foreignKey: "", seqColumn: "", nameColumn: "", statusColumn: "" }; + const [processColumns, setProcessColumns] = useState([]); + const [tableOpen, setTableOpen] = useState(false); + + useEffect(() => { + if (!src.processTable) { setProcessColumns([]); return; } + fetchTableColumns(src.processTable) + .then(setProcessColumns) + .catch(() => setProcessColumns([])); + }, [src.processTable]); + + const updateSource = (partial: Partial) => { + onUpdate({ timelineSource: { ...src, ...partial } }); + }; + + const colOptions = processColumns.map((c) => ({ value: c.name, label: c.name })); + + return ( +
+ 하위 데이터 소스 + + {/* 하위 테이블 선택 */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((t) => ( + { + updateSource({ processTable: t.tableName, foreignKey: "", seqColumn: "", nameColumn: "", statusColumn: "" }); + setTableOpen(false); + }} + className="text-[10px]" + > + + {t.displayName || t.tableName} + + ))} + + + + + +
+ + {/* 컬럼 매핑 (하위 테이블 선택 후) */} + {src.processTable && processColumns.length > 0 && ( +
+ +
+
+ 연결 FK + +
+
+ 순서 + +
+
+ 표시명 + +
+
+ 상태 + +
+
+
+ )} + + {/* 상태 값 매핑 (동적 배열) */} + {src.processTable && src.statusColumn && ( + updateSource({ statusMappings: mappings })} + /> + )} + + {/* 구분선 */} +
+ 표시 옵션 +
+ +
+ 최대 표시 수 + onUpdate({ visibleCount: parseInt(e.target.value) || 5 })} + className="h-7 w-16 text-[10px]" + /> + +
+
+ + onUpdate({ currentHighlight: v })} + /> +
+
+ + onUpdate({ showDetailModal: v })} + /> + 전체 목록 모달 +
+
+ ); +} + +// ===== 상태 값 매핑 에디터 (동적 배열) ===== + +const SEMANTIC_OPTIONS: { value: TimelineStatusSemantic; label: string }[] = [ + { value: "pending", label: "대기" }, + { value: "active", label: "진행" }, + { value: "done", label: "완료" }, +]; + +const DEFAULT_STATUS_MAPPINGS: StatusValueMapping[] = [ + { dbValue: "waiting", label: "대기", semantic: "pending" }, + { dbValue: "accepted", label: "접수", semantic: "active" }, + { dbValue: "in_progress", label: "진행중", semantic: "active" }, + { dbValue: "completed", label: "완료", semantic: "done" }, +]; + +function StatusMappingsEditor({ + mappings, + onChange, +}: { + mappings: StatusValueMapping[]; + onChange: (mappings: StatusValueMapping[]) => void; +}) { + const addMapping = () => { + onChange([...mappings, { dbValue: "", label: "", semantic: "pending" }]); + }; + + const updateMapping = (index: number, partial: Partial) => { + onChange(mappings.map((m, i) => (i === index ? { ...m, ...partial } : m))); + }; + + const removeMapping = (index: number) => { + onChange(mappings.filter((_, i) => i !== index)); + }; + + const applyDefaults = () => { + onChange([...DEFAULT_STATUS_MAPPINGS]); + }; + + return ( +
+
+ +
+ {mappings.length === 0 && ( + + )} + +
+
+

DB 값, 화면 라벨, 의미(대기/진행/완료)를 매핑합니다.

+ {mappings.map((m, i) => ( +
+ updateMapping(i, { dbValue: e.target.value })} + placeholder="DB 값" + className="h-6 flex-1 text-[10px]" + /> + updateMapping(i, { label: e.target.value })} + placeholder="라벨" + className="h-6 flex-1 text-[10px]" + /> + + + +
+ ))} +
+ ); +} + +// ===== 액션 버튼 에디터 (버튼 중심 구조) ===== + +function ActionButtonsEditor({ + cell, + allCells, + allColumnOptions, + availableTableOptions, + onUpdate, +}: { + cell: CardCellDefinitionV2; + allCells: CardCellDefinitionV2[]; + allColumnOptions: { value: string; label: string }[]; + availableTableOptions: { value: string; label: string }[]; + onUpdate: (partial: Partial) => void; +}) { + const buttons = cell.actionButtons || []; + + const statusOptions = useMemo(() => { + const timelineCell = allCells.find( + (c) => c.type === "timeline" && c.timelineSource?.statusMappings?.length, + ); + return timelineCell?.timelineSource?.statusMappings?.map((m) => ({ + value: m.dbValue, + label: m.label, + })) || []; + }, [allCells]); + + const updateButtons = (newBtns: ActionButtonDef[]) => { + onUpdate({ actionButtons: newBtns }); + }; + + const addButton = () => { + updateButtons([...buttons, { + label: "", + variant: "default" as ButtonVariant, + showCondition: { type: "always" }, + clickAction: { type: "immediate" }, + }]); + }; + + const updateBtn = (idx: number, partial: Partial) => { + updateButtons(buttons.map((b, i) => (i === idx ? { ...b, ...partial } : b))); + }; + + const removeBtn = (idx: number) => { + updateButtons(buttons.filter((_, i) => i !== idx)); + }; + + const updateCondition = (idx: number, partial: Partial) => { + const btn = buttons[idx]; + updateBtn(idx, { showCondition: { ...(btn.showCondition || { type: "always" }), ...partial } }); + }; + + // 다중 액션 체이닝 지원: clickActions 배열 우선, 없으면 clickAction 단일값 폴백 + const getActions = (btn: ActionButtonDef): ActionButtonClickAction[] => { + if (btn.clickActions && btn.clickActions.length > 0) return btn.clickActions; + return [btn.clickAction]; + }; + + const setActions = (bIdx: number, actions: ActionButtonClickAction[]) => { + updateBtn(bIdx, { clickActions: actions, clickAction: actions[0] || { type: "immediate" } }); + }; + + const updateAction = (idx: number, aIdx: number, partial: Partial) => { + const actions = [...getActions(buttons[idx])]; + actions[aIdx] = { ...actions[aIdx], ...partial }; + setActions(idx, actions); + }; + + const addAction = (bIdx: number) => { + const actions = [...getActions(buttons[bIdx]), { type: "immediate" as const }]; + setActions(bIdx, actions); + }; + + const removeAction = (bIdx: number, aIdx: number) => { + const actions = getActions(buttons[bIdx]).filter((_, i) => i !== aIdx); + if (actions.length === 0) actions.push({ type: "immediate" as const }); + setActions(bIdx, actions); + }; + + const addActionUpdate = (bIdx: number, aIdx: number) => { + const actions = getActions(buttons[bIdx]); + const a = actions[aIdx]; + updateAction(bIdx, aIdx, { updates: [...(a.updates || []), { column: "", value: "", valueType: "static" as const }] }); + }; + + const updateActionUpdate = (bIdx: number, aIdx: number, uIdx: number, partial: Partial) => { + const a = getActions(buttons[bIdx])[aIdx]; + updateAction(bIdx, aIdx, { updates: (a.updates || []).map((u, i) => (i === uIdx ? { ...u, ...partial } : u)) }); + }; + + const removeActionUpdate = (bIdx: number, aIdx: number, uIdx: number) => { + const a = getActions(buttons[bIdx])[aIdx]; + updateAction(bIdx, aIdx, { updates: (a.updates || []).filter((_, i) => i !== uIdx) }); + }; + + const addSelectModeBtn = (bIdx: number, aIdx: number) => { + const a = getActions(buttons[bIdx])[aIdx]; + const smBtns = a.selectModeButtons || []; + updateAction(bIdx, aIdx, { selectModeButtons: [...smBtns, { label: "", variant: "outline" as ButtonVariant, clickMode: "cancel-select" as const }] }); + }; + + const updateSelectModeBtn = (bIdx: number, aIdx: number, smIdx: number, partial: Partial) => { + const a = getActions(buttons[bIdx])[aIdx]; + const smBtns = (a.selectModeButtons || []).map((s, i) => (i === smIdx ? { ...s, ...partial } : s)); + updateAction(bIdx, aIdx, { selectModeButtons: smBtns }); + }; + + const removeSelectModeBtn = (bIdx: number, aIdx: number, smIdx: number) => { + const a = getActions(buttons[bIdx])[aIdx]; + updateAction(bIdx, aIdx, { selectModeButtons: (a.selectModeButtons || []).filter((_, i) => i !== smIdx) }); + }; + + const addSmBtnUpdate = (bIdx: number, aIdx: number, smIdx: number) => { + const a = getActions(buttons[bIdx])[aIdx]; + const smBtns = [...(a.selectModeButtons || [])]; + smBtns[smIdx] = { ...smBtns[smIdx], updates: [...(smBtns[smIdx].updates || []), { column: "", value: "", valueType: "static" as const }] }; + updateAction(bIdx, aIdx, { selectModeButtons: smBtns }); + }; + + const updateSmBtnUpdate = (bIdx: number, aIdx: number, smIdx: number, uIdx: number, partial: Partial) => { + const a = getActions(buttons[bIdx])[aIdx]; + const smBtns = [...(a.selectModeButtons || [])]; + smBtns[smIdx] = { ...smBtns[smIdx], updates: (smBtns[smIdx].updates || []).map((u, i) => (i === uIdx ? { ...u, ...partial } : u)) }; + updateAction(bIdx, aIdx, { selectModeButtons: smBtns }); + }; + + const removeSmBtnUpdate = (bIdx: number, aIdx: number, smIdx: number, uIdx: number) => { + const a = getActions(buttons[bIdx])[aIdx]; + const smBtns = [...(a.selectModeButtons || [])]; + smBtns[smIdx] = { ...smBtns[smIdx], updates: (smBtns[smIdx].updates || []).filter((_, i) => i !== uIdx) }; + updateAction(bIdx, aIdx, { selectModeButtons: smBtns }); + }; + + + const storageKey = `action-btn-editor-${cell.row}-${cell.col}`; + + const [expandedBtns, setExpandedBtns] = useState>(() => { + try { + const saved = sessionStorage.getItem(`${storageKey}-btns`); + return saved ? new Set(JSON.parse(saved) as number[]) : new Set(); + } catch { return new Set(); } + }); + const [expandedSections, setExpandedSections] = useState>(() => { + try { + const saved = sessionStorage.getItem(`${storageKey}-secs`); + return saved ? JSON.parse(saved) : {}; + } catch { return {}; } + }); + + useEffect(() => { + try { sessionStorage.setItem(`${storageKey}-btns`, JSON.stringify([...expandedBtns])); } catch {} + }, [expandedBtns, storageKey]); + + useEffect(() => { + try { sessionStorage.setItem(`${storageKey}-secs`, JSON.stringify(expandedSections)); } catch {} + }, [expandedSections, storageKey]); + + const toggleBtn = (idx: number) => { + setExpandedBtns((prev) => { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); else next.add(idx); + return next; + }); + }; + + const toggleSection = (key: string) => { + setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + const isSectionOpen = (key: string) => expandedSections[key] !== false; + + const ACTION_TYPE_LABELS: Record = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기" }; + + const getCondSummary = (btn: ActionButtonDef) => { + const c = btn.showCondition; + if (!c || c.type === "always") return "항상"; + if (c.type === "timeline-status") { + const opt = statusOptions.find((o) => o.value === c.value); + return opt ? opt.label : (c.value || "미설정"); + } + if (c.type === "column-value") return `${c.column || "?"} = ${c.value || "?"}`; + return "항상"; + }; + + const addButtonAndExpand = () => { + addButton(); + setExpandedBtns((prev) => new Set([...prev, buttons.length])); + }; + + return ( +
+
+ 버튼 규칙 + +
+ + {buttons.length === 0 && ( +

버튼 규칙을 추가하세요. 상태별로 다른 버튼을 설정할 수 있습니다.

+ )} + + {buttons.map((btn, bi) => { + const condType = btn.showCondition?.type || "always"; + const actions = getActions(btn); + const isExpanded = expandedBtns.has(bi); + const actionSummary = actions.map((a) => ACTION_TYPE_LABELS[a.type] || a.type).join(" -> "); + + return ( +
+ {/* 접기/펼치기 헤더 */} +
toggleBtn(bi)} + > + {isExpanded + ? + : } + #{bi + 1} + {isExpanded ? ( + <> + updateBtn(bi, { label: e.target.value })} + onClick={(e) => e.stopPropagation()} + placeholder="라벨" + className="h-6 flex-1 text-[10px]" + /> + + + ) : ( + + {btn.label || "(미입력)"} | {getCondSummary(btn)} | {actionSummary} + + )} + +
+ + {/* 펼쳐진 상세 */} + {isExpanded && ( +
+ {/* === 조건 섹션 === */} +
toggleSection(`${bi}-cond`)} + > + {isSectionOpen(`${bi}-cond`) + ? + : } + 조건 + {!isSectionOpen(`${bi}-cond`) && ( + {getCondSummary(btn)} + )} +
+ {isSectionOpen(`${bi}-cond`) && ( +
+
+ 활성화 + + {condType === "timeline-status" && ( + + )} + {condType === "column-value" && ( + <> + + updateCondition(bi, { value: e.target.value })} + placeholder="값" + className="h-6 w-20 text-[10px]" + /> + + )} +
+ {condType !== "always" && ( +
+ 그 외 + + + {(btn.showCondition?.unmatchBehavior || "hidden") === "disabled" + ? "보이지만 클릭 불가" + : "버튼 안 보임"} + +
+ )} +
+ )} + + {/* === 실행 섹션 (다중 액션) === */} +
toggleSection(`${bi}-action`)} + > + {isSectionOpen(`${bi}-action`) + ? + : } + 실행 ({actions.length}) + {!isSectionOpen(`${bi}-action`) && ( + {actionSummary} + )} +
+ {isSectionOpen(`${bi}-action`) && ( +
+ {actions.map((action, ai) => { + const aType = action.type; + return ( +
+
+ #{ai + 1} + + {actions.length > 1 && ( + + )} +
+ + {aType === "immediate" && ( + addActionUpdate(bi, ai)} + onUpdateUpdate={(ui, p) => updateActionUpdate(bi, ai, ui, p)} + onRemoveUpdate={(ui) => removeActionUpdate(bi, ai, ui)} + onUpdateAction={(p) => updateAction(bi, ai, p)} + /> + )} + + {aType === "select-mode" && ( +
+
+ 로직 순서 + +
+ {(action.selectModeButtons || []).map((smBtn, si) => ( +
+
+ updateSelectModeBtn(bi, ai, si, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" /> + + +
+
+ 동작 + +
+ {smBtn.clickMode === "status-change" && ( + addSmBtnUpdate(bi, ai, si)} + onUpdateUpdate={(ui, p) => updateSmBtnUpdate(bi, ai, si, ui, p)} + onRemoveUpdate={(ui) => removeSmBtnUpdate(bi, ai, si, ui)} + onUpdateAction={(p) => updateSelectModeBtn(bi, ai, si, { targetTable: p.targetTable ?? smBtn.targetTable, confirmMessage: p.confirmMessage ?? smBtn.confirmMessage })} + /> + )} + {smBtn.clickMode === "modal-open" && ( +
+ POP 화면 + updateSelectModeBtn(bi, ai, si, { modalScreenId: e.target.value })} + placeholder="화면 ID (예: 4481)" + className="h-6 flex-1 text-[10px]" + /> +
+ )} +
+ ))} + {(!action.selectModeButtons || action.selectModeButtons.length === 0) && ( +

로직 순서를 추가하세요.

+ )} +
+ )} + + {aType === "modal-open" && ( +
+ POP 화면 + updateAction(bi, ai, { modalScreenId: e.target.value })} + placeholder="화면 ID (예: 4481)" + className="h-6 flex-1 text-[10px]" + /> +
+ )} +
+ ); + })} + +
+ )} +
+ )} +
+ ); + })} +
+ ); +} + +const SYSTEM_COLUMNS = new Set([ + "id", "company_code", "created_date", "updated_date", "writer", +]); + +function ImmediateActionEditor({ + action, + allColumnOptions, + availableTableOptions, + onAddUpdate, + onUpdateUpdate, + onRemoveUpdate, + onUpdateAction, +}: { + action: ActionButtonClickAction; + allColumnOptions: { value: string; label: string }[]; + availableTableOptions: { value: string; label: string }[]; + onAddUpdate: () => void; + onUpdateUpdate: (uIdx: number, partial: Partial) => void; + onRemoveUpdate: (uIdx: number) => void; + onUpdateAction: (partial: Partial) => void; +}) { + const isExternalTable = action.targetTable && !availableTableOptions.some((t) => t.value === action.targetTable); + const [dbSelectMode, setDbSelectMode] = useState(!!isExternalTable); + const [allTables, setAllTables] = useState([]); + + const [tableColumnGroups, setTableColumnGroups] = useState< + { table: string; label: string; business: { value: string; label: string }[]; system: { value: string; label: string }[] }[] + >([]); + + // 외부 DB 모드 시 전체 테이블 로드 + useEffect(() => { + if (dbSelectMode && allTables.length === 0) { + fetchTableList().then(setAllTables).catch(() => setAllTables([])); + } + }, [dbSelectMode, allTables.length]); + + // 선택된 테이블 컬럼 로드 (카드 소스 + 외부 공통) + const effectiveTableOptions = useMemo(() => { + if (dbSelectMode && action.targetTable) { + const existing = availableTableOptions.find((t) => t.value === action.targetTable); + if (!existing) return [...availableTableOptions, { value: action.targetTable, label: `${action.targetTable} (외부)` }]; + } + return availableTableOptions; + }, [availableTableOptions, dbSelectMode, action.targetTable]); + + useEffect(() => { + let cancelled = false; + const loadAll = async () => { + const groups: typeof tableColumnGroups = []; + for (const t of effectiveTableOptions) { + try { + const cols = await fetchTableColumns(t.value); + const mapped = cols.map((c) => ({ value: c.name, label: c.name })); + groups.push({ + table: t.value, + label: t.label, + business: mapped.filter((c) => !SYSTEM_COLUMNS.has(c.value)), + system: mapped.filter((c) => SYSTEM_COLUMNS.has(c.value)), + }); + } catch { + groups.push({ table: t.value, label: t.label, business: [], system: [] }); + } + } + if (!cancelled) setTableColumnGroups(groups); + }; + if (effectiveTableOptions.length > 0) loadAll(); + else setTableColumnGroups([]); + return () => { cancelled = true; }; + }, [effectiveTableOptions]); + + const selectedGroup = tableColumnGroups.find((g) => g.table === action.targetTable); + const businessCols = selectedGroup?.business || []; + const systemCols = selectedGroup?.system || []; + const tableName = action.targetTable?.trim() || ""; + + // 메인 테이블 컬럼 (조인키 소스 컬럼 선택 용도) + const mainTableGroup = tableColumnGroups.find((g) => availableTableOptions[0]?.value === g.table); + const mainCols = mainTableGroup ? [...mainTableGroup.business, ...mainTableGroup.system] : []; + + return ( +
+ {/* 대상 테이블 */} +
+ 대상 테이블 + {!dbSelectMode ? ( + + ) : ( +
+ onUpdateAction({ targetTable: v })} + /> + +
+ )} +
+ + {/* 외부 DB 선택 시 조인키 설정 */} + {dbSelectMode && action.targetTable && ( + <> +
+ 기준 컬럼 + +
+
+ 매칭 컬럼 + +
+

+ 메인.기준컬럼 = 외부.매칭컬럼 으로 연결하여 업데이트 +

+ + )} + +
+ 확인 메시지 + onUpdateAction({ confirmMessage: e.target.value })} + placeholder="처리하시겠습니까?" + className="h-6 flex-1 text-[10px]" + /> +
+
+ + 변경할 컬럼{tableName ? ` (${tableName})` : ""} + + +
+ {(action.updates || []).map((u, ui) => ( +
+ + + {(u.valueType === "static" || u.valueType === "columnRef") && ( + onUpdateUpdate(ui, { value: e.target.value })} + placeholder={u.valueType === "static" ? "값" : "컬럼명"} + className="h-6 flex-1 text-[10px]" + /> + )} + +
+ ))} + {(!action.updates || action.updates.length === 0) && ( +

변경 항목을 추가하면 클릭 시 DB가 변경됩니다.

+ )} +
+ ); +} + + +// ===== DB 테이블 검색 Combobox ===== + +function DbTableCombobox({ + value, + tables, + onSelect, +}: { + value: string; + tables: TableInfo[]; + onSelect: (tableName: string) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + if (!search) return tables; + const q = search.toLowerCase(); + return tables.filter( + (t) => + t.tableName.toLowerCase().includes(q) || + (t.tableComment || "").toLowerCase().includes(q), + ); + }, [tables, search]); + + const selectedLabel = useMemo(() => { + if (!value) return "DB 테이블 검색..."; + const found = tables.find((t) => t.tableName === value); + return found ? `${found.tableName}${found.tableComment ? ` (${found.tableComment})` : ""}` : value; + }, [value, tables]); + + return ( + + + + + + + + + + 검색 결과가 없습니다. + + + {filtered.map((t) => ( + { + onSelect(t.tableName); + setOpen(false); + setSearch(""); + }} + className="text-[10px]" + > + + {t.tableName} + {t.tableComment && ( + ({t.tableComment}) + )} + + ))} + + + + + + ); +} + +// ===== 하단 상태 에디터 ===== + +function FooterStatusEditor({ + cell, + allColumnOptions, + onUpdate, +}: { + cell: CardCellDefinitionV2; + allColumnOptions: { value: string; label: string }[]; + onUpdate: (partial: Partial) => void; +}) { + const footerStatusMap = cell.footerStatusMap || []; + + const addMapping = () => { + onUpdate({ footerStatusMap: [...footerStatusMap, { value: "", label: "", color: "#6b7280" }] }); + }; + + const updateMapping = (index: number, partial: Partial<{ value: string; label: string; color: string }>) => { + onUpdate({ footerStatusMap: footerStatusMap.map((m, i) => (i === index ? { ...m, ...partial } : m)) }); + }; + + const removeMapping = (index: number) => { + onUpdate({ footerStatusMap: footerStatusMap.filter((_, i) => i !== index) }); + }; + + return ( +
+ 하단 상태 설정 +
+ onUpdate({ footerLabel: e.target.value })} + placeholder="라벨 (예: 검사의뢰)" + className="h-7 flex-1 text-[10px]" + /> +
+
+ +
+
+ + onUpdate({ showTopBorder: v })} + /> +
+
+ 상태값-색상 매핑 + +
+ {footerStatusMap.map((m, i) => ( +
+ updateMapping(i, { value: e.target.value })} placeholder="값" className="h-6 flex-1 text-[10px]" /> + updateMapping(i, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" /> + updateMapping(i, { color: e.target.value })} className="h-6 w-8 cursor-pointer rounded border" /> + +
+ ))} +
+ ); +} + +// ===== 필드 설정 에디터 ===== + +function FieldConfigEditor({ + cell, + allColumnOptions, + onUpdate, +}: { + cell: CardCellDefinitionV2; + allColumnOptions: { value: string; label: string }[]; + onUpdate: (partial: Partial) => void; +}) { + const valueType = cell.valueType || "column"; + + return ( +
+ 필드 설정 +
+ + onUpdate({ unit: e.target.value })} placeholder="단위" className="h-7 w-16 text-[10px]" /> +
+ {valueType === "formula" && ( +
+ + + + {cell.formulaRightType === "column" && ( + + )} +
+ )} +
+ ); +} + +// ===== 탭 3: 동작 ===== + +function TabActions({ + cfg, + onUpdate, +}: { + cfg: PopCardListV2Config; + onUpdate: (partial: Partial) => void; +}) { + const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }; + const clickAction = cfg.cardClickAction || "none"; + const modalConfig = cfg.cardClickModalConfig || { screenId: "" }; + + return ( +
+ {/* 카드 선택 시 */} +
+ +
+ {(["none", "publish", "navigate", "modal-open"] as V2CardClickAction[]).map((action) => ( + + ))} +
+ {clickAction === "modal-open" && ( +
+
+ POP 화면 ID + onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })} + placeholder="화면 ID (예: 4481)" + className="h-7 flex-1 text-[10px]" + /> +
+
+ 모달 제목 + onUpdate({ cardClickModalConfig: { ...modalConfig, modalTitle: e.target.value } })} + placeholder="비우면 '상세 작업' 표시" + className="h-7 flex-1 text-[10px]" + /> +
+
+ 조건 + +
+ {modalConfig.condition?.type === "timeline-status" && ( +
+ 상태 값 + onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, value: e.target.value } } })} + placeholder="예: in_progress" + className="h-7 flex-1 text-[10px]" + /> +
+ )} + {modalConfig.condition?.type === "column-value" && ( + <> +
+ 컬럼 + onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, column: e.target.value } } })} + placeholder="컬럼명" + className="h-7 flex-1 text-[10px]" + /> +
+
+ + onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, value: e.target.value } } })} + placeholder="값" + className="h-7 flex-1 text-[10px]" + /> +
+ + )} +
+ )} +
+ + {/* 필터 전 비표시 */} +
+ + onUpdate({ hideUntilFiltered: checked })} + /> +
+ {cfg.hideUntilFiltered && ( +

+ 연결된 컴포넌트에서 필터 값이 전달되기 전까지 데이터를 표시하지 않습니다. +

+ )} + + {/* 스크롤 방향 */} +
+ +
+ {(["vertical", "horizontal"] as const).map((dir) => ( + + ))} +
+
+ + {/* 오버플로우 */} +
+ +
+ {(["loadMore", "pagination"] as const).map((mode) => ( + + ))} +
+
+
+ + onUpdate({ overflow: { ...overflow, visibleCount: Number(e.target.value) || 6 } })} + className="mt-0.5 h-7 text-[10px]" + /> +
+ {overflow.mode === "loadMore" && ( +
+ + onUpdate({ overflow: { ...overflow, loadMoreCount: Number(e.target.value) || 6 } })} + className="mt-0.5 h-7 text-[10px]" + /> +
+ )} + {overflow.mode === "pagination" && ( +
+ + onUpdate({ overflow: { ...overflow, pageSize: Number(e.target.value) || 6 } })} + className="mt-0.5 h-7 text-[10px]" + /> +
+ )} +
+
+ + {/* 장바구니 */} +
+ + { + if (checked) { + onUpdate({ cartAction: { saveMode: "cart", label: "담기", cancelLabel: "취소" } }); + } else { + onUpdate({ cartAction: undefined }); + } + }} + /> +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Preview.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Preview.tsx new file mode 100644 index 00000000..8ebaf913 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Preview.tsx @@ -0,0 +1,104 @@ +"use client"; + +/** + * pop-card-list-v2 디자인 모드 미리보기 + * + * 디자이너 캔버스에서 표시되는 미리보기. + * CSS Grid 기반 셀 배치를 시각적으로 보여준다. + */ + +import React from "react"; +import { LayoutGrid, Package } from "lucide-react"; +import type { PopCardListV2Config } from "../types"; +import { CARD_SCROLL_DIRECTION_LABELS, CARD_SIZE_LABELS } from "../types"; + +interface PopCardListV2PreviewProps { + config?: PopCardListV2Config; +} + +export function PopCardListV2PreviewComponent({ config }: PopCardListV2PreviewProps) { + const scrollDirection = config?.scrollDirection || "vertical"; + const cardSize = config?.cardSize || "medium"; + const dataSource = config?.dataSource; + const cardGrid = config?.cardGrid; + const hasTable = !!dataSource?.tableName; + const cellCount = cardGrid?.cells?.length || 0; + + return ( +
+
+
+ + 카드 목록 V2 +
+
+ + {CARD_SCROLL_DIRECTION_LABELS[scrollDirection]} + + + {CARD_SIZE_LABELS[cardSize]} + +
+
+ + {!hasTable ? ( +
+
+ +

데이터 소스를 설정하세요

+
+
+ ) : ( + <> +
+ + {dataSource!.tableName} + + + ({cellCount}셀) + +
+ +
+ {[0, 1].map((cardIdx) => ( +
+ {cellCount === 0 ? ( +
+ 셀을 추가하세요 +
+ ) : ( +
w || "1fr").join(" ") + : `repeat(${cardGrid!.cols || 1}, 1fr)`, + gridTemplateRows: `repeat(${cardGrid!.rows || 1}, minmax(16px, auto))`, + gap: "2px", + }} + > + {cardGrid!.cells.map((cell) => ( +
+ + {cell.type} + {cell.columnName ? `: ${cell.columnName}` : ""} + +
+ ))} +
+ )} +
+ ))} +
+ + )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx new file mode 100644 index 00000000..f1863b13 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx @@ -0,0 +1,733 @@ +"use client"; + +/** + * pop-card-list-v2 셀 타입별 렌더러 + * + * 각 셀 타입은 독립 함수로 구현되어 CardV2Grid에서 type별 dispatch로 호출된다. + * 기존 pop-card-list의 카드 내부 렌더링과 pop-string-list의 CardModeView 패턴을 결합. + */ + +import React, { useMemo, useState } from "react"; +import { + ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star, + Loader2, CheckCircle2, CircleDot, Clock, + type LucideIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep, ActionButtonDef } from "../types"; +import { DEFAULT_CARD_IMAGE, VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC } from "../types"; +import type { ButtonVariant } from "../pop-button"; + +type RowData = Record; + +// ===== 공통 유틸 ===== + +const LUCIDE_ICON_MAP: Record = { + ShoppingCart, Package, Truck, Box, Archive, Heart, Star, +}; + +function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) { + if (!name) return ; + const IconComp = LUCIDE_ICON_MAP[name]; + if (!IconComp) return ; + return ; +} + +function formatValue(value: unknown): string { + if (value === null || value === undefined) return "-"; + if (typeof value === "number") return value.toLocaleString(); + if (typeof value === "boolean") return value ? "예" : "아니오"; + if (value instanceof Date) return value.toLocaleDateString(); + if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) { + const date = new Date(value); + if (!isNaN(date.getTime())) { + return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + } + } + return String(value); +} + +const FONT_SIZE_MAP = { xs: "10px", sm: "11px", md: "12px", lg: "14px" } as const; +const FONT_WEIGHT_MAP = { normal: 400, medium: 500, bold: 700 } as const; + +// ===== 셀 렌더러 Props ===== + +export interface CellRendererProps { + cell: CardCellDefinitionV2; + row: RowData; + inputValue?: number; + isCarted?: boolean; + isButtonLoading?: boolean; + onInputClick?: (e: React.MouseEvent) => void; + onCartAdd?: () => void; + onCartCancel?: () => void; + onButtonClick?: (cell: CardCellDefinitionV2, row: RowData) => void; + onActionButtonClick?: (taskPreset: string, row: RowData, buttonConfig?: Record) => void; + onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; + packageEntries?: PackageEntry[]; + inputUnit?: string; +} + +// ===== 메인 디스패치 ===== + +export function renderCellV2(props: CellRendererProps): React.ReactNode { + switch (props.cell.type) { + case "text": + return ; + case "field": + return ; + case "image": + return ; + case "badge": + return ; + case "button": + return ; + case "number-input": + return ; + case "cart-button": + return ; + case "package-summary": + return ; + case "status-badge": + return ; + case "timeline": + return ; + case "action-buttons": + return ; + case "footer-status": + return ; + default: + return 알 수 없는 셀 타입; + } +} + +// ===== 1. text ===== + +function TextCell({ cell, row }: CellRendererProps) { + const value = cell.columnName ? row[cell.columnName] : ""; + const fs = FONT_SIZE_MAP[cell.fontSize || "md"]; + const fw = FONT_WEIGHT_MAP[cell.fontWeight || "normal"]; + + return ( + + {formatValue(value)} + + ); +} + +// ===== 2. field (라벨+값) ===== + +function FieldCell({ cell, row, inputValue }: CellRendererProps) { + const valueType = cell.valueType || "column"; + const fs = FONT_SIZE_MAP[cell.fontSize || "md"]; + + const displayValue = useMemo(() => { + if (valueType !== "formula") { + const raw = cell.columnName ? row[cell.columnName] : undefined; + const formatted = formatValue(raw); + return cell.unit ? `${formatted} ${cell.unit}` : formatted; + } + + if (cell.formulaLeft && cell.formulaOperator) { + const rightVal = + (cell.formulaRightType || "input") === "input" + ? (inputValue ?? 0) + : Number(row[cell.formulaRight || ""] ?? 0); + const leftVal = Number(row[cell.formulaLeft] ?? 0); + + let result: number | null = null; + switch (cell.formulaOperator) { + case "+": result = leftVal + rightVal; break; + case "-": result = leftVal - rightVal; break; + case "*": result = leftVal * rightVal; break; + case "/": result = rightVal !== 0 ? leftVal / rightVal : null; break; + } + + if (result !== null && isFinite(result)) { + const formatted = (Math.round(result * 100) / 100).toLocaleString(); + return cell.unit ? `${formatted} ${cell.unit}` : formatted; + } + return "-"; + } + return "-"; + }, [valueType, cell, row, inputValue]); + + const isFormula = valueType === "formula"; + const isLabelLeft = cell.labelPosition === "left"; + + return ( +
+ {cell.label && ( + + {cell.label}{isLabelLeft ? ":" : ""} + + )} + + {displayValue} + +
+ ); +} + +// ===== 3. image ===== + +function ImageCell({ cell, row }: CellRendererProps) { + const value = cell.columnName ? row[cell.columnName] : ""; + const imageUrl = value ? String(value) : (cell.defaultImage || DEFAULT_CARD_IMAGE); + + return ( +
+ {cell.label { + const target = e.target as HTMLImageElement; + if (target.src !== DEFAULT_CARD_IMAGE) target.src = DEFAULT_CARD_IMAGE; + }} + /> +
+ ); +} + +// ===== 4. badge ===== + +function BadgeCell({ cell, row }: CellRendererProps) { + const value = cell.columnName ? row[cell.columnName] : ""; + return ( + + {formatValue(value)} + + ); +} + +// ===== 5. button ===== + +function ButtonCell({ cell, row, isButtonLoading, onButtonClick }: CellRendererProps) { + return ( + + ); +} + +// ===== 6. number-input ===== + +function NumberInputCell({ cell, row, inputValue, onInputClick }: CellRendererProps) { + const unit = cell.inputUnit || "EA"; + return ( + + ); +} + +// ===== 7. cart-button ===== + +function CartButtonCell({ cell, row, isCarted, onCartAdd, onCartCancel }: CellRendererProps) { + const iconSize = 18; + const label = cell.cartLabel || "담기"; + const cancelLabel = cell.cartCancelLabel || "취소"; + + if (isCarted) { + return ( + + ); + } + + return ( + + ); +} + +// ===== 8. package-summary ===== + +function PackageSummaryCell({ cell, packageEntries, inputUnit }: CellRendererProps) { + if (!packageEntries || packageEntries.length === 0) return null; + + return ( +
+ {packageEntries.map((entry, idx) => ( +
+
+ + 포장완료 + + + + {entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit} + +
+ + = {entry.totalQuantity.toLocaleString()}{inputUnit || "EA"} + +
+ ))} +
+ ); +} + +// ===== 9. status-badge ===== + +const STATUS_COLORS: Record = { + waiting: { bg: "#94a3b820", text: "#64748b" }, + accepted: { bg: "#3b82f620", text: "#2563eb" }, + in_progress: { bg: "#f59e0b20", text: "#d97706" }, + completed: { bg: "#10b98120", text: "#059669" }, +}; + +function StatusBadgeCell({ cell, row }: CellRendererProps) { + const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined; + const effectiveValue = hasSubStatus + ? row[VIRTUAL_SUB_STATUS] + : (cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : "")); + const strValue = String(effectiveValue || ""); + const mapped = cell.statusMap?.find((m) => m.value === strValue); + + if (mapped) { + return ( + + {mapped.label} + + ); + } + + const defaultColors = STATUS_COLORS[strValue]; + if (defaultColors) { + const labelMap: Record = { + waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료", + }; + return ( + + {labelMap[strValue] || strValue} + + ); + } + + return ( + + {formatValue(effectiveValue)} + + ); +} + +// ===== 10. timeline ===== + +type TimelineStyle = { chipBg: string; chipText: string; icon: React.ReactNode }; + +const TIMELINE_SEMANTIC_STYLES: Record = { + done: { chipBg: "#10b981", chipText: "#ffffff", icon: }, + active: { chipBg: "#3b82f6", chipText: "#ffffff", icon: }, + pending: { chipBg: "#e2e8f0", chipText: "#64748b", icon: }, +}; + +// 레거시 status 값 → semantic 매핑 (기존 데이터 호환) +const LEGACY_STATUS_TO_SEMANTIC: Record = { + completed: "done", in_progress: "active", accepted: "active", waiting: "pending", +}; + +function getTimelineStyle(step: TimelineProcessStep): TimelineStyle { + if (step.semantic) return TIMELINE_SEMANTIC_STYLES[step.semantic] || TIMELINE_SEMANTIC_STYLES.pending; + const fallback = LEGACY_STATUS_TO_SEMANTIC[step.status]; + return TIMELINE_SEMANTIC_STYLES[fallback || "pending"]; +} + +function TimelineCell({ cell, row }: CellRendererProps) { + const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; + + if (!processFlow || processFlow.length === 0) { + const fallback = cell.processColumn ? row[cell.processColumn] : ""; + return ( + + {formatValue(fallback)} + + ); + } + + const maxVisible = cell.visibleCount || 5; + const currentIdx = processFlow.findIndex((s) => s.isCurrent); + + type DisplayItem = + | { kind: "step"; step: TimelineProcessStep } + | { kind: "count"; count: number; side: "before" | "after" }; + + // 현재 항목 기준으로 앞뒤 배분하여 축약 + const displayItems = useMemo((): DisplayItem[] => { + if (processFlow.length <= maxVisible) { + return processFlow.map((s) => ({ kind: "step" as const, step: s })); + } + + const effectiveIdx = Math.max(0, currentIdx); + const priority = cell.timelinePriority || "before"; + // 숫자칩 2개를 제외한 나머지를 앞뒤로 배분 (priority에 따라 여분 슬롯 방향 결정) + const slotForSteps = maxVisible - 2; + const half = Math.floor(slotForSteps / 2); + const extra = slotForSteps - half - 1; + const beforeSlots = priority === "before" ? Math.max(half, extra) : Math.min(half, extra); + const afterSlots = slotForSteps - beforeSlots - 1; + + let startIdx = effectiveIdx - beforeSlots; + let endIdx = effectiveIdx + afterSlots; + + // 경계 보정 + if (startIdx < 0) { + endIdx = Math.min(processFlow.length - 1, endIdx + Math.abs(startIdx)); + startIdx = 0; + } + if (endIdx >= processFlow.length) { + startIdx = Math.max(0, startIdx - (endIdx - processFlow.length + 1)); + endIdx = processFlow.length - 1; + } + + const items: DisplayItem[] = []; + const beforeCount = startIdx; + const afterCount = processFlow.length - 1 - endIdx; + + if (beforeCount > 0) { + items.push({ kind: "count", count: beforeCount, side: "before" }); + } + for (let i = startIdx; i <= endIdx; i++) { + items.push({ kind: "step", step: processFlow[i] }); + } + if (afterCount > 0) { + items.push({ kind: "count", count: afterCount, side: "after" }); + } + + return items; + }, [processFlow, maxVisible, currentIdx]); + + const [modalOpen, setModalOpen] = useState(false); + + const completedCount = processFlow.filter((s) => (s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status]) === "done").length; + const totalCount = processFlow.length; + + return ( + <> +
{ e.stopPropagation(); setModalOpen(true); } : undefined} + title={cell.showDetailModal !== false ? "클릭하여 전체 현황 보기" : undefined} + > + {displayItems.map((item, idx) => { + const isLast = idx === displayItems.length - 1; + + if (item.kind === "count") { + return ( + +
+ {item.count} +
+ {!isLast &&
} + + ); + } + + const styles = getTimelineStyle(item.step); + + return ( + +
+ {styles.icon} + + {item.step.processName} + +
+ {!isLast &&
} + + ); + })} +
+ + + + + 전체 현황 + + 총 {totalCount}개 중 {completedCount}개 완료 + + + +
+ {processFlow.map((step, idx) => { + const styles = getTimelineStyle(step); + const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending"; + const statusLabel = sem === "done" ? "완료" : sem === "active" ? "진행" : "대기"; + + return ( +
+ {/* 세로 연결선 + 아이콘 */} +
+ {idx > 0 &&
} +
+ {styles.icon} +
+ {idx < processFlow.length - 1 &&
} +
+ + {/* 항목 정보 */} +
+
+ {step.seqNo} + + {step.processName} + + {step.isCurrent && ( + + )} +
+ + {statusLabel} + +
+
+ ); + })} +
+ + {/* 하단 진행률 바 */} +
+
+ 진행률 + {totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0}% +
+
+
0 ? (completedCount / totalCount) * 100 : 0}%` }} + /> +
+
+ +
+ + ); +} + +// ===== 11. action-buttons ===== + +function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" { + const cond = btn.showCondition; + if (!cond || cond.type === "always") return "visible"; + + let matched = false; + + if (cond.type === "timeline-status") { + const subStatus = row[VIRTUAL_SUB_STATUS]; + matched = subStatus !== undefined && String(subStatus) === cond.value; + } else if (cond.type === "column-value" && cond.column) { + matched = String(row[cond.column] ?? "") === (cond.value ?? ""); + } else { + return "visible"; + } + + if (matched) return "visible"; + return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden"; +} + +function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) { + const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; + const currentProcess = processFlow?.find((s) => s.isCurrent); + const currentProcessId = currentProcess?.processId; + + if (cell.actionButtons && cell.actionButtons.length > 0) { + const evaluated = cell.actionButtons.map((btn) => ({ + btn, + state: evaluateShowCondition(btn, row), + })); + + const activeBtn = evaluated.find((e) => e.state === "visible"); + const disabledBtn = activeBtn ? null : evaluated.find((e) => e.state === "disabled"); + const pick = activeBtn || disabledBtn; + if (!pick) return null; + + const { btn, state } = pick; + + return ( +
+ +
+ ); + } + + // 기존 구조 (actionRules) 폴백 + const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined; + const statusValue = hasSubStatus + ? String(row[VIRTUAL_SUB_STATUS] || "") + : (cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : "")); + const rules = cell.actionRules || []; + const matchedRule = rules.find((r) => r.whenStatus === statusValue); + if (!matchedRule) return null; + + return ( +
+ {matchedRule.buttons.map((btn, idx) => ( + + ))} +
+ ); +} + +// ===== 12. footer-status ===== + +function FooterStatusCell({ cell, row }: CellRendererProps) { + const value = cell.footerStatusColumn ? row[cell.footerStatusColumn] : ""; + const strValue = String(value || ""); + const mapped = cell.footerStatusMap?.find((m) => m.value === strValue); + + if (!strValue && !cell.footerLabel) return null; + + return ( +
+ {cell.footerLabel && ( + {cell.footerLabel} + )} + {mapped ? ( + + {mapped.label} + + ) : strValue ? ( + + {strValue} + + ) : null} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/index.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/index.tsx new file mode 100644 index 00000000..138ab941 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/index.tsx @@ -0,0 +1,61 @@ +"use client"; + +/** + * pop-card-list-v2 컴포넌트 레지스트리 등록 진입점 + * + * import 시 side-effect로 PopComponentRegistry에 자동 등록 + */ + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopCardListV2Component } from "./PopCardListV2Component"; +import { PopCardListV2ConfigPanel } from "./PopCardListV2Config"; +import { PopCardListV2PreviewComponent } from "./PopCardListV2Preview"; +import type { PopCardListV2Config } from "../types"; + +const defaultConfig: PopCardListV2Config = { + dataSource: { tableName: "" }, + cardGrid: { + rows: 1, + cols: 1, + colWidths: ["1fr"], + rowHeights: ["32px"], + gap: 4, + showCellBorder: true, + cells: [], + }, + gridColumns: 3, + cardGap: 8, + scrollDirection: "vertical", + overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 }, + cardClickAction: "none", +}; + +PopComponentRegistry.registerComponent({ + id: "pop-card-list-v2", + name: "카드 목록 V2", + description: "슬롯 기반 카드 레이아웃 (CSS Grid + 셀 타입별 렌더링)", + category: "display", + icon: "LayoutGrid", + component: PopCardListV2Component, + configPanel: PopCardListV2ConfigPanel, + preview: PopCardListV2PreviewComponent, + defaultProps: defaultConfig, + connectionMeta: { + sendable: [ + { key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" }, + { key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" }, + { key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" }, + { key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, + { key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" }, + { key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" }, + ], + receivable: [ + { key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" }, + { key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" }, + { key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" }, + { key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" }, + ], + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/migrate.ts b/frontend/lib/registry/pop-components/pop-card-list-v2/migrate.ts new file mode 100644 index 00000000..e4bfed8f --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/migrate.ts @@ -0,0 +1,163 @@ +/** + * pop-card-list v1 -> v2 마이그레이션 함수 + * + * 기존 PopCardListConfig의 고정 레이아웃(헤더/이미지/필드/입력/담기/포장)을 + * CardGridConfigV2 셀 배열로 변환하여 PopCardListV2Config를 생성한다. + */ + +import type { + PopCardListConfig, + PopCardListV2Config, + CardCellDefinitionV2, +} from "../types"; + +export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Config { + const cells: CardCellDefinitionV2[] = []; + let nextRow = 1; + + // 1. 헤더 행 (코드 + 제목) + if (old.cardTemplate?.header?.codeField || old.cardTemplate?.header?.titleField) { + if (old.cardTemplate.header.codeField) { + cells.push({ + id: "h-code", + row: nextRow, + col: 1, + rowSpan: 1, + colSpan: 1, + type: "text", + columnName: old.cardTemplate.header.codeField, + fontSize: "sm", + textColor: "hsl(var(--muted-foreground))", + }); + } + if (old.cardTemplate.header.titleField) { + cells.push({ + id: "h-title", + row: nextRow, + col: 2, + rowSpan: 1, + colSpan: old.cardTemplate.header.codeField ? 2 : 3, + type: "text", + columnName: old.cardTemplate.header.titleField, + fontSize: "md", + fontWeight: "bold", + }); + } + nextRow++; + } + + // 2. 이미지 (왼쪽, 본문 높이만큼 rowSpan) + const bodyFieldCount = old.cardTemplate?.body?.fields?.length || 0; + const bodyRowSpan = Math.max(1, bodyFieldCount); + + if (old.cardTemplate?.image?.enabled) { + cells.push({ + id: "img", + row: nextRow, + col: 1, + rowSpan: bodyRowSpan, + colSpan: 1, + type: "image", + columnName: old.cardTemplate.image.imageColumn || "", + defaultImage: old.cardTemplate.image.defaultImage, + }); + } + + // 3. 본문 필드들 (이미지 오른쪽) + const fieldStartCol = old.cardTemplate?.image?.enabled ? 2 : 1; + const fieldColSpan = old.cardTemplate?.image?.enabled ? 2 : 3; + const hasRightActions = !!(old.inputField?.enabled || old.cartAction); + + (old.cardTemplate?.body?.fields || []).forEach((field, i) => { + cells.push({ + id: `f-${i}`, + row: nextRow + i, + col: fieldStartCol, + rowSpan: 1, + colSpan: hasRightActions ? fieldColSpan - 1 : fieldColSpan, + type: "field", + columnName: field.columnName, + label: field.label, + valueType: field.valueType, + formulaLeft: field.formulaLeft, + formulaOperator: field.formulaOperator as CardCellDefinitionV2["formulaOperator"], + formulaRight: field.formulaRight, + formulaRightType: field.formulaRightType as CardCellDefinitionV2["formulaRightType"], + unit: field.unit, + textColor: field.textColor, + }); + }); + + // 4. 수량 입력 + 담기 버튼 (오른쪽 열) + const rightCol = 3; + if (old.inputField?.enabled) { + cells.push({ + id: "input", + row: nextRow, + col: rightCol, + rowSpan: Math.ceil(bodyRowSpan / 2), + colSpan: 1, + type: "number-input", + inputUnit: old.inputField.unit, + limitColumn: old.inputField.limitColumn || old.inputField.maxColumn, + }); + } + if (old.cartAction) { + cells.push({ + id: "cart", + row: nextRow + Math.ceil(bodyRowSpan / 2), + col: rightCol, + rowSpan: Math.floor(bodyRowSpan / 2) || 1, + colSpan: 1, + type: "cart-button", + cartLabel: old.cartAction.label, + cartCancelLabel: old.cartAction.cancelLabel, + cartIconType: old.cartAction.iconType, + cartIconValue: old.cartAction.iconValue, + }); + } + + // 5. 포장 요약 (마지막 행, full-width) + if (old.packageConfig?.enabled) { + const summaryRow = nextRow + bodyRowSpan; + cells.push({ + id: "pkg", + row: summaryRow, + col: 1, + rowSpan: 1, + colSpan: 3, + type: "package-summary", + }); + } + + // 그리드 크기 계산 + const maxRow = cells.length > 0 ? Math.max(...cells.map((c) => c.row + c.rowSpan - 1)) : 1; + const maxCol = 3; + + return { + dataSource: old.dataSource, + cardGrid: { + rows: maxRow, + cols: maxCol, + colWidths: old.cardTemplate?.image?.enabled + ? ["1fr", "2fr", "1fr"] + : ["1fr", "2fr", "1fr"], + gap: 2, + showCellBorder: false, + cells, + }, + scrollDirection: old.scrollDirection, + cardSize: old.cardSize, + gridColumns: old.gridColumns, + gridRows: old.gridRows, + cardGap: 8, + overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 }, + cardClickAction: "none", + responsiveDisplay: old.responsiveDisplay, + inputField: old.inputField, + packageConfig: old.packageConfig, + cartAction: old.cartAction, + cartListMode: old.cartListMode, + saveMapping: old.saveMapping, + }; +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx index 4b7c182c..60260693 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -256,6 +256,12 @@ export function PopCardListComponent({ return unsub; }, [componentId, subscribe]); + // 전체 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용) + useEffect(() => { + if (!componentId || loading) return; + publish(`__comp_output__${componentId}__all_rows`, rows); + }, [componentId, rows, loading, publish]); + // cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용 const cartRef = useRef(cart); cartRef.current = cart; diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx index 6383974b..1c351cf2 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -2039,16 +2039,29 @@ function FilterSettingsSection({ {filters.map((filter, index) => (
+
+ + 조건 {index + 1} + + +
- - - - - updateFilter(index, { ...filter, value: e.target.value }) - } - placeholder="값" - className="h-7 flex-1 text-xs" - /> - - +
+ + + updateFilter(index, { ...filter, value: e.target.value }) + } + placeholder="값 입력" + className="h-8 flex-1 text-xs" + /> +
))}
@@ -2663,46 +2667,51 @@ function FilterCriteriaSection({ ) : (
{filters.map((filter, index) => ( -
-
- updateFilter(index, { ...filter, column: val || "" })} - placeholder="컬럼 선택" +
+
+ + 조건 {index + 1} + + +
+ updateFilter(index, { ...filter, column: val || "" })} + placeholder="컬럼 선택" + /> +
+ + updateFilter(index, { ...filter, value: e.target.value })} + placeholder="값 입력" + className="h-8 flex-1 text-xs" />
- - updateFilter(index, { ...filter, value: e.target.value })} - placeholder="값" - className="h-7 flex-1 text-xs" - /> -
))}
diff --git a/frontend/lib/registry/pop-components/pop-card-list/index.tsx b/frontend/lib/registry/pop-components/pop-card-list/index.tsx index b9b769af..fe6a43df 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -61,6 +61,7 @@ PopComponentRegistry.registerComponent({ connectionMeta: { sendable: [ { key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" }, + { key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" }, { key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" }, { key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, { key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" }, diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts index 0f6adda6..b05846ef 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -34,6 +34,7 @@ export interface ColumnInfo { type: string; udtName: string; isPrimaryKey?: boolean; + comment?: string; } // ===== SQL 값 이스케이프 ===== @@ -330,6 +331,7 @@ export async function fetchTableColumns( type: col.dataType || col.data_type || col.type || "unknown", udtName: col.dbType || col.udt_name || col.udtName || "unknown", isPrimaryKey: col.isPrimaryKey === true || col.isPrimaryKey === "true" || col.is_primary_key === true || col.is_primary_key === "true", + comment: col.columnComment || col.description || "", })); } } diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx index dace22f6..0438df90 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx @@ -203,6 +203,32 @@ export function PopFieldComponent({ return unsub; }, [componentId, subscribe, cfg.readSource, fetchReadSourceData]); + useEffect(() => { + const unsub = subscribe("scan_auto_fill", (payload: unknown) => { + const data = payload as Record | null; + if (!data || typeof data !== "object") return; + + const fieldNames = new Set(); + for (const section of cfg.sections) { + for (const f of section.fields ?? []) { + if (f.fieldName) fieldNames.add(f.fieldName); + } + } + + const matched: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (fieldNames.has(key)) { + matched[key] = value; + } + } + + if (Object.keys(matched).length > 0) { + setAllValues((prev) => ({ ...prev, ...matched })); + } + }); + return unsub; + }, [subscribe, cfg.sections]); + // 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → allValues + saveConfig 응답 useEffect(() => { if (!componentId) return; @@ -220,7 +246,7 @@ export function PopFieldComponent({ ? { targetTable: cfg.saveConfig.tableName, columnMapping: Object.fromEntries( - (cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn]) + (cfg.saveConfig.fieldMappings || []).map((m) => [fieldIdToName[m.fieldId] || m.fieldId, m.targetColumn]) ), autoGenMappings: (cfg.saveConfig.autoGenMappings || []) .filter((m) => m.numberingRuleId) @@ -228,6 +254,7 @@ export function PopFieldComponent({ numberingRuleId: m.numberingRuleId!, targetColumn: m.targetColumn, showResultModal: m.showResultModal, + shareAcrossItems: m.shareAcrossItems ?? false, })), hiddenMappings: (cfg.saveConfig.hiddenMappings || []) .filter((m) => m.targetColumn) @@ -247,7 +274,7 @@ export function PopFieldComponent({ } ); return unsub; - }, [componentId, subscribe, publish, allValues, cfg.saveConfig]); + }, [componentId, subscribe, publish, allValues, cfg.saveConfig, fieldIdToName]); // 필드 값 변경 핸들러 const handleFieldChange = useCallback( diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx index 3b51667f..8e0f8ad1 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx @@ -398,8 +398,19 @@ function SaveTabContent({ syncAndUpdateSaveMappings((prev) => prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m)) ); + + if (partial.targetColumn !== undefined) { + const newFieldName = partial.targetColumn || ""; + const sections = cfg.sections.map((s) => ({ + ...s, + fields: (s.fields ?? []).map((f) => + f.id === fieldId ? { ...f, fieldName: newFieldName } : f + ), + })); + onUpdateConfig({ sections }); + } }, - [syncAndUpdateSaveMappings] + [syncAndUpdateSaveMappings, cfg, onUpdateConfig] ); // --- 숨은 필드 매핑 로직 --- @@ -1337,7 +1348,19 @@ function SaveTabContent({ />
+
+ updateAutoGenMapping(m.id, { shareAcrossItems: v })} + /> + +
+ {m.shareAcrossItems && ( +

+ 저장되는 모든 행에 동일한 번호를 부여합니다 +

+ )}
); })} @@ -1414,7 +1437,7 @@ function SectionEditor({ const newField: PopFieldItem = { id: fieldId, inputType: "text", - fieldName: fieldId, + fieldName: "", labelText: "", readOnly: false, }; diff --git a/frontend/lib/registry/pop-components/pop-field/types.ts b/frontend/lib/registry/pop-components/pop-field/types.ts index 7118d0a6..f0813e6c 100644 --- a/frontend/lib/registry/pop-components/pop-field/types.ts +++ b/frontend/lib/registry/pop-components/pop-field/types.ts @@ -153,6 +153,7 @@ export interface PopFieldAutoGenMapping { numberingRuleId?: string; showInForm: boolean; showResultModal: boolean; + shareAcrossItems?: boolean; } export interface PopFieldSaveConfig { diff --git a/frontend/lib/registry/pop-components/pop-profile.tsx b/frontend/lib/registry/pop-components/pop-profile.tsx new file mode 100644 index 00000000..49aaa10c --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-profile.tsx @@ -0,0 +1,336 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Monitor, LayoutGrid, LogOut, UserCircle } from "lucide-react"; +import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; +import { useAuth } from "@/hooks/useAuth"; + +// ======================================== +// 타입 정의 +// ======================================== + +type AvatarSize = "sm" | "md" | "lg"; + +export interface PopProfileConfig { + avatarSize?: AvatarSize; + showDashboardLink?: boolean; + showPcMode?: boolean; + showLogout?: boolean; +} + +const DEFAULT_CONFIG: PopProfileConfig = { + avatarSize: "md", + showDashboardLink: true, + showPcMode: true, + showLogout: true, +}; + +const AVATAR_SIZE_MAP: Record = { + sm: { container: "h-8 w-8", text: "text-sm", px: 32 }, + md: { container: "h-10 w-10", text: "text-base", px: 40 }, + lg: { container: "h-12 w-12", text: "text-lg", px: 48 }, +}; + +const AVATAR_SIZE_LABELS: Record = { + sm: "작은 (32px)", + md: "보통 (40px)", + lg: "큰 (48px)", +}; + +// ======================================== +// 뷰어 컴포넌트 +// ======================================== + +interface PopProfileComponentProps { + config?: PopProfileConfig; + componentId?: string; + screenId?: string; +} + +function PopProfileComponent({ config: rawConfig }: PopProfileComponentProps) { + const router = useRouter(); + const { user, isLoggedIn, logout } = useAuth(); + const [open, setOpen] = useState(false); + + const config = useMemo(() => ({ + ...DEFAULT_CONFIG, + ...rawConfig, + }), [rawConfig]); + + const sizeInfo = AVATAR_SIZE_MAP[config.avatarSize || "md"]; + const initial = user?.userName?.substring(0, 1)?.toUpperCase() || "?"; + + const handlePcMode = () => { + setOpen(false); + router.push("/"); + }; + + const handleDashboard = () => { + setOpen(false); + router.push("/pop"); + }; + + const handleLogout = async () => { + setOpen(false); + await logout(); + }; + + const handleLogin = () => { + setOpen(false); + router.push("/login"); + }; + + return ( +
+ + + + + + {isLoggedIn && user ? ( + <> + {/* 사용자 정보 */} +
+
+ {user.photo && user.photo.trim() !== "" && user.photo !== "null" ? ( + {user.userName + ) : ( + initial + )} +
+
+ + {user.userName || "사용자"} ({user.userId || ""}) + + + {user.deptName || "부서 정보 없음"} + +
+
+ + {/* 메뉴 항목 */} +
+ {config.showDashboardLink && ( + + )} + {config.showPcMode && ( + + )} + {config.showLogout && ( + <> +
+ + + )} +
+ + ) : ( +
+

+ 로그인이 필요합니다 +

+ +
+ )} + + +
+ ); +} + +// ======================================== +// 설정 패널 +// ======================================== + +interface PopProfileConfigPanelProps { + config: PopProfileConfig; + onUpdate: (config: PopProfileConfig) => void; +} + +function PopProfileConfigPanel({ config: rawConfig, onUpdate }: PopProfileConfigPanelProps) { + const config = useMemo(() => ({ + ...DEFAULT_CONFIG, + ...rawConfig, + }), [rawConfig]); + + const updateConfig = (partial: Partial) => { + onUpdate({ ...config, ...partial }); + }; + + return ( +
+ {/* 아바타 크기 */} +
+ + +
+ + {/* 메뉴 항목 토글 */} +
+ + +
+ + updateConfig({ showDashboardLink: v })} + /> +
+ +
+ + updateConfig({ showPcMode: v })} + /> +
+ +
+ + updateConfig({ showLogout: v })} + /> +
+
+
+ ); +} + +// ======================================== +// 디자이너 미리보기 +// ======================================== + +function PopProfilePreview({ config }: { config?: PopProfileConfig }) { + const size = AVATAR_SIZE_MAP[config?.avatarSize || "md"]; + return ( +
+
+ +
+ 프로필 +
+ ); +} + +// ======================================== +// 레지스트리 등록 +// ======================================== + +PopComponentRegistry.registerComponent({ + id: "pop-profile", + name: "프로필", + description: "사용자 프로필 / PC 전환 / 로그아웃", + category: "action", + icon: "UserCircle", + component: PopProfileComponent, + configPanel: PopProfileConfigPanel, + preview: PopProfilePreview, + defaultProps: { + avatarSize: "md", + showDashboardLink: true, + showPcMode: true, + showLogout: true, + }, + connectionMeta: { + sendable: [], + receivable: [], + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-scanner.tsx b/frontend/lib/registry/pop-components/pop-scanner.tsx new file mode 100644 index 00000000..e2230170 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-scanner.tsx @@ -0,0 +1,694 @@ +"use client"; + +import React, { useState, useCallback, useMemo, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScanLine } from "lucide-react"; +import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; +import { usePopEvent } from "@/hooks/pop/usePopEvent"; +import { BarcodeScanModal } from "@/components/common/BarcodeScanModal"; +import type { + PopDataConnection, + PopComponentDefinitionV5, +} from "@/components/pop/designer/types/pop-layout"; + +// ======================================== +// 타입 정의 +// ======================================== + +export interface ScanFieldMapping { + sourceKey: string; + outputIndex: number; + label: string; + targetComponentId: string; + targetFieldName: string; + enabled: boolean; +} + +export interface PopScannerConfig { + barcodeFormat: "all" | "1d" | "2d"; + autoSubmit: boolean; + showLastScan: boolean; + buttonLabel: string; + buttonVariant: "default" | "outline" | "secondary"; + parseMode: "none" | "auto" | "json"; + fieldMappings: ScanFieldMapping[]; +} + +// 연결된 컴포넌트의 필드 정보 +interface ConnectedFieldInfo { + componentId: string; + componentName: string; + componentType: string; + fieldName: string; + fieldLabel: string; +} + +const DEFAULT_SCANNER_CONFIG: PopScannerConfig = { + barcodeFormat: "all", + autoSubmit: true, + showLastScan: false, + buttonLabel: "스캔", + buttonVariant: "default", + parseMode: "none", + fieldMappings: [], +}; + +// ======================================== +// 파싱 유틸리티 +// ======================================== + +function tryParseJson(raw: string): Record | null { + try { + const parsed = JSON.parse(raw); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + const result: Record = {}; + for (const [k, v] of Object.entries(parsed)) { + result[k] = String(v); + } + return result; + } + } catch { + // JSON이 아닌 경우 + } + return null; +} + +function parseScanResult( + raw: string, + mode: PopScannerConfig["parseMode"] +): Record | null { + if (mode === "none") return null; + return tryParseJson(raw); +} + +// ======================================== +// 연결된 컴포넌트 필드 추출 +// ======================================== + +function getConnectedFields( + componentId?: string, + connections?: PopDataConnection[], + allComponents?: PopComponentDefinitionV5[], +): ConnectedFieldInfo[] { + if (!componentId || !connections || !allComponents) return []; + + const targetIds = connections + .filter((c) => c.sourceComponent === componentId) + .map((c) => c.targetComponent); + + const uniqueTargetIds = [...new Set(targetIds)]; + const fields: ConnectedFieldInfo[] = []; + + for (const tid of uniqueTargetIds) { + const comp = allComponents.find((c) => c.id === tid); + if (!comp?.config) continue; + const compCfg = comp.config as Record; + const compType = comp.type || ""; + const compName = (comp as Record).label as string || comp.type || tid; + + // pop-search: filterColumns (복수) 또는 modalConfig.valueField 또는 fieldName (단일) + const filterCols = compCfg.filterColumns as string[] | undefined; + const modalCfg = compCfg.modalConfig as { valueField?: string; displayField?: string } | undefined; + + if (Array.isArray(filterCols) && filterCols.length > 0) { + for (const col of filterCols) { + fields.push({ + componentId: tid, + componentName: compName, + componentType: compType, + fieldName: col, + fieldLabel: col, + }); + } + } else if (modalCfg?.valueField) { + fields.push({ + componentId: tid, + componentName: compName, + componentType: compType, + fieldName: modalCfg.valueField, + fieldLabel: (compCfg.placeholder as string) || modalCfg.valueField, + }); + } else if (compCfg.fieldName && typeof compCfg.fieldName === "string") { + fields.push({ + componentId: tid, + componentName: compName, + componentType: compType, + fieldName: compCfg.fieldName, + fieldLabel: (compCfg.placeholder as string) || compCfg.fieldName as string, + }); + } + + // pop-field: sections 내 fields + const sections = compCfg.sections as Array<{ + fields?: Array<{ id: string; fieldName?: string; labelText?: string }>; + }> | undefined; + if (Array.isArray(sections)) { + for (const section of sections) { + for (const f of section.fields ?? []) { + if (f.fieldName) { + fields.push({ + componentId: tid, + componentName: compName, + componentType: compType, + fieldName: f.fieldName, + fieldLabel: f.labelText || f.fieldName, + }); + } + } + } + } + } + + return fields; +} + +// ======================================== +// 메인 컴포넌트 +// ======================================== + +interface PopScannerComponentProps { + config?: PopScannerConfig; + label?: string; + isDesignMode?: boolean; + screenId?: string; + componentId?: string; +} + +function PopScannerComponent({ + config, + isDesignMode, + screenId, + componentId, +}: PopScannerComponentProps) { + const cfg = { ...DEFAULT_SCANNER_CONFIG, ...(config || {}) }; + const { publish } = usePopEvent(screenId || ""); + const [lastScan, setLastScan] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + + const handleScanSuccess = useCallback( + (barcode: string) => { + setLastScan(barcode); + setModalOpen(false); + + if (!componentId) return; + + if (cfg.parseMode === "none") { + publish(`__comp_output__${componentId}__scan_value`, barcode); + return; + } + + const parsed = parseScanResult(barcode, cfg.parseMode); + + if (!parsed) { + publish(`__comp_output__${componentId}__scan_value`, barcode); + return; + } + + if (cfg.parseMode === "auto") { + publish("scan_auto_fill", parsed); + publish(`__comp_output__${componentId}__scan_value`, barcode); + return; + } + + if (cfg.fieldMappings.length === 0) { + publish(`__comp_output__${componentId}__scan_value`, barcode); + return; + } + + for (const mapping of cfg.fieldMappings) { + if (!mapping.enabled) continue; + const value = parsed[mapping.sourceKey]; + if (value === undefined) continue; + + publish( + `__comp_output__${componentId}__scan_field_${mapping.outputIndex}`, + value + ); + + if (mapping.targetComponentId && mapping.targetFieldName) { + publish( + `__comp_input__${mapping.targetComponentId}__set_value`, + { fieldName: mapping.targetFieldName, value } + ); + } + } + }, + [componentId, publish, cfg.parseMode, cfg.fieldMappings], + ); + + const handleClick = useCallback(() => { + if (isDesignMode) return; + setModalOpen(true); + }, [isDesignMode]); + + return ( +
+ + + {cfg.showLastScan && lastScan && ( +
+ {lastScan} +
+ )} + + {!isDesignMode && ( + + )} +
+ ); +} + +// ======================================== +// 설정 패널 +// ======================================== + +const FORMAT_LABELS: Record = { + all: "모든 형식", + "1d": "1D 바코드", + "2d": "2D 바코드 (QR)", +}; + +const VARIANT_LABELS: Record = { + default: "기본 (Primary)", + outline: "외곽선 (Outline)", + secondary: "보조 (Secondary)", +}; + +const PARSE_MODE_LABELS: Record = { + none: "없음 (단일 값)", + auto: "자동 (검색 필드명과 매칭)", + json: "JSON (수동 매핑)", +}; + +interface PopScannerConfigPanelProps { + config: PopScannerConfig; + onUpdate: (config: PopScannerConfig) => void; + allComponents?: PopComponentDefinitionV5[]; + connections?: PopDataConnection[]; + componentId?: string; +} + +function PopScannerConfigPanel({ + config, + onUpdate, + allComponents, + connections, + componentId, +}: PopScannerConfigPanelProps) { + const cfg = { ...DEFAULT_SCANNER_CONFIG, ...config }; + + const update = (partial: Partial) => { + onUpdate({ ...cfg, ...partial }); + }; + + const connectedFields = useMemo( + () => getConnectedFields(componentId, connections, allComponents), + [componentId, connections, allComponents], + ); + + const buildMappingsFromFields = useCallback( + (fields: ConnectedFieldInfo[], existing: ScanFieldMapping[]): ScanFieldMapping[] => { + return fields.map((f, i) => { + const prev = existing.find( + (m) => m.targetComponentId === f.componentId && m.targetFieldName === f.fieldName + ); + return { + sourceKey: prev?.sourceKey ?? f.fieldName, + outputIndex: i, + label: f.fieldLabel, + targetComponentId: f.componentId, + targetFieldName: f.fieldName, + enabled: prev?.enabled ?? true, + }; + }); + }, + [], + ); + + const toggleMapping = (fieldName: string, componentId: string) => { + const updated = cfg.fieldMappings.map((m) => + m.targetFieldName === fieldName && m.targetComponentId === componentId + ? { ...m, enabled: !m.enabled } + : m + ); + update({ fieldMappings: updated }); + }; + + const updateMappingSourceKey = (fieldName: string, componentId: string, sourceKey: string) => { + const updated = cfg.fieldMappings.map((m) => + m.targetFieldName === fieldName && m.targetComponentId === componentId + ? { ...m, sourceKey } + : m + ); + update({ fieldMappings: updated }); + }; + + useEffect(() => { + if (cfg.parseMode !== "json" || connectedFields.length === 0) return; + const synced = buildMappingsFromFields(connectedFields, cfg.fieldMappings); + const isSame = + synced.length === cfg.fieldMappings.length && + synced.every( + (s, i) => + s.targetComponentId === cfg.fieldMappings[i]?.targetComponentId && + s.targetFieldName === cfg.fieldMappings[i]?.targetFieldName, + ); + if (!isSame) { + onUpdate({ ...cfg, fieldMappings: synced }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connectedFields, cfg.parseMode]); + + return ( +
+
+ + +

인식할 바코드 종류를 선택합니다

+
+ +
+ + update({ buttonLabel: e.target.value })} + placeholder="스캔" + className="h-8 text-xs" + /> +
+ +
+ + +
+ +
+
+ +

+ {cfg.autoSubmit + ? "바코드 인식 즉시 값 전달 (확인 버튼 생략)" + : "인식 후 확인 버튼을 눌러야 값 전달"} +

+
+ update({ autoSubmit: v })} + /> +
+ +
+
+ +

버튼 아래에 마지막 스캔값 표시

+
+ update({ showLastScan: v })} + /> +
+ + {/* 파싱 설정 섹션 */} +
+ +

+ 바코드/QR에 여러 정보가 담긴 경우, 파싱하여 각각 다른 컴포넌트에 전달 +

+ +
+ + +
+ + {cfg.parseMode === "auto" && ( +
+

자동 매칭 방식

+

+ QR/바코드의 JSON 키가 연결된 컴포넌트의 필드명과 같으면 자동 입력됩니다. +

+ {connectedFields.length > 0 && ( +
+

연결된 필드 목록:

+ {connectedFields.map((f, i) => ( +
+ {f.fieldName} + - {f.fieldLabel} + ({f.componentName}) +
+ ))} +

+ QR에 위 필드명이 JSON 키로 포함되면 자동 매칭됩니다. +

+
+ )} + {connectedFields.length === 0 && ( +

+ 연결 탭에서 스캐너와 다른 컴포넌트를 먼저 연결하세요. + 연결 없이도 같은 화면의 모든 컴포넌트에 전역으로 전달됩니다. +

+ )} +
+ )} + + {cfg.parseMode === "json" && ( +
+

+ 연결된 컴포넌트의 필드를 선택하고, 매핑할 JSON 키를 지정합니다. + 필드명과 같은 JSON 키가 있으면 자동 매칭됩니다. +

+ + {connectedFields.length === 0 ? ( +
+

+ 연결 탭에서 스캐너와 다른 컴포넌트를 먼저 연결해주세요. + 연결된 컴포넌트의 필드 목록이 여기에 표시됩니다. +

+
+ ) : ( +
+ +
+ {cfg.fieldMappings.map((mapping) => ( +
+ + toggleMapping(mapping.targetFieldName, mapping.targetComponentId) + } + className="mt-0.5" + /> +
+ + {mapping.enabled && ( +
+ + JSON 키: + + + updateMappingSourceKey( + mapping.targetFieldName, + mapping.targetComponentId, + e.target.value, + ) + } + placeholder={mapping.targetFieldName} + className="h-6 text-[10px]" + /> +
+ )} +
+
+ ))} +
+ + {cfg.fieldMappings.some((m) => m.enabled) && ( +
+

활성 매핑:

+
    + {cfg.fieldMappings + .filter((m) => m.enabled) + .map((m, i) => ( +
  • + {m.sourceKey || "?"} + {" -> "} + {m.targetFieldName} + {m.label && ({m.label})} +
  • + ))} +
+
+ )} +
+ )} +
+ )} +
+
+ ); +} + +// ======================================== +// 미리보기 +// ======================================== + +function PopScannerPreview({ config }: { config?: PopScannerConfig }) { + const cfg = config || DEFAULT_SCANNER_CONFIG; + + return ( +
+ +
+ ); +} + +// ======================================== +// 동적 sendable 생성 +// ======================================== + +function buildSendableMeta(config?: PopScannerConfig) { + const base = [ + { + key: "scan_value", + label: "스캔 값 (원본)", + type: "filter_value" as const, + category: "filter" as const, + description: "파싱 전 원본 스캔 결과 (단일 값 모드이거나 파싱 실패 시)", + }, + ]; + + if (config?.fieldMappings && config.fieldMappings.length > 0) { + for (const mapping of config.fieldMappings) { + base.push({ + key: `scan_field_${mapping.outputIndex}`, + label: mapping.label || `스캔 필드 ${mapping.outputIndex}`, + type: "filter_value" as const, + category: "filter" as const, + description: `파싱된 필드: JSON 키 "${mapping.sourceKey}"`, + }); + } + } + + return base; +} + +// ======================================== +// 레지스트리 등록 +// ======================================== + +PopComponentRegistry.registerComponent({ + id: "pop-scanner", + name: "스캐너", + description: "바코드/QR 카메라 스캔", + category: "input", + icon: "ScanLine", + component: PopScannerComponent, + configPanel: PopScannerConfigPanel, + preview: PopScannerPreview, + defaultProps: DEFAULT_SCANNER_CONFIG, + connectionMeta: { + sendable: buildSendableMeta(), + receivable: [], + }, + getDynamicConnectionMeta: (config: Record) => ({ + sendable: buildSendableMeta(config as unknown as PopScannerConfig), + receivable: [], + }), + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx index 6813e373..01ecc173 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx @@ -18,12 +18,21 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; -import { Search, ChevronRight, Loader2, X } from "lucide-react"; +import { Search, ChevronRight, Loader2, X, CalendarDays } from "lucide-react"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { format, startOfWeek, endOfWeek, startOfMonth, endOfMonth } from "date-fns"; +import { ko } from "date-fns/locale"; import { usePopEvent } from "@/hooks/pop"; import { dataApi } from "@/lib/api/data"; import type { PopSearchConfig, DatePresetOption, + DateSelectionMode, ModalSelectConfig, ModalSearchMode, ModalFilterTab, @@ -62,9 +71,21 @@ export function PopSearchComponent({ const [modalDisplayText, setModalDisplayText] = useState(""); const [simpleModalOpen, setSimpleModalOpen] = useState(false); - const fieldKey = config.fieldName || componentId || "search"; const normalizedType = normalizeInputType(config.inputType as string); const isModalType = normalizedType === "modal"; + const fieldKey = isModalType + ? (config.modalConfig?.valueField || config.fieldName || componentId || "search") + : (config.fieldName || componentId || "search"); + + const resolveFilterMode = useCallback(() => { + if (config.filterMode) return config.filterMode; + if (normalizedType === "date") { + const mode: DateSelectionMode = config.dateSelectionMode || "single"; + return mode === "range" ? "range" : "equals"; + } + return "contains"; + }, [config.filterMode, config.dateSelectionMode, normalizedType]); + const emitFilterChanged = useCallback( (newValue: unknown) => { @@ -72,15 +93,18 @@ export function PopSearchComponent({ setSharedData(`search_${fieldKey}`, newValue); if (componentId) { + const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey]; publish(`__comp_output__${componentId}__filter_value`, { fieldName: fieldKey, + filterColumns, value: newValue, + filterMode: resolveFilterMode(), }); } publish("filter_changed", { [fieldKey]: newValue }); }, - [fieldKey, publish, setSharedData, componentId] + [fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns] ); useEffect(() => { @@ -88,15 +112,40 @@ export function PopSearchComponent({ const unsub = subscribe( `__comp_input__${componentId}__set_value`, (payload: unknown) => { - const data = payload as { value?: unknown } | unknown; + const data = payload as { value?: unknown; displayText?: string } | unknown; const incoming = typeof data === "object" && data && "value" in data ? (data as { value: unknown }).value : data; + if (isModalType && incoming != null) { + setModalDisplayText(String(incoming)); + } emitFilterChanged(incoming); } ); return unsub; - }, [componentId, subscribe, emitFilterChanged]); + }, [componentId, subscribe, emitFilterChanged, isModalType]); + + useEffect(() => { + const unsub = subscribe("scan_auto_fill", (payload: unknown) => { + const data = payload as Record | null; + if (!data || typeof data !== "object") return; + const myKey = config.fieldName; + if (!myKey) return; + const targetKeys = config.filterColumns?.length ? config.filterColumns : [myKey]; + for (const key of targetKeys) { + if (key in data) { + if (isModalType) setModalDisplayText(String(data[key])); + emitFilterChanged(data[key]); + return; + } + } + if (myKey in data) { + if (isModalType) setModalDisplayText(String(data[myKey])); + emitFilterChanged(data[myKey]); + } + }); + return unsub; + }, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]); const handleModalOpen = useCallback(() => { if (!config.modalConfig) return; @@ -116,29 +165,30 @@ export function PopSearchComponent({ [config.modalConfig, emitFilterChanged] ); + const handleModalClear = useCallback(() => { + setModalDisplayText(""); + emitFilterChanged(""); + }, [emitFilterChanged]); + const showLabel = config.labelVisible !== false && !!config.labelText; return (
{showLabel && ( - + {config.labelText} )} -
+
@@ -165,9 +215,10 @@ interface InputRendererProps { onChange: (v: unknown) => void; modalDisplayText?: string; onModalOpen?: () => void; + onModalClear?: () => void; } -function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) { +function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear }: InputRendererProps) { const normalized = normalizeInputType(config.inputType as string); switch (normalized) { case "text": @@ -175,12 +226,24 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa return ; case "select": return ; + case "date": { + const dateMode: DateSelectionMode = config.dateSelectionMode || "single"; + return dateMode === "range" + ? + : ; + } case "date-preset": return ; case "toggle": return ; case "modal": - return ; + return ; + case "status-chip": + return ( +
+ pop-status-bar 컴포넌트를 사용하세요 +
+ ); default: return ; } @@ -215,7 +278,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig; const isNumber = config.inputType === "number"; return ( -
+
); } +// ======================================== +// date 서브타입 - 단일 날짜 +// ======================================== + +function DateSingleInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) { + const [open, setOpen] = useState(false); + const useModal = config.calendarDisplay === "modal"; + const selected = value ? new Date(value + "T00:00:00") : undefined; + + const handleSelect = useCallback( + (day: Date | undefined) => { + if (!day) return; + onChange(format(day, "yyyy-MM-dd")); + setOpen(false); + }, + [onChange] + ); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onChange(""); + }, + [onChange] + ); + + const triggerButton = ( + + ); + + if (useModal) { + return ( + <> + {triggerButton} + + + + 날짜 선택 + +
+ +
+
+
+ + ); + } + + return ( + + + {triggerButton} + + + + + + ); +} + +// ======================================== +// date 서브타입 - 기간 선택 (프리셋 + Calendar Range) +// ======================================== + +interface DateRangeValue { from?: string; to?: string } + +const RANGE_PRESETS = [ + { key: "today", label: "오늘" }, + { key: "this-week", label: "이번주" }, + { key: "this-month", label: "이번달" }, +] as const; + +function computeRangePreset(key: string): DateRangeValue { + const now = new Date(); + const fmt = (d: Date) => format(d, "yyyy-MM-dd"); + switch (key) { + case "today": + return { from: fmt(now), to: fmt(now) }; + case "this-week": + return { from: fmt(startOfWeek(now, { weekStartsOn: 1 })), to: fmt(endOfWeek(now, { weekStartsOn: 1 })) }; + case "this-month": + return { from: fmt(startOfMonth(now)), to: fmt(endOfMonth(now)) }; + default: + return {}; + } +} + +function DateRangeInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) { + const [open, setOpen] = useState(false); + const useModal = config.calendarDisplay === "modal"; + + const rangeVal: DateRangeValue = (typeof value === "object" && value !== null) + ? value as DateRangeValue + : (typeof value === "string" && value ? { from: value, to: value } : {}); + + const calendarRange = useMemo(() => { + if (!rangeVal.from) return undefined; + return { + from: new Date(rangeVal.from + "T00:00:00"), + to: rangeVal.to ? new Date(rangeVal.to + "T00:00:00") : undefined, + }; + }, [rangeVal.from, rangeVal.to]); + + const activePreset = RANGE_PRESETS.find((p) => { + const preset = computeRangePreset(p.key); + return preset.from === rangeVal.from && preset.to === rangeVal.to; + })?.key ?? null; + + const handlePreset = useCallback( + (key: string) => { + const preset = computeRangePreset(key); + onChange(preset); + }, + [onChange] + ); + + const handleRangeSelect = useCallback( + (range: { from?: Date; to?: Date } | undefined) => { + if (!range?.from) return; + const from = format(range.from, "yyyy-MM-dd"); + const to = range.to ? format(range.to, "yyyy-MM-dd") : from; + onChange({ from, to }); + if (range.to) setOpen(false); + }, + [onChange] + ); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onChange({}); + }, + [onChange] + ); + + const displayText = rangeVal.from + ? rangeVal.from === rangeVal.to + ? format(new Date(rangeVal.from + "T00:00:00"), "MM/dd (EEE)", { locale: ko }) + : `${format(new Date(rangeVal.from + "T00:00:00"), "MM/dd", { locale: ko })} ~ ${rangeVal.to ? format(new Date(rangeVal.to + "T00:00:00"), "MM/dd", { locale: ko }) : ""}` + : ""; + + const presetBar = ( +
+ {RANGE_PRESETS.map((p) => ( + + ))} +
+ ); + + const calendarEl = ( + + ); + + const triggerButton = ( + + ); + + if (useModal) { + return ( + <> + {triggerButton} + + + + 기간 선택 + +
+ {presetBar} +
+ {calendarEl} +
+
+
+
+ + ); + } + + return ( + + + {triggerButton} + + +
+ {presetBar} + {calendarEl} +
+
+
+ ); +} + // ======================================== // select 서브타입 // ======================================== @@ -237,7 +571,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig; function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) { return ( update({ labelText: e.target.value })} - placeholder="예: 거래처명" - className="h-8 text-xs" - /> -
-
- - -
- +
+ + update({ labelText: e.target.value })} + placeholder="예: 거래처명" + className="h-8 text-xs" + /> +
)} +
); } @@ -224,18 +217,29 @@ function StepBasicSettings({ cfg, update }: StepProps) { // STEP 2: 타입별 상세 설정 // ======================================== -function StepDetailSettings({ cfg, update }: StepProps) { +function StepDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { const normalized = normalizeInputType(cfg.inputType as string); switch (normalized) { case "text": case "number": - return ; + return ; case "select": - return ; + return ; + case "date": + return ; case "date-preset": - return ; + return ; case "modal": return ; + case "status-chip": + return ( +
+

+ 상태 칩은 pop-status-bar 컴포넌트로 분리되었습니다. + 새로운 "상태 바" 컴포넌트를 사용해주세요. +

+
+ ); case "toggle": return (
@@ -255,11 +259,278 @@ function StepDetailSettings({ cfg, update }: StepProps) { } } +// ======================================== +// 공통: 필터 연결 설정 섹션 +// ======================================== + +interface FilterConnectionSectionProps { + cfg: PopSearchConfig; + update: (partial: Partial) => void; + showFieldName: boolean; + fixedFilterMode?: SearchFilterMode; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; + componentId?: string; +} + +interface ConnectedComponentInfo { + tableNames: string[]; + displayedColumns: Set; +} + +/** + * 연결된 대상 컴포넌트의 tableName과 카드에서 표시 중인 컬럼을 추출한다. + */ +function getConnectedComponentInfo( + componentId?: string, + connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[], + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[], +): ConnectedComponentInfo { + const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() }; + if (!componentId || !connections || !allComponents) return empty; + + const targetIds = connections + .filter((c) => c.sourceComponent === componentId) + .map((c) => c.targetComponent); + + const tableNames = new Set(); + const displayedColumns = new Set(); + + for (const tid of targetIds) { + const comp = allComponents.find((c) => c.id === tid); + if (!comp?.config) continue; + const compCfg = comp.config as Record; + + const tn = compCfg.dataSource?.tableName; + if (tn) tableNames.add(tn); + + // pop-card-list: cardTemplate에서 사용 중인 컬럼 수집 + const tpl = compCfg.cardTemplate; + if (tpl) { + if (tpl.header?.codeField) displayedColumns.add(tpl.header.codeField); + if (tpl.header?.titleField) displayedColumns.add(tpl.header.titleField); + if (tpl.image?.imageColumn) displayedColumns.add(tpl.image.imageColumn); + if (Array.isArray(tpl.body?.fields)) { + for (const f of tpl.body.fields) { + if (f.columnName) displayedColumns.add(f.columnName); + } + } + } + + // pop-string-list: selectedColumns / listColumns + if (Array.isArray(compCfg.selectedColumns)) { + for (const col of compCfg.selectedColumns) displayedColumns.add(col); + } + if (Array.isArray(compCfg.listColumns)) { + for (const lc of compCfg.listColumns) { + if (lc.columnName) displayedColumns.add(lc.columnName); + } + } + } + + return { tableNames: Array.from(tableNames), displayedColumns }; +} + +function FilterConnectionSection({ cfg, update, showFieldName, fixedFilterMode, allComponents, connections, componentId }: FilterConnectionSectionProps) { + const connInfo = useMemo( + () => getConnectedComponentInfo(componentId, connections, allComponents), + [componentId, connections, allComponents], + ); + + const [targetColumns, setTargetColumns] = useState([]); + const [columnsLoading, setColumnsLoading] = useState(false); + + const connectedTablesKey = connInfo.tableNames.join(","); + useEffect(() => { + if (connInfo.tableNames.length === 0) { + setTargetColumns([]); + return; + } + let cancelled = false; + setColumnsLoading(true); + + Promise.all(connInfo.tableNames.map((t) => getTableColumns(t))) + .then((results) => { + if (cancelled) return; + const allCols: ColumnTypeInfo[] = []; + const seen = new Set(); + for (const res of results) { + if (res.success && res.data?.columns) { + for (const col of res.data.columns) { + if (!seen.has(col.columnName)) { + seen.add(col.columnName); + allCols.push(col); + } + } + } + } + setTargetColumns(allCols); + }) + .finally(() => { if (!cancelled) setColumnsLoading(false); }); + + return () => { cancelled = true; }; + }, [connectedTablesKey]); // eslint-disable-line react-hooks/exhaustive-deps + + const hasConnection = connInfo.tableNames.length > 0; + + const { displayedCols, otherCols } = useMemo(() => { + if (connInfo.displayedColumns.size === 0) { + return { displayedCols: [] as ColumnTypeInfo[], otherCols: targetColumns }; + } + const displayed: ColumnTypeInfo[] = []; + const others: ColumnTypeInfo[] = []; + for (const col of targetColumns) { + if (connInfo.displayedColumns.has(col.columnName)) { + displayed.push(col); + } else { + others.push(col); + } + } + return { displayedCols: displayed, otherCols: others }; + }, [targetColumns, connInfo.displayedColumns]); + + const selectedFilterCols = cfg.filterColumns || (cfg.fieldName ? [cfg.fieldName] : []); + + const toggleFilterColumn = (colName: string) => { + const current = new Set(selectedFilterCols); + if (current.has(colName)) { + current.delete(colName); + } else { + current.add(colName); + } + const next = Array.from(current); + update({ + filterColumns: next, + fieldName: next[0] || "", + }); + }; + + const renderColumnCheckbox = (col: ColumnTypeInfo) => ( +
+ toggleFilterColumn(col.columnName)} + /> + +
+ ); + + return ( +
+
+ 필터 연결 설정 +
+ + {!hasConnection && ( +
+ +

+ 연결 탭에서 대상 컴포넌트를 먼저 연결해주세요. + 연결된 리스트의 컬럼 목록이 여기에 표시됩니다. +

+
+ )} + + {hasConnection && showFieldName && ( +
+ + {columnsLoading ? ( +
+ + 컬럼 로딩... +
+ ) : targetColumns.length > 0 ? ( +
+ {displayedCols.length > 0 && ( +
+

카드에서 표시 중

+ {displayedCols.map(renderColumnCheckbox)} +
+ )} + {displayedCols.length > 0 && otherCols.length > 0 && ( +
+ )} + {otherCols.length > 0 && ( +
+

기타 컬럼

+ {otherCols.map(renderColumnCheckbox)} +
+ )} +
+ ) : ( +

+ 연결된 테이블에서 컬럼을 찾을 수 없습니다 +

+ )} + {selectedFilterCols.length === 0 && hasConnection && !columnsLoading && targetColumns.length > 0 && ( +
+ +

+ 필터 대상 컬럼을 선택해야 연결된 리스트에서 검색이 작동합니다 +

+
+ )} + {selectedFilterCols.length > 0 && ( +

+ {selectedFilterCols.length}개 컬럼 선택됨 - 검색어가 선택된 모든 컬럼에서 매칭됩니다 +

+ )} + {selectedFilterCols.length === 0 && ( +

+ 연결된 리스트에서 이 검색값과 매칭할 컬럼 (복수 선택 가능) +

+ )} +
+ )} + + {fixedFilterMode ? ( +
+ +
+ {SEARCH_FILTER_MODE_LABELS[fixedFilterMode]} +
+

+ 이 입력 타입은 {SEARCH_FILTER_MODE_LABELS[fixedFilterMode]} 방식이 자동 적용됩니다 +

+
+ ) : ( +
+ + +

+ 연결된 리스트에 값을 보낼 때 적용되는 매칭 방식 +

+
+ )} +
+ ); +} + // ======================================== // text/number 상세 설정 // ======================================== -function TextDetailSettings({ cfg, update }: StepProps) { +function TextDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { return (
@@ -285,6 +556,8 @@ function TextDetailSettings({ cfg, update }: StepProps) { />
+ +
); } @@ -293,7 +566,7 @@ function TextDetailSettings({ cfg, update }: StepProps) { // select 상세 설정 // ======================================== -function SelectDetailSettings({ cfg, update }: StepProps) { +function SelectDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { const options = cfg.options || []; const addOption = () => { @@ -329,6 +602,90 @@ function SelectDetailSettings({ cfg, update }: StepProps) { 옵션 추가 + + +
+ ); +} + +// ======================================== +// date 상세 설정 +// ======================================== + +const DATE_SELECTION_MODE_LABELS: Record = { + single: "단일 날짜", + range: "기간 선택", +}; + +const CALENDAR_DISPLAY_LABELS: Record = { + popover: "팝오버 (PC용)", + modal: "모달 (터치/POP용)", +}; + +function DateDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { + const mode: DateSelectionMode = cfg.dateSelectionMode || "single"; + const calDisplay: CalendarDisplayMode = cfg.calendarDisplay || "modal"; + const autoFilterMode = mode === "range" ? "range" : "equals"; + + return ( +
+
+ + +

+ {mode === "single" + ? "캘린더에서 날짜 하나를 선택합니다" + : "프리셋(오늘/이번주/이번달) + 캘린더 기간 선택"} +

+
+ +
+ + +

+ {calDisplay === "modal" + ? "터치 친화적인 큰 모달로 캘린더가 열립니다" + : "입력란 아래에 작은 팝오버로 열립니다"} +

+
+ +
); } @@ -337,7 +694,7 @@ function SelectDetailSettings({ cfg, update }: StepProps) { // date-preset 상세 설정 // ======================================== -function DatePresetDetailSettings({ cfg, update }: StepProps) { +function DatePresetDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"]; const activePresets = cfg.datePresets || ["today", "this-week", "this-month"]; @@ -366,6 +723,8 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) { "직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현)

)} + +
); } @@ -647,6 +1006,21 @@ function ModalDetailSettings({ cfg, update }: StepProps) {

+ {/* 중복 제거 (Distinct) */} +
+
+ updateModal({ distinct: !!checked })} + /> + +
+

+ 표시 필드 기준으로 동일한 값이 여러 건이면 하나만 표시 +

+
+ {/* 검색창에 보일 값 */}
@@ -694,8 +1068,11 @@ function ModalDetailSettings({ cfg, update }: StepProps) { 연결된 리스트를 필터할 때 사용할 값 (예: 회사코드)

+ + )}
); } + diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index 6c49b1c5..6b284b60 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -1,7 +1,7 @@ // ===== pop-search 전용 타입 ===== // 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나. -/** 검색 필드 입력 타입 (9종) */ +/** 검색 필드 입력 타입 (10종) */ export type SearchInputType = | "text" | "number" @@ -11,7 +11,8 @@ export type SearchInputType = | "multi-select" | "combo" | "modal" - | "toggle"; + | "toggle" + | "status-chip"; /** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */ export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid"; @@ -22,6 +23,12 @@ export function normalizeInputType(t: string): SearchInputType { return t as SearchInputType; } +/** 날짜 선택 모드 */ +export type DateSelectionMode = "single" | "range"; + +/** 캘린더 표시 방식 (POP 터치 환경에서는 modal 권장) */ +export type CalendarDisplayMode = "popover" | "modal"; + /** 날짜 프리셋 옵션 */ export type DatePresetOption = "today" | "this-week" | "this-month" | "custom"; @@ -46,6 +53,9 @@ export type ModalDisplayStyle = "table" | "icon"; /** 모달 검색 방식 */ export type ModalSearchMode = "contains" | "starts-with" | "equals"; +/** 검색 값을 대상 리스트에 전달할 때의 필터링 방식 */ +export type SearchFilterMode = "contains" | "equals" | "starts_with" | "range"; + /** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */ export type ModalFilterTab = "korean" | "alphabet"; @@ -64,6 +74,22 @@ export interface ModalSelectConfig { displayField: string; valueField: string; + + /** displayField 기준 중복 제거 */ + distinct?: boolean; +} + +/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */ +export type StatusChipStyle = "tab" | "pill"; + +/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */ +export interface StatusChipConfig { + showCount?: boolean; + countColumn?: string; + allowAll?: boolean; + allLabel?: string; + chipStyle?: StatusChipStyle; + useSubCount?: boolean; } /** pop-search 전체 설정 */ @@ -81,18 +107,28 @@ export interface PopSearchConfig { options?: SelectOption[]; optionsDataSource?: SelectDataSource; + // date 전용 + dateSelectionMode?: DateSelectionMode; + calendarDisplay?: CalendarDisplayMode; + // date-preset 전용 datePresets?: DatePresetOption[]; // modal 전용 modalConfig?: ModalSelectConfig; + // status-chip 전용 + statusChipConfig?: StatusChipConfig; + // 라벨 labelText?: string; labelVisible?: boolean; - // 스타일 - labelPosition?: "top" | "left"; + // 연결된 리스트에 필터를 보낼 때의 매칭 방식 + filterMode?: SearchFilterMode; + + // 필터 대상 컬럼 복수 선택 (fieldName은 대표 컬럼, filterColumns는 전체 대상) + filterColumns?: string[]; } /** 기본 설정값 (레지스트리 + 컴포넌트 공유) */ @@ -102,7 +138,6 @@ export const DEFAULT_SEARCH_CONFIG: PopSearchConfig = { placeholder: "검색어 입력", debounceMs: 500, triggerOnEnter: true, - labelPosition: "top", labelText: "", labelVisible: true, }; @@ -126,6 +161,13 @@ export const SEARCH_INPUT_TYPE_LABELS: Record = { combo: "자동완성", modal: "모달", toggle: "토글", + "status-chip": "상태 칩 (대시보드)", +}; + +/** 상태 칩 스타일 라벨 (설정 패널용) */ +export const STATUS_CHIP_STYLE_LABELS: Record = { + tab: "탭 (큰 숫자)", + pill: "알약 (작은 뱃지)", }; /** 모달 보여주기 방식 라벨 */ @@ -147,6 +189,14 @@ export const MODAL_FILTER_TAB_LABELS: Record = { alphabet: "ABC", }; +/** 검색 필터 방식 라벨 (설정 패널용) */ +export const SEARCH_FILTER_MODE_LABELS: Record = { + contains: "포함", + equals: "일치", + starts_with: "시작", + range: "범위", +}; + /** 한글 초성 추출 */ const KOREAN_CONSONANTS = [ "ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", diff --git a/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx b/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx index 62d63f02..99444d95 100644 --- a/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx +++ b/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx @@ -38,9 +38,23 @@ export function ColumnCombobox({ const filtered = useMemo(() => { if (!search) return columns; const q = search.toLowerCase(); - return columns.filter((c) => c.name.toLowerCase().includes(q)); + return columns.filter( + (c) => + c.name.toLowerCase().includes(q) || + (c.comment && c.comment.toLowerCase().includes(q)) + ); }, [columns, search]); + const selectedCol = useMemo( + () => columns.find((c) => c.name === value), + [columns, value], + ); + const displayValue = selectedCol + ? selectedCol.comment + ? `${selectedCol.name} (${selectedCol.comment})` + : selectedCol.name + : ""; + return ( @@ -50,7 +64,7 @@ export function ColumnCombobox({ aria-expanded={open} className="mt-1 h-8 w-full justify-between text-xs" > - {value || placeholder} + {displayValue || placeholder} @@ -61,7 +75,7 @@ export function ColumnCombobox({ > -
- {col.name} +
+
+ {col.name} + {col.comment && ( + + ({col.comment}) + + )} +
{col.type} diff --git a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx new file mode 100644 index 00000000..805fadcd --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useState, useCallback, useEffect, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { usePopEvent } from "@/hooks/pop"; +import type { StatusBarConfig, StatusChipOption } from "./types"; +import { DEFAULT_STATUS_BAR_CONFIG } from "./types"; + +interface PopStatusBarComponentProps { + config: StatusBarConfig; + label?: string; + screenId?: string; + componentId?: string; +} + +export function PopStatusBarComponent({ + config: rawConfig, + label, + screenId, + componentId, +}: PopStatusBarComponentProps) { + const config = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) }; + const { publish, subscribe } = usePopEvent(screenId || ""); + + const [selectedValue, setSelectedValue] = useState(""); + const [allRows, setAllRows] = useState[]>([]); + const [autoSubStatusColumn, setAutoSubStatusColumn] = useState(null); + + // all_rows 이벤트 구독 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__all_rows`, + (payload: unknown) => { + const data = payload as { value?: unknown } | unknown; + const inner = + typeof data === "object" && data && "value" in data + ? (data as { value: unknown }).value + : data; + + if ( + typeof inner === "object" && + inner && + !Array.isArray(inner) && + "rows" in inner + ) { + const envelope = inner as { + rows?: unknown; + subStatusColumn?: string | null; + }; + if (Array.isArray(envelope.rows)) + setAllRows(envelope.rows as Record[]); + setAutoSubStatusColumn(envelope.subStatusColumn ?? null); + } else if (Array.isArray(inner)) { + setAllRows(inner as Record[]); + setAutoSubStatusColumn(null); + } + } + ); + return unsub; + }, [componentId, subscribe]); + + // 외부에서 값 설정 이벤트 구독 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__set_value`, + (payload: unknown) => { + const data = payload as { value?: unknown } | unknown; + const incoming = + typeof data === "object" && data && "value" in data + ? (data as { value: unknown }).value + : data; + setSelectedValue(String(incoming ?? "")); + } + ); + return unsub; + }, [componentId, subscribe]); + + const emitFilter = useCallback( + (newValue: string) => { + setSelectedValue(newValue); + if (!componentId) return; + + const baseColumn = config.filterColumn || config.countColumn || ""; + const subActive = config.useSubCount && !!autoSubStatusColumn; + const filterColumns = subActive + ? [...new Set([baseColumn, autoSubStatusColumn!].filter(Boolean))] + : [baseColumn].filter(Boolean); + + publish(`__comp_output__${componentId}__filter_value`, { + fieldName: baseColumn, + filterColumns, + value: newValue, + filterMode: "equals", + _source: "status-bar", + }); + }, + [componentId, publish, config.filterColumn, config.countColumn, config.useSubCount, autoSubStatusColumn] + ); + + const chipCfg = config; + const showCount = chipCfg.showCount !== false; + const baseCountColumn = chipCfg.countColumn || ""; + const useSubCount = chipCfg.useSubCount || false; + const hideUntilSubFilter = chipCfg.hideUntilSubFilter || false; + const allowAll = chipCfg.allowAll !== false; + const allLabel = chipCfg.allLabel || "전체"; + const chipStyle = chipCfg.chipStyle || "tab"; + const options: StatusChipOption[] = chipCfg.options || []; + + // 하위 필터(공정) 활성 여부 + const subFilterActive = useSubCount && !!autoSubStatusColumn; + + // hideUntilSubFilter가 켜져있으면서 아직 공정 선택이 안 된 경우 숨김 + const shouldHide = hideUntilSubFilter && !subFilterActive; + + const effectiveCountColumn = + subFilterActive ? autoSubStatusColumn : baseCountColumn; + + const counts = useMemo(() => { + if (!showCount || !effectiveCountColumn || allRows.length === 0) + return new Map(); + const map = new Map(); + for (const row of allRows) { + if (row == null || typeof row !== "object") continue; + const v = String(row[effectiveCountColumn] ?? ""); + map.set(v, (map.get(v) || 0) + 1); + } + return map; + }, [allRows, effectiveCountColumn, showCount]); + + const totalCount = allRows.length; + + const chipItems = useMemo(() => { + const items: { value: string; label: string; count: number }[] = []; + if (allowAll) { + items.push({ value: "", label: allLabel, count: totalCount }); + } + for (const opt of options) { + items.push({ + value: opt.value, + label: opt.label, + count: counts.get(opt.value) || 0, + }); + } + return items; + }, [options, counts, totalCount, allowAll, allLabel]); + + const showLabel = !!label; + + if (shouldHide) { + return ( +
+ + {chipCfg.hiddenMessage || "조건을 선택하면 상태별 현황이 표시됩니다"} + +
+ ); + } + + if (chipStyle === "pill") { + return ( +
+ {showLabel && ( + + {label} + + )} +
+ {chipItems.map((item) => { + const isActive = selectedValue === item.value; + return ( + + ); + })} +
+
+ ); + } + + // tab 스타일 (기본) + return ( +
+ {showLabel && ( + + {label} + + )} +
+ {chipItems.map((item) => { + const isActive = selectedValue === item.value; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx new file mode 100644 index 00000000..3b0ce864 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx @@ -0,0 +1,489 @@ +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Plus, Trash2, Loader2, AlertTriangle, RefreshCw } from "lucide-react"; +import { getTableColumns } from "@/lib/api/tableManagement"; +import { dataApi } from "@/lib/api/data"; +import type { ColumnTypeInfo } from "@/lib/api/tableManagement"; +import type { StatusBarConfig, StatusChipStyle, StatusChipOption } from "./types"; +import { DEFAULT_STATUS_BAR_CONFIG, STATUS_CHIP_STYLE_LABELS } from "./types"; + +interface ConfigPanelProps { + config: StatusBarConfig | undefined; + onUpdate: (config: StatusBarConfig) => void; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; + componentId?: string; +} + +export function PopStatusBarConfigPanel({ + config: rawConfig, + onUpdate, + allComponents, + connections, + componentId, +}: ConfigPanelProps) { + const cfg = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) }; + + const update = (partial: Partial) => { + onUpdate({ ...cfg, ...partial }); + }; + + const options = cfg.options || []; + + const removeOption = (index: number) => { + update({ options: options.filter((_, i) => i !== index) }); + }; + + const updateOption = ( + index: number, + field: keyof StatusChipOption, + val: string + ) => { + update({ + options: options.map((opt, i) => + i === index ? { ...opt, [field]: val } : opt + ), + }); + }; + + // 연결된 카드 컴포넌트의 테이블 컬럼 가져오기 + const connectedTableName = useMemo(() => { + if (!componentId || !connections || !allComponents) return null; + const targetIds = connections + .filter((c) => c.sourceComponent === componentId) + .map((c) => c.targetComponent); + const sourceIds = connections + .filter((c) => c.targetComponent === componentId) + .map((c) => c.sourceComponent); + const peerIds = [...new Set([...targetIds, ...sourceIds])]; + + for (const pid of peerIds) { + const comp = allComponents.find((c) => c.id === pid); + if (!comp?.config) continue; + const compCfg = comp.config as Record; + const ds = compCfg.dataSource as { tableName?: string } | undefined; + if (ds?.tableName) return ds.tableName; + } + return null; + }, [componentId, connections, allComponents]); + + const [targetColumns, setTargetColumns] = useState([]); + const [columnsLoading, setColumnsLoading] = useState(false); + + // 집계 컬럼의 고유값 (옵션 선택용) + const [distinctValues, setDistinctValues] = useState([]); + const [distinctLoading, setDistinctLoading] = useState(false); + + useEffect(() => { + if (!connectedTableName) { + setTargetColumns([]); + return; + } + let cancelled = false; + setColumnsLoading(true); + getTableColumns(connectedTableName) + .then((res) => { + if (cancelled) return; + if (res.success && res.data?.columns) { + setTargetColumns(res.data.columns); + } + }) + .finally(() => { + if (!cancelled) setColumnsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [connectedTableName]); + + const fetchDistinctValues = useCallback(async (tableName: string, column: string) => { + setDistinctLoading(true); + try { + const res = await dataApi.getTableData(tableName, { page: 1, size: 9999 }); + const vals = new Set(); + for (const row of res.data) { + const v = row[column]; + if (v != null && String(v).trim() !== "") { + vals.add(String(v)); + } + } + const sorted = [...vals].sort(); + setDistinctValues(sorted); + return sorted; + } catch { + setDistinctValues([]); + return []; + } finally { + setDistinctLoading(false); + } + }, []); + + // 집계 컬럼 변경 시 고유값 새로 가져오기 + useEffect(() => { + const col = cfg.countColumn; + if (!connectedTableName || !col) { + setDistinctValues([]); + return; + } + fetchDistinctValues(connectedTableName, col); + }, [connectedTableName, cfg.countColumn, fetchDistinctValues]); + + const handleAutoFill = useCallback(async () => { + if (!connectedTableName || !cfg.countColumn) return; + const vals = await fetchDistinctValues(connectedTableName, cfg.countColumn); + if (vals.length === 0) return; + const newOptions: StatusChipOption[] = vals.map((v) => { + const existing = options.find((o) => o.value === v); + return { value: v, label: existing?.label || v }; + }); + update({ options: newOptions }); + }, [connectedTableName, cfg.countColumn, options, fetchDistinctValues]); + + const addOptionFromValue = (value: string) => { + if (options.some((o) => o.value === value)) return; + update({ + options: [...options, { value, label: value }], + }); + }; + + return ( +
+ {/* --- 칩 옵션 목록 --- */} +
+
+ + {connectedTableName && cfg.countColumn && ( + + )} +
+ {cfg.useSubCount && ( +
+ +

+ 하위 필터 자동 전환이 켜져 있으면 런타임에 가상 컬럼으로 + 집계됩니다. DB 값과 다를 수 있으니 직접 입력을 권장합니다. +

+
+ )} + {options.length === 0 && ( +

+ {connectedTableName && cfg.countColumn + ? "\"DB에서 자동 채우기\"를 클릭하거나 아래에서 추가하세요." + : "옵션이 없습니다. 먼저 집계 컬럼을 선택한 후 추가하세요."} +

+ )} + {options.map((opt, i) => ( +
+ updateOption(i, "value", e.target.value)} + placeholder="DB 값" + className="h-7 flex-1 text-[10px]" + /> + updateOption(i, "label", e.target.value)} + placeholder="표시 라벨" + className="h-7 flex-1 text-[10px]" + /> + +
+ ))} + + {/* 고유값에서 추가 */} + {distinctValues.length > 0 && ( +
+ +
+ {distinctValues + .filter((dv) => !options.some((o) => o.value === dv)) + .map((dv) => ( + + ))} + {distinctValues.every((dv) => options.some((o) => o.value === dv)) && ( +

모든 값이 추가되었습니다

+ )} +
+
+ )} + + {/* 수동 추가 */} + +
+ + {/* --- 전체 보기 칩 --- */} +
+
+ update({ allowAll: Boolean(checked) })} + /> + +
+

+ 필터 해제용 칩을 옵션 목록 맨 앞에 자동 추가합니다 +

+ + {cfg.allowAll !== false && ( +
+ + update({ allLabel: e.target.value })} + placeholder="전체" + className="h-7 text-[10px]" + /> +
+ )} +
+ + {/* --- 건수 표시 --- */} +
+ update({ showCount: Boolean(checked) })} + /> + +
+ + {cfg.showCount !== false && ( +
+ + {columnsLoading ? ( +
+ + 컬럼 로딩... +
+ ) : targetColumns.length > 0 ? ( + + ) : ( + update({ countColumn: e.target.value })} + placeholder="예: status" + className="h-8 text-xs" + /> + )} +

+ 연결된 카드의 이 컬럼 값으로 상태별 건수를 집계합니다 +

+
+ )} + + {cfg.showCount !== false && ( +
+
+ + update({ useSubCount: Boolean(checked) }) + } + /> + +
+ {cfg.useSubCount && ( + <> +

+ 연결된 카드의 하위 테이블 필터가 적용되면 집계 컬럼이 자동 + 전환됩니다 +

+
+ + update({ hideUntilSubFilter: Boolean(checked) }) + } + /> + +
+ {cfg.hideUntilSubFilter && ( +
+ + update({ hiddenMessage: e.target.value })} + placeholder="조건을 선택하면 상태별 현황이 표시됩니다" + className="h-7 text-[10px]" + /> +
+ )} + + )} +
+ )} + + {/* --- 칩 스타일 --- */} +
+ + +

+ 탭: 큰 숫자 + 라벨 / 알약: 작은 뱃지 형태 +

+
+ + {/* --- 필터 컬럼 --- */} +
+ + {!connectedTableName && ( +
+ +

+ 연결 탭에서 대상 카드 컴포넌트를 먼저 연결해주세요. +

+
+ )} + {connectedTableName && ( + <> + {columnsLoading ? ( +
+ + 컬럼 로딩... +
+ ) : targetColumns.length > 0 ? ( + + ) : ( + update({ filterColumn: e.target.value })} + placeholder="예: status" + className="h-8 text-xs" + /> + )} +

+ 선택한 상태 칩 값으로 카드를 필터링할 컬럼 (비어있으면 집계 + 컬럼과 동일) +

+ + )} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-status-bar/index.tsx b/frontend/lib/registry/pop-components/pop-status-bar/index.tsx new file mode 100644 index 00000000..e94f321a --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-status-bar/index.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopStatusBarComponent } from "./PopStatusBarComponent"; +import { PopStatusBarConfigPanel } from "./PopStatusBarConfig"; +import type { StatusBarConfig } from "./types"; +import { DEFAULT_STATUS_BAR_CONFIG } from "./types"; + +function PopStatusBarPreviewComponent({ + config, + label, +}: { + config?: StatusBarConfig; + label?: string; +}) { + const cfg = config || DEFAULT_STATUS_BAR_CONFIG; + const options = cfg.options || []; + const displayLabel = label || "상태 바"; + + return ( +
+ + {displayLabel} + +
+ {options.length === 0 ? ( + + 옵션 없음 + + ) : ( + options.slice(0, 4).map((opt) => ( +
+ 0 + + {opt.label} + +
+ )) + )} +
+
+ ); +} + +PopComponentRegistry.registerComponent({ + id: "pop-status-bar", + name: "상태 바", + description: "상태별 건수 대시보드 + 필터", + category: "display", + icon: "BarChart3", + component: PopStatusBarComponent, + configPanel: PopStatusBarConfigPanel, + preview: PopStatusBarPreviewComponent, + defaultProps: DEFAULT_STATUS_BAR_CONFIG, + connectionMeta: { + sendable: [ + { + key: "filter_value", + label: "필터 값", + type: "filter_value", + category: "filter", + description: "선택한 상태 칩 값을 카드에 필터로 전달", + }, + ], + receivable: [ + { + key: "all_rows", + label: "전체 데이터", + type: "all_rows", + category: "data", + description: "연결된 카드의 전체 데이터를 받아 상태별 건수 집계", + }, + { + key: "set_value", + label: "값 설정", + type: "filter_value", + category: "filter", + description: "외부에서 선택 값 설정", + }, + ], + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-status-bar/types.ts b/frontend/lib/registry/pop-components/pop-status-bar/types.ts new file mode 100644 index 00000000..91a37c40 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-status-bar/types.ts @@ -0,0 +1,48 @@ +// ===== pop-status-bar 전용 타입 ===== +// 상태 칩 대시보드 컴포넌트. 카드 데이터를 집계하여 상태별 건수 표시 + 필터 발행. + +/** 상태 칩 표시 스타일 */ +export type StatusChipStyle = "tab" | "pill"; + +/** 개별 옵션 */ +export interface StatusChipOption { + value: string; + label: string; +} + +/** status-bar 전용 설정 */ +export interface StatusBarConfig { + showCount?: boolean; + countColumn?: string; + allowAll?: boolean; + allLabel?: string; + chipStyle?: StatusChipStyle; + /** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */ + useSubCount?: boolean; + /** 하위 필터(공정 선택 등)가 활성화되기 전까지 칩을 숨김 */ + hideUntilSubFilter?: boolean; + /** 칩 숨김 상태일 때 표시할 안내 문구 */ + hiddenMessage?: string; + + options?: StatusChipOption[]; + + /** 필터 대상 컬럼명 (기본: countColumn) */ + filterColumn?: string; + /** 추가 필터 대상 컬럼 (하위 테이블 등) */ + filterColumns?: string[]; +} + +/** 기본 설정값 */ +export const DEFAULT_STATUS_BAR_CONFIG: StatusBarConfig = { + showCount: true, + allowAll: true, + allLabel: "전체", + chipStyle: "tab", + options: [], +}; + +/** 칩 스타일 라벨 (설정 패널용) */ +export const STATUS_CHIP_STYLE_LABELS: Record = { + tab: "탭 (큰 숫자)", + pill: "알약 (작은 뱃지)", +}; diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx index ea7f5d58..d6595f36 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx @@ -193,10 +193,9 @@ export function PopStringListComponent({ row: RowData, filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } } ): boolean => { - const searchValue = String(filter.value).toLowerCase(); - if (!searchValue) return true; - const fc = filter.filterConfig; + const mode = fc?.filterMode || "contains"; + const columns: string[] = fc?.targetColumns?.length ? fc.targetColumns @@ -208,17 +207,46 @@ export function PopStringListComponent({ if (columns.length === 0) return true; - const mode = fc?.filterMode || "contains"; + // range 모드: { from, to } 객체 또는 단일 날짜 문자열 지원 + if (mode === "range") { + const val = filter.value as { from?: string; to?: string } | string; + let from = ""; + let to = ""; + if (typeof val === "object" && val !== null) { + from = val.from || ""; + to = val.to || ""; + } else { + from = String(val || ""); + to = from; + } + if (!from && !to) return true; + + return columns.some((col) => { + const cellDate = String(row[col] ?? "").slice(0, 10); + if (!cellDate) return false; + if (from && cellDate < from) return false; + if (to && cellDate > to) return false; + return true; + }); + } + + // 문자열 기반 필터 (contains, equals, starts_with) + const searchValue = String(filter.value ?? "").toLowerCase(); + if (!searchValue) return true; + + // 날짜 패턴 감지 (YYYY-MM-DD): equals 비교 시 ISO 타임스탬프에서 날짜만 추출 + const isDateValue = /^\d{4}-\d{2}-\d{2}$/.test(searchValue); const matchCell = (cellValue: string) => { + const target = isDateValue && mode === "equals" ? cellValue.slice(0, 10) : cellValue; switch (mode) { case "equals": - return cellValue === searchValue; + return target === searchValue; case "starts_with": - return cellValue.startsWith(searchValue); + return target.startsWith(searchValue); case "contains": default: - return cellValue.includes(searchValue); + return target.includes(searchValue); } }; diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 16340c5d..3b7ff73e 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -722,3 +722,264 @@ export interface PopCardListConfig { cartListMode?: CartListModeConfig; saveMapping?: CardListSaveMapping; } + +// ============================================= +// pop-card-list-v2 전용 타입 (슬롯 기반 카드) +// ============================================= + +import type { ButtonMainAction, ButtonVariant, ConfirmConfig } from "./pop-button"; + +export type CardCellType = + | "text" + | "field" + | "image" + | "badge" + | "button" + | "number-input" + | "cart-button" + | "package-summary" + | "status-badge" + | "timeline" + | "action-buttons" + | "footer-status"; + +// timeline 셀에서 사용하는 하위 단계 데이터 +export interface TimelineProcessStep { + seqNo: number; + processName: string; + status: string; // DB 원본 값 (또는 derivedFrom에 의해 변환된 값) + semantic?: "pending" | "active" | "done"; // 시각적 의미 (렌더러 색상 결정) + isCurrent: boolean; + processId?: string | number; // 공정 테이블 레코드 PK (접수 등 UPDATE 대상 특정용) + rawData?: Record; // 하위 테이블 원본 행 (하위 필터 매칭용) +} + +// timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정 +export interface TimelineDataSource { + processTable: string; // 하위 데이터 테이블명 (예: work_order_process) + foreignKey: string; // 메인 테이블 id와 매칭되는 FK 컬럼 (예: wo_id) + seqColumn: string; // 순서 컬럼 (예: seq_no) + nameColumn: string; // 표시명 컬럼 (예: process_name) + statusColumn: string; // 상태 컬럼 (예: status) + // 상태 값 매핑: DB값 → 시맨틱 (동적 배열, 순서대로 표시) + // 레거시 호환: 기존 { waiting, accepted, inProgress, completed } 객체도 런타임에서 자동 변환 + statusMappings?: StatusValueMapping[]; +} + +export type TimelineStatusSemantic = "pending" | "active" | "done"; + +export interface StatusValueMapping { + dbValue: string; // DB에 저장된 실제 값 (또는 파생 상태의 식별값) + label: string; // 화면에 보이는 이름 + semantic: TimelineStatusSemantic; // 타임라인 색상 결정 (pending=회색, active=파랑, done=초록) + isDerived?: boolean; // true면 DB에 없는 자동 판별 상태 (이전 공정 완료 시 변환) +} + +export interface CardCellDefinitionV2 { + id: string; + row: number; + col: number; + rowSpan: number; + colSpan: number; + type: CardCellType; + + // 공통 + columnName?: string; + label?: string; + labelPosition?: "top" | "left"; + fontSize?: "xs" | "sm" | "md" | "lg"; + fontWeight?: "normal" | "medium" | "bold"; + textColor?: string; + align?: "left" | "center" | "right"; + verticalAlign?: "top" | "middle" | "bottom"; + + // field 타입 전용 (CardFieldBinding 흡수) + valueType?: "column" | "formula"; + formulaLeft?: string; + formulaOperator?: "+" | "-" | "*" | "/"; + formulaRight?: string; + formulaRightType?: "input" | "column"; + unit?: string; + + // image 타입 전용 + defaultImage?: string; + + // button 타입 전용 + buttonAction?: ButtonMainAction; + buttonVariant?: ButtonVariant; + buttonConfirm?: ConfirmConfig; + + // number-input 타입 전용 + inputUnit?: string; + limitColumn?: string; + autoInitMax?: boolean; + + // cart-button 타입 전용 + cartLabel?: string; + cartCancelLabel?: string; + cartIconType?: "lucide" | "emoji"; + cartIconValue?: string; + + // status-badge 타입 전용 + statusColumn?: string; + statusMap?: Array<{ value: string; label: string; color: string }>; + + // timeline 타입 전용: 공정 데이터 소스 설정 + timelineSource?: TimelineDataSource; + processColumn?: string; + processStatusColumn?: string; + currentHighlight?: boolean; + visibleCount?: number; + timelinePriority?: "before" | "after"; + showDetailModal?: boolean; + + // action-buttons 타입 전용 (신규: 버튼 중심 구조) + actionButtons?: ActionButtonDef[]; + // action-buttons 타입 전용 (구: 조건 중심 구조, 하위호환) + actionRules?: Array<{ + whenStatus: string; + buttons: Array; + }>; + + // footer-status 타입 전용 + footerLabel?: string; + footerStatusColumn?: string; + footerStatusMap?: Array<{ value: string; label: string; color: string }>; + showTopBorder?: boolean; +} + +export interface ActionButtonUpdate { + column: string; + value?: string; + valueType: "static" | "currentUser" | "currentTime" | "columnRef"; +} + +// 액션 버튼 클릭 시 동작 모드 +export type ActionButtonClickMode = "status-change" | "modal-open" | "select-mode"; + +// 액션 버튼 개별 설정 +export interface ActionButtonConfig { + label: string; + variant: ButtonVariant; + taskPreset: string; + confirm?: ConfirmConfig; + targetTable?: string; + confirmMessage?: string; + allowMultiSelect?: boolean; + updates?: ActionButtonUpdate[]; + clickMode?: ActionButtonClickMode; + selectModeConfig?: SelectModeConfig; +} + +// 선택 모드 설정 +export interface SelectModeConfig { + filterStatus?: string; + buttons: Array; +} + +// 선택 모드 하단 버튼 설정 +export interface SelectModeButtonConfig { + label: string; + variant: ButtonVariant; + clickMode: "status-change" | "modal-open" | "cancel-select"; + targetTable?: string; + updates?: ActionButtonUpdate[]; + confirmMessage?: string; + modalScreenId?: string; +} + +// ===== 버튼 중심 구조 (신규) ===== + +export interface ActionButtonShowCondition { + type: "timeline-status" | "column-value" | "always"; + value?: string; + column?: string; + unmatchBehavior?: "hidden" | "disabled"; +} + +export interface ActionButtonClickAction { + type: "immediate" | "select-mode" | "modal-open"; + targetTable?: string; + updates?: ActionButtonUpdate[]; + confirmMessage?: string; + selectModeButtons?: SelectModeButtonConfig[]; + modalScreenId?: string; + // 외부 테이블 조인 설정 (DB 직접 선택 시) + joinConfig?: { + sourceColumn: string; // 메인 테이블의 FK 컬럼 + targetColumn: string; // 외부 테이블의 매칭 컬럼 + }; +} + +export interface ActionButtonDef { + label: string; + variant: ButtonVariant; + showCondition?: ActionButtonShowCondition; + /** 단일 액션 (하위호환) 또는 다중 액션 체이닝 */ + clickAction: ActionButtonClickAction; + clickActions?: ActionButtonClickAction[]; +} + +export interface CardGridConfigV2 { + rows: number; + cols: number; + colWidths: string[]; + rowHeights?: string[]; + gap: number; + showCellBorder: boolean; + cells: CardCellDefinitionV2[]; +} + +// ----- V2 카드 선택 동작 ----- + +export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open"; + +export interface V2CardClickModalConfig { + screenId: string; + modalTitle?: string; + condition?: { + type: "timeline-status" | "column-value" | "always"; + value?: string; + column?: string; + }; +} + +// ----- V2 오버플로우 설정 ----- + +export interface V2OverflowConfig { + mode: "loadMore" | "pagination"; + visibleCount: number; + loadMoreCount?: number; + pageSize?: number; +} + +// ----- pop-card-list-v2 전체 설정 ----- + +export interface PopCardListV2Config { + dataSource: CardListDataSource; + cardGrid: CardGridConfigV2; + selectedColumns?: string[]; + gridColumns?: number; + gridRows?: number; + scrollDirection?: CardScrollDirection; + /** @deprecated 열 수(gridColumns)로 카드 크기 결정. 하위 호환용 */ + cardSize?: CardSize; + cardGap?: number; + overflow?: V2OverflowConfig; + cardClickAction?: V2CardClickAction; + cardClickModalConfig?: V2CardClickModalConfig; + /** 연결된 필터 값이 전달되기 전까지 데이터 비표시 */ + hideUntilFiltered?: boolean; + responsiveDisplay?: CardResponsiveConfig; + inputField?: CardInputFieldConfig; + packageConfig?: CardPackageConfig; + cartAction?: CardCartActionConfig; + cartListMode?: CartListModeConfig; + saveMapping?: CardListSaveMapping; +} + +/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */ +export const VIRTUAL_SUB_STATUS = "__subStatus__" as const; +export const VIRTUAL_SUB_SEMANTIC = "__subSemantic__" as const; +export const VIRTUAL_SUB_PROCESS = "__subProcessName__" as const; +export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f38af595..614e0d47 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -265,6 +265,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -306,6 +307,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -339,6 +341,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -3054,6 +3057,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.32.0", @@ -3707,6 +3711,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.6" }, @@ -3774,6 +3779,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4087,6 +4093,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz", "integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -6587,6 +6594,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6597,6 +6605,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6639,6 +6648,7 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", "license": "MIT", + "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -6721,6 +6731,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -7353,6 +7364,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8503,7 +8515,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3": { "version": "7.9.0", @@ -8825,6 +8838,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -9584,6 +9598,7 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9672,6 +9687,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9773,6 +9789,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10944,6 +10961,7 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11724,7 +11742,8 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/levn": { "version": "0.4.1", @@ -13063,6 +13082,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13356,6 +13376,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -13385,6 +13406,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -13433,6 +13455,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -13636,6 +13659,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13705,6 +13729,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -13755,6 +13780,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -13787,7 +13813,8 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-leaflet": { "version": "5.0.0", @@ -14095,6 +14122,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -14117,7 +14145,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/recharts/node_modules/redux-thunk": { "version": "3.1.0", @@ -15147,7 +15176,8 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -15235,6 +15265,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15583,6 +15614,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/types/auth.ts b/frontend/types/auth.ts index cd8e65b6..574eff59 100644 --- a/frontend/types/auth.ts +++ b/frontend/types/auth.ts @@ -14,6 +14,7 @@ export interface LoginResponse { token?: string; userInfo?: any; firstMenuPath?: string | null; + popLandingPath?: string | null; }; errorCode?: string; }