From e1a5befdf72c5c0bfadb0267cf7564748a124313 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 18 Nov 2025 16:12:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B8=B0=EA=B0=84=EB=B3=84=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20-=20=EC=9E=90=EB=8F=99=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선택항목 상세입력 컴포넌트 확장 - 실시간 가격 계산 기능 추가 (할인율/할인금액, 반올림 방식) - 카테고리 값 기반 연산 매핑 시스템 - 3단계 드릴다운 방식 설정 UI (메뉴 → 카테고리 → 값 매핑) - 설정 가능한 계산 로직 - autoCalculation 설정으로 계산 필드명 동적 지정 - valueMapping으로 카테고리 코드와 연산 타입 매핑 - 할인 방식: none/rate/amount - 반올림 방식: none/round/floor/ceil - 반올림 단위: 1/10/100/1000 - UI 개선 - 입력 필드 가로 배치 (반응형 Grid) - 카테고리 타입 필드 옵션 로딩 개선 - 계산 결과 필드 자동 표시 및 읽기 전용 처리 - 날짜 입력 필드 네이티브 피커 지원 - API 연동 - 2레벨 메뉴 목록 조회 - 메뉴별 카테고리 컬럼 조회 - 카테고리별 값 목록 조회 - 문서화 - 기간별 단가 설정 가이드 작성 --- .../controllers/tableManagementController.ts | 106 +-- docs/기간별_단가_설정_가이드.md | 382 +++++++++++ frontend/components/common/ScreenModal.tsx | 107 +-- .../config-panels/ButtonConfigPanel.tsx | 449 ++++++++++++- .../SelectedItemsDetailInputComponent.tsx | 283 ++++++-- .../SelectedItemsDetailInputConfigPanel.tsx | 627 +++++++++++++++++- .../selected-items-detail-input/types.ts | 43 ++ .../SplitPanelLayoutComponent.tsx | 11 +- frontend/lib/utils/buttonActions.ts | 141 +++- frontend/types/unified-core.ts | 3 +- 10 files changed, 1966 insertions(+), 186 deletions(-) create mode 100644 docs/기간별_단가_설정_가이드.md diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index beade4e6..f552124f 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1604,10 +1604,14 @@ export async function toggleLogTable( } /** - * 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회 + * 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속) * * @route GET /api/table-management/menu/:menuObjid/category-columns - * @description 형제 메뉴들의 화면에서 사용하는 테이블의 input_type='category' 컬럼 조회 + * @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회 + * + * 예시: + * - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정 + * - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속) */ export async function getCategoryColumnsByMenu( req: AuthenticatedRequest, @@ -1627,40 +1631,10 @@ export async function getCategoryColumnsByMenu( return; } - // 1. 형제 메뉴 조회 - const { getSiblingMenuObjids } = await import("../services/menuService"); - const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid)); - - logger.info("✅ 형제 메뉴 조회 완료", { siblingObjids }); - - // 2. 형제 메뉴들이 사용하는 테이블 조회 const { getPool } = await import("../database/db"); const pool = getPool(); - - const tablesQuery = ` - SELECT DISTINCT sd.table_name - FROM screen_menu_assignments sma - INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id - WHERE sma.menu_objid = ANY($1) - AND sma.company_code = $2 - AND sd.table_name IS NOT NULL - `; - - const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]); - const tableNames = tablesResult.rows.map((row: any) => row.table_name); - - logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length }); - if (tableNames.length === 0) { - res.json({ - success: true, - data: [], - message: "형제 메뉴에 연결된 테이블이 없습니다.", - }); - return; - } - - // 3. category_column_mapping 테이블 존재 여부 확인 + // 1. category_column_mapping 테이블 존재 여부 확인 const tableExistsResult = await pool.query(` SELECT EXISTS ( SELECT FROM information_schema.tables @@ -1672,33 +1646,42 @@ export async function getCategoryColumnsByMenu( let columnsResult; if (mappingTableExists) { - // 🆕 category_column_mapping을 사용한 필터링 - logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); + // 🆕 category_column_mapping을 사용한 계층 구조 기반 조회 + logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode }); // 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀) const ancestorMenuQuery = ` WITH RECURSIVE menu_hierarchy AS ( -- 현재 메뉴 - SELECT objid, parent_obj_id, menu_type + SELECT objid, parent_obj_id, menu_type, menu_name_kor FROM menu_info WHERE objid = $1 UNION ALL -- 부모 메뉴 재귀 조회 - SELECT m.objid, m.parent_obj_id, m.menu_type + SELECT m.objid, m.parent_obj_id, m.menu_type, m.menu_name_kor FROM menu_info m INNER JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id WHERE m.parent_obj_id != 0 -- 최상위 메뉴(parent_obj_id=0) 제외 ) - SELECT ARRAY_AGG(objid) as menu_objids + SELECT + ARRAY_AGG(objid) as menu_objids, + ARRAY_AGG(menu_name_kor) as menu_names FROM menu_hierarchy `; const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]); const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)]; + const ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || []; + logger.info("✅ 상위 메뉴 계층 조회 완료", { + ancestorMenuObjids, + ancestorMenuNames, + hierarchyDepth: ancestorMenuObjids.length + }); + // 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거) const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", @@ -1711,7 +1694,8 @@ export async function getCategoryColumnsByMenu( cl.column_label, initcap(replace(ccm.logical_column_name, '_', ' ')) ) AS "columnLabel", - ttc.input_type AS "inputType" + ttc.input_type AS "inputType", + ccm.menu_objid AS "definedAtMenuObjid" FROM category_column_mapping ccm INNER JOIN table_type_columns ttc ON ccm.table_name = ttc.table_name @@ -1721,18 +1705,48 @@ export async function getCategoryColumnsByMenu( AND ttc.column_name = cl.column_name LEFT JOIN table_labels tl ON ttc.table_name = tl.table_name - WHERE ccm.table_name = ANY($1) - AND ccm.company_code = $2 - AND ccm.menu_objid = ANY($3) + WHERE ccm.company_code = $1 + AND ccm.menu_objid = ANY($2) AND ttc.input_type = 'category' ORDER BY ttc.table_name, ccm.logical_column_name `; - columnsResult = await pool.query(columnsQuery, [tableNames, companyCode, ancestorMenuObjids]); - logger.info("✅ category_column_mapping 기반 조회 완료", { rowCount: columnsResult.rows.length }); + columnsResult = await pool.query(columnsQuery, [companyCode, ancestorMenuObjids]); + logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", { + rowCount: columnsResult.rows.length, + columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`) + }); } else { - // 🔄 기존 방식: table_type_columns에서 모든 카테고리 컬럼 조회 - logger.info("🔍 레거시 방식: table_type_columns 기반 카테고리 컬럼 조회", { tableNames, companyCode }); + // 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회 + logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); + + // 형제 메뉴 조회 + const { getSiblingMenuObjids } = await import("../services/menuService"); + const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid)); + + // 형제 메뉴들이 사용하는 테이블 조회 + const tablesQuery = ` + SELECT DISTINCT sd.table_name + FROM screen_menu_assignments sma + INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id + WHERE sma.menu_objid = ANY($1) + AND sma.company_code = $2 + AND sd.table_name IS NOT NULL + `; + + const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]); + const tableNames = tablesResult.rows.map((row: any) => row.table_name); + + logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length }); + + if (tableNames.length === 0) { + res.json({ + success: true, + data: [], + message: "형제 메뉴에 연결된 테이블이 없습니다.", + }); + return; + } const columnsQuery = ` SELECT diff --git a/docs/기간별_단가_설정_가이드.md b/docs/기간별_단가_설정_가이드.md new file mode 100644 index 00000000..67bed5f9 --- /dev/null +++ b/docs/기간별_단가_설정_가이드.md @@ -0,0 +1,382 @@ +# 기간별 단가 설정 시스템 구현 가이드 + +## 개요 + +**선택항목 상세입력(selected-items-detail-input)** 컴포넌트를 활용하여 기간별 단가를 설정하는 범용 시스템입니다. + +## 데이터베이스 설계 + +### 1. 마이그레이션 실행 + +```bash +# 마이그레이션 파일 위치 +db/migrations/999_add_period_price_columns_to_customer_item_mapping.sql + +# 실행 (로컬) +npm run migrate:local + +# 또는 수동 실행 +psql -U your_user -d erp_db -f db/migrations/999_add_period_price_columns_to_customer_item_mapping.sql +``` + +### 2. 추가된 컬럼들 + +| 컬럼명 | 타입 | 설명 | 사진 항목 | +|--------|------|------|-----------| +| `start_date` | DATE | 기간 시작일 | ✅ 시작일 DatePicker | +| `end_date` | DATE | 기간 종료일 | ✅ 종료일 DatePicker | +| `discount_type` | VARCHAR(50) | 할인 방식 | ✅ 할인율/할인금액 Select | +| `discount_value` | NUMERIC(15,2) | 할인율 또는 할인금액 | ✅ 숫자 입력 | +| `rounding_type` | VARCHAR(50) | 반올림 방식 | ✅ 반올림/절삭/올림 Select | +| `rounding_unit_value` | VARCHAR(50) | 반올림 단위 | ✅ 1원/10원/100원/1,000원 Select | +| `calculated_price` | NUMERIC(15,2) | 계산된 최종 단가 | ✅ 계산 결과 표시 | +| `is_base_price` | BOOLEAN | 기준단가 여부 | ✅ 기준단가 Checkbox | + +## 화면 편집기 설정 방법 + +### Step 1: 선택항목 상세입력 컴포넌트 추가 + +1. 화면 편집기에서 "선택항목 상세입력" 컴포넌트를 캔버스에 드래그앤드롭 +2. 컴포넌트 ID: `customer-item-price-periods` + +### Step 2: 데이터 소스 설정 + +- **원본 데이터 테이블**: `item_info` (품목 정보) +- **저장 대상 테이블**: `customer_item_mapping` +- **데이터 소스 ID**: URL 파라미터에서 자동 설정 (Button 컴포넌트가 전달) + +### Step 3: 표시할 원본 데이터 컬럼 설정 + +이전 화면(품목 선택 모달)에서 전달받은 품목 정보를 표시: + +``` +컬럼1: item_code (품목코드) +컬럼2: item_name (품목명) +컬럼3: spec (규격) +``` + +### Step 4: 필드 그룹 2개 생성 + +#### 그룹 1: 거래처 품목/품명 관리 (group_customer) + +| 필드명 | 라벨 | 타입 | 설명 | +|--------|------|------|------| +| `customer_item_code` | 거래처 품번 | text | 거래처에서 사용하는 품번 | +| `customer_item_name` | 거래처 품명 | text | 거래처에서 사용하는 품명 | + +#### 그룹 2: 기간별 단가 설정 (group_period_price) + +| 필드명 | 라벨 | 타입 | 자동 채우기 | 설명 | +|--------|------|------|-------------|------| +| `start_date` | 시작일 | date | - | 단가 적용 시작일 | +| `end_date` | 종료일 | date | - | 단가 적용 종료일 (NULL이면 무기한) | +| `current_unit_price` | 단가 | number | `item_info.standard_price` | 기본 단가 (품목에서 자동 채우기) | +| `currency_code` | 통화 | code/category | - | 통화 코드 (KRW, USD 등) | +| `discount_type` | 할인 방식 | code/category | - | 할인율없음/할인율(%)/할인금액 | +| `discount_value` | 할인값 | number | - | 할인율(5) 또는 할인금액 | +| `rounding_type` | 반올림 방식 | code/category | - | 반올림없음/반올림/절삭/올림 | +| `rounding_unit_value` | 반올림 단위 | code/category | - | 1원/10원/100원/1,000원 | +| `calculated_price` | 최종 단가 | number | - | 계산된 최종 단가 (읽기 전용) | +| `is_base_price` | 기준단가 | checkbox | - | 기준단가 여부 | + +### Step 5: 그룹별 표시 항목 설정 (DisplayItems) + +**그룹 2 (기간별 단가 설정)의 표시 설정:** + +``` +1. [필드] start_date | 라벨: "" | 형식: date | 빈 값: 기본값 (미설정) +2. [텍스트] " ~ " +3. [필드] end_date | 라벨: "" | 형식: date | 빈 값: 기본값 (무기한) +4. [텍스트] " | " +5. [필드] calculated_price | 라벨: "" | 형식: currency | 빈 값: 기본값 (계산 중) +6. [텍스트] " " +7. [필드] currency_code | 라벨: "" | 형식: text | 빈 값: 기본값 (KRW) +8. [조건] is_base_price가 true이면 → [배지] "기준단가" (variant: default) +``` + +**렌더링 예시:** +``` +2024-01-01 ~ 2024-06-30 | 50,000 KRW [기준단가] +2024-07-01 ~ 무기한 | 55,000 KRW +``` + +## 데이터 흐름 + +### 1. 품목 선택 모달 (이전 화면) + +```tsx +// TableList 컴포넌트에서 품목 선택 + +``` + +### 2. 기간별 단가 설정 화면 + +```tsx +// 선택항목 상세입력 컴포넌트가 자동으로 처리 +// 1. URL 파라미터에서 dataSourceId 읽기 +// 2. modalDataStore에서 item_info 데이터 가져오기 +// 3. 사용자가 그룹별로 여러 개의 기간별 단가 입력 +// 4. 저장 버튼 클릭 시 customer_item_mapping 테이블에 저장 +``` + +### 3. 저장 데이터 구조 + +**하나의 품목(item_id = "ITEM001")에 대해 3개의 기간별 단가를 입력한 경우:** + +```sql +-- customer_item_mapping 테이블에 3개의 행으로 저장 +INSERT INTO customer_item_mapping ( + customer_id, item_id, + customer_item_code, customer_item_name, + start_date, end_date, + current_unit_price, currency_code, + discount_type, discount_value, + rounding_type, rounding_unit_value, + calculated_price, is_base_price +) VALUES +-- 첫 번째 기간 (기준단가) +('CUST001', 'ITEM001', + 'CUST-A-001', '실리콘 고무 시트', + '2024-01-01', '2024-06-30', + 50000, 'KRW', + '할인율없음', 0, + '반올림', '100원', + 50000, true), + +-- 두 번째 기간 +('CUST001', 'ITEM001', + 'CUST-A-001', '실리콘 고무 시트', + '2024-07-01', '2024-12-31', + 50000, 'KRW', + '할인율(%)', 5, + '절삭', '1원', + 47500, false), + +-- 세 번째 기간 (무기한) +('CUST001', 'ITEM001', + 'CUST-A-001', '실리콘 고무 시트', + '2025-01-01', NULL, + 50000, 'KRW', + '할인금액', 3000, + '올림', '1000원', + 47000, false); +``` + +## 계산 로직 (선택사항) + +단가 계산을 자동화하려면 프론트엔드에서 `calculated_price`를 자동 계산: + +```typescript +const calculatePrice = ( + basePrice: number, + discountType: string, + discountValue: number, + roundingType: string, + roundingUnit: string +): number => { + let price = basePrice; + + // 1단계: 할인 적용 + if (discountType === "할인율(%)") { + price = price * (1 - discountValue / 100); + } else if (discountType === "할인금액") { + price = price - discountValue; + } + + // 2단계: 반올림 적용 + const unitMap: Record = { + "1원": 1, + "10원": 10, + "100원": 100, + "1,000원": 1000, + }; + + const unit = unitMap[roundingUnit] || 1; + + if (roundingType === "반올림") { + price = Math.round(price / unit) * unit; + } else if (roundingType === "절삭") { + price = Math.floor(price / unit) * unit; + } else if (roundingType === "올림") { + price = Math.ceil(price / unit) * unit; + } + + return price; +}; + +// 필드 변경 시 자동 계산 +useEffect(() => { + const calculatedPrice = calculatePrice( + basePrice, + discountType, + discountValue, + roundingType, + roundingUnit + ); + + // calculated_price 필드 업데이트 + handleFieldChange(itemId, groupId, entryId, "calculated_price", calculatedPrice); +}, [basePrice, discountType, discountValue, roundingType, roundingUnit]); +``` + +## 백엔드 API 구현 (필요시) + +### 기간별 단가 조회 + +```typescript +// GET /api/customer-item/price-periods?customer_id=CUST001&item_id=ITEM001 +router.get("/price-periods", async (req, res) => { + const { customer_id, item_id } = req.query; + const companyCode = req.user!.companyCode; + + const query = ` + SELECT * FROM customer_item_mapping + WHERE customer_id = $1 + AND item_id = $2 + AND company_code = $3 + ORDER BY start_date ASC + `; + + const result = await pool.query(query, [customer_id, item_id, companyCode]); + + return res.json({ success: true, data: result.rows }); +}); +``` + +### 기간별 단가 저장 + +```typescript +// POST /api/customer-item/price-periods +router.post("/price-periods", async (req, res) => { + const { items } = req.body; // 선택항목 상세입력 컴포넌트에서 전달 + const companyCode = req.user!.companyCode; + + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + for (const item of items) { + // item.fieldGroups.group_period_price 배열의 각 항목을 INSERT + const periodPrices = item.fieldGroups.group_period_price || []; + + for (const periodPrice of periodPrices) { + const query = ` + INSERT INTO customer_item_mapping ( + company_code, customer_id, item_id, + customer_item_code, customer_item_name, + start_date, end_date, + current_unit_price, currency_code, + discount_type, discount_value, + rounding_type, rounding_unit_value, + calculated_price, is_base_price + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + `; + + await client.query(query, [ + companyCode, + item.originalData.customer_id, + item.originalData.item_id, + periodPrice.customer_item_code, + periodPrice.customer_item_name, + periodPrice.start_date, + periodPrice.end_date || null, + periodPrice.current_unit_price, + periodPrice.currency_code, + periodPrice.discount_type, + periodPrice.discount_value, + periodPrice.rounding_type, + periodPrice.rounding_unit_value, + periodPrice.calculated_price, + periodPrice.is_base_price + ]); + } + } + + await client.query("COMMIT"); + + return res.json({ success: true, message: "기간별 단가가 저장되었습니다." }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("기간별 단가 저장 실패:", error); + return res.status(500).json({ success: false, error: "저장 실패" }); + } finally { + client.release(); + } +}); +``` + +## 사용 시나리오 예시 + +### 시나리오 1: 거래처별 단가 관리 + +1. 거래처 선택 모달 → 거래처 선택 → 다음 +2. 품목 선택 모달 → 품목 여러 개 선택 → 다음 +3. **기간별 단가 설정 화면** + - 품목1 (실리콘 고무 시트) + - **그룹1 추가**: 거래처 품번: CUST-A-001, 품명: 실리콘 시트 + - **그룹2 추가**: 2024-01-01 ~ 2024-06-30, 50,000원 (기준단가) + - **그룹2 추가**: 2024-07-01 ~ 무기한, 할인율 5% → 47,500원 + - 품목2 (스테인리스 판) + - **그룹1 추가**: 거래처 품번: CUST-A-002, 품명: SUS304 판 + - **그룹2 추가**: 2024-01-01 ~ 무기한, 150,000원 (기준단가) +4. 저장 버튼 클릭 → customer_item_mapping 테이블에 4개 행 저장 + +### 시나리오 2: 단순 단가 입력 + +필드 그룹을 사용하지 않고 단일 입력도 가능: + +``` +그룹 없이 필드 정의: +- customer_item_code +- customer_item_name +- current_unit_price +- currency_code + +→ 각 품목당 1개의 행만 저장 +``` + +## 장점 + +### 1. 범용성 +- 기간별 단가뿐만 아니라 **모든 숫자 계산 시나리오**에 적용 가능 +- 견적서, 발주서, 판매 단가, 구매 단가 등 + +### 2. 유연성 +- 필드 그룹으로 자유롭게 섹션 구성 +- 표시 항목 설정으로 UI 커스터마이징 + +### 3. 데이터 무결성 +- 1:N 관계로 여러 기간별 데이터 관리 +- 기간 중복 체크는 백엔드에서 처리 + +### 4. 사용자 경험 +- 품목별로 여러 개의 기간별 단가를 손쉽게 입력 +- 입력 완료 후 작은 카드로 요약 표시 + +## 다음 단계 + +1. **마이그레이션 실행** (999_add_period_price_columns_to_customer_item_mapping.sql) +2. **화면 편집기에서 설정** (위 Step 1~5 참고) +3. **백엔드 API 구현** (저장/조회 엔드포인트) +4. **계산 로직 추가** (선택사항: 자동 계산) +5. **테스트** (품목 선택 → 기간별 단가 입력 → 저장 → 조회) + +## 참고 자료 + +- 선택항목 상세입력 컴포넌트: `frontend/lib/registry/components/selected-items-detail-input/` +- 타입 정의: `frontend/lib/registry/components/selected-items-detail-input/types.ts` +- 설정 패널: `SelectedItemsDetailInputConfigPanel.tsx` + diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 3cb55fc1..8c922da1 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -15,6 +15,7 @@ import { screenApi } from "@/lib/api/screen"; import { ComponentData } from "@/types/screen"; import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; +import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; interface ScreenModalState { isOpen: boolean; @@ -394,60 +395,62 @@ export const ScreenModal: React.FC = ({ className }) => { ) : screenData ? ( -
- {screenData.components.map((component) => { - // 화면 관리 해상도를 사용하는 경우 offset 조정 불필요 - const offsetX = screenDimensions?.offsetX || 0; - const offsetY = screenDimensions?.offsetY || 0; + +
+ {screenData.components.map((component) => { + // 화면 관리 해상도를 사용하는 경우 offset 조정 불필요 + const offsetX = screenDimensions?.offsetX || 0; + const offsetY = screenDimensions?.offsetY || 0; - // offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시) - const adjustedComponent = (offsetX === 0 && offsetY === 0) ? component : { - ...component, - position: { - ...component.position, - x: parseFloat(component.position?.x?.toString() || "0") - offsetX, - y: parseFloat(component.position?.y?.toString() || "0") - offsetY, - }, - }; + // offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시) + const adjustedComponent = (offsetX === 0 && offsetY === 0) ? component : { + ...component, + position: { + ...component.position, + x: parseFloat(component.position?.x?.toString() || "0") - offsetX, + y: parseFloat(component.position?.y?.toString() || "0") - offsetY, + }, + }; - return ( - { - // console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`); - // console.log("📋 현재 formData:", formData); - setFormData((prev) => { - const newFormData = { - ...prev, - [fieldName]: value, - }; - // console.log("📝 ScreenModal 업데이트된 formData:", newFormData); - return newFormData; - }); - }} - onRefresh={() => { - // 부모 화면의 테이블 새로고침 이벤트 발송 - console.log("🔄 모달에서 부모 화면 테이블 새로고침 이벤트 발송"); - window.dispatchEvent(new CustomEvent("refreshTable")); - }} - screenInfo={{ - id: modalState.screenId!, - tableName: screenData.screenInfo?.tableName, - }} - /> - ); - })} -
+ return ( + { + // console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`); + // console.log("📋 현재 formData:", formData); + setFormData((prev) => { + const newFormData = { + ...prev, + [fieldName]: value, + }; + // console.log("📝 ScreenModal 업데이트된 formData:", newFormData); + return newFormData; + }); + }} + onRefresh={() => { + // 부모 화면의 테이블 새로고침 이벤트 발송 + console.log("🔄 모달에서 부모 화면 테이블 새로고침 이벤트 발송"); + window.dispatchEvent(new CustomEvent("refreshTable")); + }} + screenInfo={{ + id: modalState.screenId!, + tableName: screenData.screenInfo?.tableName, + }} + /> + ); + })} +
+ ) : (

화면 데이터가 없습니다.

diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 5288108f..1ef8fee4 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -8,7 +8,8 @@ import { Switch } from "@/components/ui/switch"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Button } from "@/components/ui/button"; -import { Check, ChevronsUpDown, Search } from "lucide-react"; +import { Card } from "@/components/ui/card"; +import { Check, ChevronsUpDown, Search, Plus, X, ChevronUp, ChevronDown, Type, Database } from "lucide-react"; import { cn } from "@/lib/utils"; import { ComponentData } from "@/types/screen"; import { apiClient } from "@/lib/api/client"; @@ -16,6 +17,15 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel"; import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel"; import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel"; +// 🆕 제목 블록 타입 +interface TitleBlock { + id: string; + type: "text" | "field"; + value: string; // text: 텍스트 내용, field: 컬럼명 + tableName?: string; // field일 때 테이블명 + label?: string; // field일 때 표시용 라벨 +} + interface ButtonConfigPanelProps { component: ComponentData; onUpdateProperty: (path: string, value: any) => void; @@ -64,6 +74,15 @@ export const ButtonConfigPanel: React.FC = ({ const [displayColumnOpen, setDisplayColumnOpen] = useState(false); const [displayColumnSearch, setDisplayColumnSearch] = useState(""); + // 🆕 제목 블록 빌더 상태 + const [titleBlocks, setTitleBlocks] = useState([]); + const [availableTables, setAvailableTables] = useState>([]); // 시스템의 모든 테이블 목록 + const [tableColumnsMap, setTableColumnsMap] = useState>>({}); + const [blockTableSearches, setBlockTableSearches] = useState>({}); // 블록별 테이블 검색어 + const [blockColumnSearches, setBlockColumnSearches] = useState>({}); // 블록별 컬럼 검색어 + const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState>({}); // 블록별 테이블 Popover 열림 상태 + const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState>({}); // 블록별 컬럼 Popover 열림 상태 + // 🎯 플로우 위젯이 화면에 있는지 확인 const hasFlowWidget = useMemo(() => { const found = allComponents.some((comp: any) => { @@ -95,9 +114,150 @@ export const ButtonConfigPanel: React.FC = ({ editModalDescription: String(latestAction.editModalDescription || ""), targetUrl: String(latestAction.targetUrl || ""), }); + + // 🆕 제목 블록 초기화 + if (latestAction.modalTitleBlocks && latestAction.modalTitleBlocks.length > 0) { + setTitleBlocks(latestAction.modalTitleBlocks); + } else { + // 기본값: 빈 배열 + setTitleBlocks([]); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [component.id]); + // 🆕 제목 블록 핸들러 + const addTextBlock = () => { + const newBlock: TitleBlock = { + id: `text-${Date.now()}`, + type: "text", + value: "", + }; + const updatedBlocks = [...titleBlocks, newBlock]; + setTitleBlocks(updatedBlocks); + onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks); + }; + + const addFieldBlock = () => { + const newBlock: TitleBlock = { + id: `field-${Date.now()}`, + type: "field", + value: "", + tableName: "", + label: "", + }; + const updatedBlocks = [...titleBlocks, newBlock]; + setTitleBlocks(updatedBlocks); + onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks); + }; + + const updateBlock = (id: string, updates: Partial) => { + const updatedBlocks = titleBlocks.map((block) => + block.id === id ? { ...block, ...updates } : block + ); + setTitleBlocks(updatedBlocks); + onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks); + }; + + const removeBlock = (id: string) => { + const updatedBlocks = titleBlocks.filter((block) => block.id !== id); + setTitleBlocks(updatedBlocks); + onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks); + }; + + const moveBlockUp = (id: string) => { + const index = titleBlocks.findIndex((b) => b.id === id); + if (index <= 0) return; + const newBlocks = [...titleBlocks]; + [newBlocks[index - 1], newBlocks[index]] = [newBlocks[index], newBlocks[index - 1]]; + setTitleBlocks(newBlocks); + onUpdateProperty("componentConfig.action.modalTitleBlocks", newBlocks); + }; + + const moveBlockDown = (id: string) => { + const index = titleBlocks.findIndex((b) => b.id === id); + if (index < 0 || index >= titleBlocks.length - 1) return; + const newBlocks = [...titleBlocks]; + [newBlocks[index], newBlocks[index + 1]] = [newBlocks[index + 1], newBlocks[index]]; + setTitleBlocks(newBlocks); + onUpdateProperty("componentConfig.action.modalTitleBlocks", newBlocks); + }; + + // 🆕 제목 미리보기 생성 + const generateTitlePreview = (): string => { + if (titleBlocks.length === 0) return "(제목 없음)"; + return titleBlocks + .map((block) => { + if (block.type === "text") { + return block.value || "(텍스트)"; + } else { + return block.label || block.value || "(필드)"; + } + }) + .join(""); + }; + + // 🆕 시스템의 모든 테이블 목록 로드 + useEffect(() => { + const fetchAllTables = async () => { + try { + const response = await apiClient.get("/table-management/tables"); + + if (response.data.success && response.data.data) { + const tables = response.data.data.map((table: any) => ({ + name: table.tableName, + label: table.displayName || table.tableName, + })); + setAvailableTables(tables); + console.log(`✅ 전체 테이블 목록 로드 성공:`, tables.length); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } + }; + + fetchAllTables(); + }, []); + + // 🆕 특정 테이블의 컬럼 로드 + const loadTableColumns = async (tableName: string) => { + if (!tableName || tableColumnsMap[tableName]) return; + + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + console.log(`📥 테이블 ${tableName} 컬럼 응답:`, response.data); + + if (response.data.success) { + // data가 배열인지 확인 + let columnData = response.data.data; + + // data.columns 형태일 수도 있음 + if (!Array.isArray(columnData) && columnData?.columns) { + columnData = columnData.columns; + } + + // data.data 형태일 수도 있음 + if (!Array.isArray(columnData) && columnData?.data) { + columnData = columnData.data; + } + + if (Array.isArray(columnData)) { + const columns = columnData.map((col: any) => { + const name = col.name || col.columnName; + const label = col.displayName || col.label || col.columnLabel || name; + console.log(` - 컬럼: ${name} → "${label}"`); + return { name, label }; + }); + setTableColumnsMap((prev) => ({ ...prev, [tableName]: columns })); + console.log(`✅ 테이블 ${tableName} 컬럼 로드 성공:`, columns.length, "개"); + } else { + console.error("❌ 컬럼 데이터가 배열이 아닙니다:", columnData); + } + } + } catch (error) { + console.error("컬럼 로드 실패:", error); + } + }; + // 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준) useEffect(() => { const fetchScreens = async () => { @@ -431,25 +591,284 @@ export const ButtonConfigPanel: React.FC = ({ }} />

- ✨ 비워두면 같은 화면의 TableList를 자동으로 감지합니다 + ✨ 비워두면 현재 화면의 TableList를 자동으로 감지합니다

- 직접 지정하려면 테이블명을 입력하세요 (예: item_info) + • 자동 감지: 현재 화면의 TableList 선택 데이터
+ • 누적 전달: 이전 모달의 모든 데이터도 자동으로 함께 전달
+ • 다음 화면에서 tableName으로 바로 사용 가능
+ • 수동 설정: 필요시 직접 테이블명 입력 (예: item_info)

-
- - { - const newValue = e.target.value; - setLocalInputs((prev) => ({ ...prev, modalTitle: newValue })); - onUpdateProperty("componentConfig.action.modalTitle", newValue); - }} - /> + {/* 🆕 블록 기반 제목 빌더 */} +
+
+ +
+ + +
+
+ + {/* 블록 목록 */} +
+ {titleBlocks.length === 0 ? ( +
+ 텍스트나 필드를 추가하여 제목을 구성하세요 +
+ ) : ( + titleBlocks.map((block, index) => ( + +
+ {/* 순서 변경 버튼 */} +
+ + +
+ + {/* 블록 타입 표시 */} +
+ {block.type === "text" ? ( + + ) : ( + + )} +
+ + {/* 블록 설정 */} +
+ {block.type === "text" ? ( + // 텍스트 블록 + updateBlock(block.id, { value: e.target.value })} + className="h-7 text-xs" + /> + ) : ( + // 필드 블록 + <> + {/* 테이블 선택 - Combobox */} + { + setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: open })); + }} + > + + + + + + { + setBlockTableSearches((prev) => ({ ...prev, [block.id]: value })); + }} + /> + + 테이블을 찾을 수 없습니다. + + {availableTables + .filter((table) => { + const search = (blockTableSearches[block.id] || "").toLowerCase(); + if (!search) return true; + return ( + table.label.toLowerCase().includes(search) || + table.name.toLowerCase().includes(search) + ); + }) + .map((table) => ( + { + updateBlock(block.id, { tableName: table.name, value: "" }); + loadTableColumns(table.name); + setBlockTableSearches((prev) => ({ ...prev, [block.id]: "" })); + setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: false })); + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + + + {block.tableName && ( + <> + {/* 컬럼 선택 - Combobox (라벨명 표시) */} + { + setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: open })); + }} + > + + + + + + { + setBlockColumnSearches((prev) => ({ ...prev, [block.id]: value })); + }} + /> + + 컬럼을 찾을 수 없습니다. + + {(tableColumnsMap[block.tableName] || []) + .filter((col) => { + const search = (blockColumnSearches[block.id] || "").toLowerCase(); + if (!search) return true; + return ( + col.label.toLowerCase().includes(search) || + col.name.toLowerCase().includes(search) + ); + }) + .map((col) => ( + { + updateBlock(block.id, { + value: col.name, + label: col.label, + }); + setBlockColumnSearches((prev) => ({ ...prev, [block.id]: "" })); + setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: false })); + }} + className="text-xs" + > + + {col.label} + ({col.name}) + + ))} + + + + + + + updateBlock(block.id, { label: e.target.value })} + className="h-7 text-xs" + /> + + )} + + )} +
+ + {/* 삭제 버튼 */} + +
+
+ )) + )} +
+ + {/* 미리보기 */} + {titleBlocks.length > 0 && ( +
+ 미리보기: + {generateTitlePreview()} +
+ )} + +

+ • 텍스트: 고정 텍스트 입력 (예: "품목 상세정보 - ")
+ • 필드: 이전 화면 데이터로 자동 채워짐 (예: 품목명, 규격)
+ • 순서 변경: ↑↓ 버튼으로 자유롭게 배치
+ • 데이터가 없으면 "표시 라벨"이 대신 표시됩니다 +

diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 4bdb1a2e..904fc4be 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -74,6 +74,15 @@ export const SelectedItemsDetailInputComponent: React.FC ({ + table: key, + count: data.length, + })), + }); + const updateItemData = useModalDataStore((state) => state.updateItemData); // 🆕 새로운 데이터 구조: 품목별로 여러 개의 상세 데이터 @@ -138,39 +147,63 @@ export const SelectedItemsDetailInputComponent: React.FC 0) { - const columnMeta = targetTableColumns.find( - (col: any) => (col.columnName || col.column_name) === field.name - ); - if (columnMeta) { - codeCategory = columnMeta.codeCategory || columnMeta.code_category; - console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory); - } - } - - if (!codeCategory) { - console.warn(`⚠️ 필드 "${field.name}"의 codeCategory를 찾을 수 없습니다`); + // 이미 로드된 옵션이면 스킵 + if (newOptions[field.name]) { + console.log(`⏭️ 이미 로드된 옵션 (${field.name})`); continue; } - // 이미 로드된 옵션이면 스킵 - if (newOptions[codeCategory]) continue; - try { - const response = await commonCodeApi.options.getOptions(codeCategory); - if (response.success && response.data) { - newOptions[codeCategory] = response.data.map((opt) => ({ - label: opt.label, - value: opt.value, - })); - console.log(`✅ 코드 옵션 로드 완료: ${codeCategory}`, newOptions[codeCategory]); + // 🆕 category 타입이면 table_column_category_values에서 로드 + if (field.inputType === "category" && targetTable) { + console.log(`🔄 카테고리 옵션 로드 시도 (${targetTable}.${field.name})`); + + const { getCategoryValues } = await import("@/lib/api/tableCategoryValue"); + const response = await getCategoryValues(targetTable, field.name, false); + + console.log(`📥 getCategoryValues 응답:`, response); + + if (response.success && response.data) { + newOptions[field.name] = response.data.map((item: any) => ({ + label: item.value_label || item.valueLabel, + value: item.value_code || item.valueCode, + })); + console.log(`✅ 카테고리 옵션 로드 완료 (${field.name}):`, newOptions[field.name]); + } else { + console.error(`❌ 카테고리 옵션 로드 실패 (${field.name}):`, response.error || "응답 없음"); + } + } else if (field.inputType === "code") { + // code 타입이면 기존대로 code_info에서 로드 + // 이미 codeCategory가 있으면 사용 + let codeCategory = field.codeCategory; + + // 🆕 codeCategory가 없으면 대상 테이블 컬럼에서 찾기 + if (!codeCategory && targetTableColumns.length > 0) { + const columnMeta = targetTableColumns.find( + (col: any) => (col.columnName || col.column_name) === field.name + ); + if (columnMeta) { + codeCategory = columnMeta.codeCategory || columnMeta.code_category; + console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory); + } + } + + if (!codeCategory) { + console.warn(`⚠️ 필드 "${field.name}"의 codeCategory를 찾을 수 없습니다`); + continue; + } + + const response = await commonCodeApi.options.getOptions(codeCategory); + if (response.success && response.data) { + newOptions[field.name] = response.data.map((opt) => ({ + label: opt.label, + value: opt.value, + })); + console.log(`✅ 코드 옵션 로드 완료 (${codeCategory}):`, newOptions[field.name]); + } } } catch (error) { - console.error(`❌ 코드 옵션 로드 실패: ${codeCategory}`, error); + console.error(`❌ 옵션 로드 실패 (${field.name}):`, error); } } @@ -262,6 +295,51 @@ export const SelectedItemsDetailInputComponent: React.FC { + // 자동 계산 설정이 없으면 계산하지 않음 + if (!componentConfig.autoCalculation) return 0; + + const { inputFields, valueMapping } = componentConfig.autoCalculation; + + // 기본 단가 + const basePrice = parseFloat(entry[inputFields.basePrice] || "0"); + if (basePrice === 0) return 0; + + let price = basePrice; + + // 1단계: 할인 적용 + const discountTypeValue = entry[inputFields.discountType]; + const discountValue = parseFloat(entry[inputFields.discountValue] || "0"); + + // 매핑을 통해 실제 연산 타입 결정 + const discountOperation = valueMapping?.discountType?.[discountTypeValue] || "none"; + + if (discountOperation === "rate") { + price = price * (1 - discountValue / 100); + } else if (discountOperation === "amount") { + price = price - discountValue; + } + + // 2단계: 반올림 적용 + const roundingTypeValue = entry[inputFields.roundingType]; + const roundingUnitValue = entry[inputFields.roundingUnit]; + + // 매핑을 통해 실제 연산 타입 결정 + const roundingOperation = valueMapping?.roundingType?.[roundingTypeValue] || "none"; + const unit = valueMapping?.roundingUnit?.[roundingUnitValue] || parseFloat(roundingUnitValue) || 1; + + if (roundingOperation === "round") { + price = Math.round(price / unit) * unit; + } else if (roundingOperation === "floor") { + price = Math.floor(price / unit) * unit; + } else if (roundingOperation === "ceil") { + price = Math.ceil(price / unit) * unit; + } + + return price; + }, [componentConfig.autoCalculation]); + // 🆕 그룹별 필드 변경 핸들러: itemId + groupId + entryId + fieldName const handleFieldChange = useCallback((itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => { setItems((prevItems) => { @@ -274,10 +352,38 @@ export const SelectedItemsDetailInputComponent: React.FC= 0) { // 기존 entry 업데이트 (항상 이 경로로만 진입) const updatedEntries = [...groupEntries]; - updatedEntries[existingEntryIndex] = { + const updatedEntry = { ...updatedEntries[existingEntryIndex], [fieldName]: value, }; + + // 🆕 가격 관련 필드가 변경되면 자동 계산 + if (componentConfig.autoCalculation) { + const { inputFields, targetField } = componentConfig.autoCalculation; + const priceRelatedFields = [ + inputFields.basePrice, + inputFields.discountType, + inputFields.discountValue, + inputFields.roundingType, + inputFields.roundingUnit, + ]; + + if (priceRelatedFields.includes(fieldName)) { + const calculatedPrice = calculatePrice(updatedEntry); + updatedEntry[targetField] = calculatedPrice; + console.log("💰 [자동 계산]", { + basePrice: updatedEntry[inputFields.basePrice], + discountType: updatedEntry[inputFields.discountType], + discountValue: updatedEntry[inputFields.discountValue], + roundingType: updatedEntry[inputFields.roundingType], + roundingUnit: updatedEntry[inputFields.roundingUnit], + calculatedPrice, + targetField, + }); + } + } + + updatedEntries[existingEntryIndex] = updatedEntry; return { ...item, fieldGroups: { @@ -292,7 +398,7 @@ export const SelectedItemsDetailInputComponent: React.FC { @@ -303,7 +409,7 @@ export const SelectedItemsDetailInputComponent: React.FC { const newEntryId = `entry-${Date.now()}`; - // 🔧 미리 빈 entry를 추가하여 리렌더링 방지 + // 🔧 미리 빈 entry를 추가하여 리렌더링 방지 (autoFillFrom 처리) setItems((prevItems) => { return prevItems.map((item) => { if (item.id !== itemId) return item; @@ -311,6 +417,36 @@ export const SelectedItemsDetailInputComponent: React.FC f.groupId === groupId + ); + + groupFields.forEach((field) => { + if (!field.autoFillFrom) return; + + // 데이터 소스 결정 + let sourceData: any = null; + + if (field.autoFillFromTable) { + // 특정 테이블에서 가져오기 + const tableData = dataRegistry[field.autoFillFromTable]; + if (tableData && tableData.length > 0) { + // 첫 번째 항목 사용 (또는 매칭 로직 추가 가능) + sourceData = tableData[0].originalData || tableData[0]; + console.log(`✅ [autoFill] ${field.name} ← ${field.autoFillFrom} (테이블: ${field.autoFillFromTable}):`, sourceData[field.autoFillFrom]); + } + } else { + // 주 데이터 소스 (item.originalData) 사용 + sourceData = item.originalData; + console.log(`✅ [autoFill] ${field.name} ← ${field.autoFillFrom} (주 소스):`, sourceData[field.autoFillFrom]); + } + + if (sourceData && sourceData[field.autoFillFrom] !== undefined) { + newEntry[field.name] = sourceData[field.autoFillFrom]; + } + }); + return { ...item, fieldGroups: { @@ -377,6 +513,9 @@ export const SelectedItemsDetailInputComponent: React.FC { const value = entry[field.name] || field.defaultValue || ""; + // 🆕 계산된 필드는 읽기 전용 (자동 계산 설정 기반) + const isCalculatedField = componentConfig.autoCalculation?.targetField === field.name; + const commonProps = { value: value || "", disabled: componentConfig.disabled || componentConfig.readonly, @@ -399,7 +538,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} maxLength={field.validation?.maxLength} - className="h-8 text-xs sm:h-10 sm:text-sm" + className="h-10 text-sm" /> ); @@ -409,6 +548,30 @@ export const SelectedItemsDetailInputComponent: React.FC + +
+ 자동 계산 +
+
+ ); + } + return ( handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} min={field.validation?.min} max={field.validation?.max} - className="h-8 text-xs sm:h-10 sm:text-sm" + className="h-10 text-sm" /> ); @@ -428,7 +591,14 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} - className="h-8 text-xs sm:h-10 sm:text-sm" + onClick={(e) => { + // 날짜 선택기 강제 열기 + const target = e.target as HTMLInputElement; + if (target && target.showPicker) { + target.showPicker(); + } + }} + className="h-10 text-sm cursor-pointer" /> ); @@ -456,20 +626,16 @@ export const SelectedItemsDetailInputComponent: React.FC { - // 필드명과 매칭되는 카테고리 찾기 (예: currency_code → CURRENCY) - return field.name.toLowerCase().includes(cat.toLowerCase().replace('_', '')); - }); - if (matchedCategory) { - categoryOptions = codeOptions[matchedCategory]; - } } return ( @@ -478,7 +644,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, val)} disabled={componentConfig.disabled || componentConfig.readonly} > - + @@ -769,11 +935,11 @@ export const SelectedItemsDetailInputComponent: React.FC - -
+ +
수정 중
- {groupFields.map((field) => ( -
- - {renderField(field, item.id, group.id, entry.id, entry)} -
- ))} + {/* 🆕 가로 Grid 배치 (2~3열) */} +
+ {groupFields.map((field) => ( +
+ + {renderField(field, item.id, group.id, entry.id, entry)} +
+ ))} +
); diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx index a2b2df51..0f8bca42 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useEffect } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -14,6 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from " import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; +import { getSecondLevelMenus, getCategoryColumns, getCategoryValues } from "@/lib/api/tableCategoryValue"; export interface SelectedItemsDetailInputConfigPanelProps { config: SelectedItemsDetailInputConfig; @@ -47,6 +49,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC(config.fieldGroups || []); + // 🆕 그룹별 펼침/접힘 상태 const [expandedGroups, setExpandedGroups] = useState>({}); @@ -61,6 +64,77 @@ export const SelectedItemsDetailInputConfigPanel: React.FC>([]); + const [categoryColumns, setCategoryColumns] = useState>>({}); + const [categoryValues, setCategoryValues] = useState>>({}); + + // 2레벨 메뉴 목록 로드 + useEffect(() => { + const loadMenus = async () => { + const response = await getSecondLevelMenus(); + if (response.success && response.data) { + setSecondLevelMenus(response.data); + } + }; + loadMenus(); + }, []); + + // 메뉴 선택 시 카테고리 목록 로드 + const handleMenuSelect = async (menuObjid: number, fieldType: "discountType" | "roundingType" | "roundingUnit") => { + if (!config.targetTable) { + console.warn("⚠️ targetTable이 설정되지 않았습니다"); + return; + } + + console.log("🔍 카테고리 목록 로드 시작", { targetTable: config.targetTable, menuObjid, fieldType }); + + const response = await getCategoryColumns(config.targetTable); + + console.log("📥 getCategoryColumns 응답:", response); + + if (response.success && response.data) { + console.log("✅ 카테고리 컬럼 데이터:", response.data); + setCategoryColumns(prev => ({ ...prev, [fieldType]: response.data })); + } else { + console.error("❌ 카테고리 컬럼 로드 실패:", response); + } + + // valueMapping 업데이트 + handleChange("autoCalculation", { + ...config.autoCalculation, + valueMapping: { + ...config.autoCalculation.valueMapping, + _selectedMenus: { + ...(config.autoCalculation.valueMapping as any)?._selectedMenus, + [fieldType]: menuObjid, + }, + }, + }); + }; + + // 카테고리 선택 시 카테고리 값 목록 로드 + const handleCategorySelect = async (columnName: string, menuObjid: number, fieldType: "discountType" | "roundingType" | "roundingUnit") => { + if (!config.targetTable) return; + + const response = await getCategoryValues(config.targetTable, columnName, false, menuObjid); + if (response.success && response.data) { + setCategoryValues(prev => ({ ...prev, [fieldType]: response.data })); + } + + // valueMapping 업데이트 + handleChange("autoCalculation", { + ...config.autoCalculation, + valueMapping: { + ...config.autoCalculation.valueMapping, + _selectedCategories: { + ...(config.autoCalculation.valueMapping as any)?._selectedCategories, + [fieldType]: columnName, + }, + }, + }); + }; + // 🆕 초기 로드 시 screenTableName을 targetTable로 자동 설정 React.useEffect(() => { if (screenTableName && !config.targetTable) { @@ -568,6 +642,85 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
+ {/* 🆕 원본 데이터 자동 채우기 */} +
+ + + {/* 테이블명 입력 */} + updateField(index, { autoFillFromTable: e.target.value })} + placeholder="비워두면 주 데이터 (예: item_price)" + className="h-6 w-full text-[10px] sm:h-7 sm:text-xs" + /> +

+ 다른 테이블에서 가져올 경우 테이블명 입력 +

+ + {/* 필드 선택 */} + + + + + + + + 원본 테이블을 먼저 선택하세요. + + updateField(index, { autoFillFrom: undefined })} + className="text-[10px] sm:text-xs" + > + + 선택 안 함 + + {sourceTableColumns.map((column) => ( + updateField(index, { autoFillFrom: column.columnName })} + className="text-[10px] sm:text-xs" + > + +
+
{column.columnLabel}
+
{column.columnName}
+
+
+ ))} +
+
+
+
+ +

+ {field.autoFillFromTable + ? `"${field.autoFillFromTable}" 테이블에서 자동 채우기` + : "주 데이터 소스에서 자동 채우기 (수정 가능)" + } +

+
+ {/* 🆕 필드 그룹 선택 */} {localFieldGroups.length > 0 && (
@@ -970,6 +1123,478 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
+ {/* 자동 계산 설정 */} +
+
+ + { + if (checked) { + handleChange("autoCalculation", { + targetField: "calculated_price", + inputFields: { + basePrice: "current_unit_price", + discountType: "discount_type", + discountValue: "discount_value", + roundingType: "rounding_type", + roundingUnit: "rounding_unit_value", + }, + calculationType: "price", + }); + } else { + handleChange("autoCalculation", undefined); + } + }} + /> +
+ + {config.autoCalculation && ( +
+
+ + handleChange("autoCalculation", { + ...config.autoCalculation, + targetField: e.target.value, + })} + placeholder="calculated_price" + className="h-7 text-xs" + /> +
+ +
+ + handleChange("autoCalculation", { + ...config.autoCalculation, + inputFields: { + ...config.autoCalculation.inputFields, + basePrice: e.target.value, + }, + })} + placeholder="current_unit_price" + className="h-7 text-xs" + /> +
+ +
+
+ + handleChange("autoCalculation", { + ...config.autoCalculation, + inputFields: { + ...config.autoCalculation.inputFields, + discountType: e.target.value, + }, + })} + placeholder="discount_type" + className="h-7 text-xs" + /> +
+ +
+ + handleChange("autoCalculation", { + ...config.autoCalculation, + inputFields: { + ...config.autoCalculation.inputFields, + discountValue: e.target.value, + }, + })} + placeholder="discount_value" + className="h-7 text-xs" + /> +
+
+ +
+
+ + handleChange("autoCalculation", { + ...config.autoCalculation, + inputFields: { + ...config.autoCalculation.inputFields, + roundingType: e.target.value, + }, + })} + placeholder="rounding_type" + className="h-7 text-xs" + /> +
+ +
+ + handleChange("autoCalculation", { + ...config.autoCalculation, + inputFields: { + ...config.autoCalculation.inputFields, + roundingUnit: e.target.value, + }, + })} + placeholder="rounding_unit_value" + className="h-7 text-xs" + /> +
+
+ +

+ 💡 위 필드명들이 추가 입력 필드에 있어야 자동 계산이 작동합니다 +

+ + {/* 카테고리 값 매핑 */} +
+ + + {/* 할인 방식 매핑 */} + + + + + + {/* 1단계: 메뉴 선택 */} +
+ + +
+ + {/* 2단계: 카테고리 선택 */} + {(config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType && ( +
+ + +
+ )} + + {/* 3단계: 값 매핑 */} + {(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType && ( +
+ + {["할인없음", "할인율(%)", "할인금액"].map((label, idx) => { + const operations = ["none", "rate", "amount"]; + return ( +
+ {label} + + + {operations[idx]} +
+ ); + })} +
+ )} +
+
+ + {/* 반올림 방식 매핑 */} + + + + + + {/* 1단계: 메뉴 선택 */} +
+ + +
+ + {/* 2단계: 카테고리 선택 */} + {(config.autoCalculation.valueMapping as any)?._selectedMenus?.roundingType && ( +
+ + +
+ )} + + {/* 3단계: 값 매핑 */} + {(config.autoCalculation.valueMapping as any)?._selectedCategories?.roundingType && ( +
+ + {["반올림없음", "반올림", "절삭", "올림"].map((label, idx) => { + const operations = ["none", "round", "floor", "ceil"]; + return ( +
+ {label} + + + {operations[idx]} +
+ ); + })} +
+ )} +
+
+ + {/* 반올림 단위 매핑 */} + + + + + + {/* 1단계: 메뉴 선택 */} +
+ + +
+ + {/* 2단계: 카테고리 선택 */} + {(config.autoCalculation.valueMapping as any)?._selectedMenus?.roundingUnit && ( +
+ + +
+ )} + + {/* 3단계: 값 매핑 */} + {(config.autoCalculation.valueMapping as any)?._selectedCategories?.roundingUnit && ( +
+ + {["1원", "10원", "100원", "1,000원"].map((label) => { + const unitValue = label === "1,000원" ? 1000 : parseInt(label); + return ( +
+ {label} + + + {unitValue} +
+ ); + })} +
+ )} +
+
+ +

+ 💡 1단계: 메뉴 선택 → 2단계: 카테고리 선택 → 3단계: 값 매핑 +

+
+
+ )} +
+ {/* 옵션 */}
diff --git a/frontend/lib/registry/components/selected-items-detail-input/types.ts b/frontend/lib/registry/components/selected-items-detail-input/types.ts index 05c11e4a..0e6120c6 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/types.ts +++ b/frontend/lib/registry/components/selected-items-detail-input/types.ts @@ -22,6 +22,10 @@ export interface AdditionalFieldDefinition { placeholder?: string; /** 기본값 */ defaultValue?: any; + /** 🆕 원본 데이터에서 자동으로 값을 가져올 필드명 */ + autoFillFrom?: string; + /** 🆕 자동 채우기할 데이터의 테이블명 (비워두면 주 데이터 소스 사용) */ + autoFillFromTable?: string; /** 선택 옵션 (type이 select일 때) */ options?: Array<{ label: string; value: string }>; /** 필드 너비 (px 또는 %) */ @@ -54,6 +58,39 @@ export interface FieldGroup { displayItems?: DisplayItem[]; } +/** + * 🆕 자동 계산 설정 + */ +export interface AutoCalculationConfig { + /** 계산 대상 필드명 (예: calculated_price) */ + targetField: string; + /** 계산에 사용할 입력 필드들 */ + inputFields: { + basePrice: string; // 기본 단가 필드명 + discountType: string; // 할인 방식 필드명 + discountValue: string; // 할인값 필드명 + roundingType: string; // 반올림 방식 필드명 + roundingUnit: string; // 반올림 단위 필드명 + }; + /** 계산 함수 타입 */ + calculationType: "price" | "custom"; + /** 🆕 카테고리 값 → 연산 매핑 */ + valueMapping?: { + /** 할인 방식 매핑 */ + discountType?: { + [valueCode: string]: "none" | "rate" | "amount"; // 예: { "CATEGORY_544740": "rate" } + }; + /** 반올림 방식 매핑 */ + roundingType?: { + [valueCode: string]: "none" | "round" | "floor" | "ceil"; + }; + /** 반올림 단위 매핑 (숫자로 변환) */ + roundingUnit?: { + [valueCode: string]: number; // 예: { "10": 10, "100": 100 } + }; + }; +} + /** * SelectedItemsDetailInput 컴포넌트 설정 타입 */ @@ -93,6 +130,12 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig { */ targetTable?: string; + /** + * 🆕 자동 계산 설정 + * 특정 필드가 변경되면 다른 필드를 자동으로 계산 + */ + autoCalculation?: AutoCalculationConfig; + /** * 레이아웃 모드 * - grid: 테이블 형식 (기본) diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index b7364a4b..5d2d621f 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -418,8 +418,17 @@ export const SplitPanelLayoutComponent: React.FC setSelectedLeftItem(item); setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 loadRightData(item); + + // 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택) + const leftTableName = componentConfig.leftPanel?.tableName; + if (leftTableName && !isDesignMode) { + import("@/stores/modalDataStore").then(({ useModalDataStore }) => { + useModalDataStore.getState().setData(leftTableName, [item]); + console.log(`✅ 분할 패널 좌측 선택: ${leftTableName}`, item); + }); + } }, - [loadRightData], + [loadRightData, componentConfig.leftPanel?.tableName, isDesignMode], ); // 우측 항목 확장/축소 토글 diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 1d39ce91..615aedf9 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -41,6 +41,13 @@ export interface ButtonActionConfig { // 모달/팝업 관련 modalTitle?: string; + modalTitleBlocks?: Array<{ // 🆕 블록 기반 제목 (우선순위 높음) + id: string; + type: "text" | "field"; + value: string; // type=text: 텍스트 내용, type=field: 컬럼명 + tableName?: string; // type=field일 때 테이블명 + label?: string; // type=field일 때 표시용 라벨 + }>; modalDescription?: string; modalSize?: "sm" | "md" | "lg" | "xl"; popupWidth?: number; @@ -207,6 +214,20 @@ export class ButtonActionExecutor { await new Promise(resolve => setTimeout(resolve, 100)); // 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조) + console.log("🔍 [handleSave] formData 구조 확인:", { + keys: Object.keys(context.formData), + values: Object.entries(context.formData).map(([key, value]) => ({ + key, + isArray: Array.isArray(value), + length: Array.isArray(value) ? value.length : 0, + firstItem: Array.isArray(value) && value.length > 0 ? { + hasOriginalData: !!value[0]?.originalData, + hasFieldGroups: !!value[0]?.fieldGroups, + keys: Object.keys(value[0] || {}) + } : null + })) + }); + const selectedItemsKeys = Object.keys(context.formData).filter(key => { const value = context.formData[key]; return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups; @@ -215,6 +236,8 @@ export class ButtonActionExecutor { if (selectedItemsKeys.length > 0) { console.log("🔄 [handleSave] SelectedItemsDetailInput 배치 저장 감지:", selectedItemsKeys); return await this.handleBatchSave(config, context, selectedItemsKeys); + } else { + console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행"); } // 폼 유효성 검사 @@ -830,11 +853,11 @@ export class ButtonActionExecutor { dataSourceId: config.dataSourceId, }); - // 🆕 1. dataSourceId 자동 결정 + // 🆕 1. 현재 화면의 TableList 또는 SplitPanelLayout 자동 감지 let dataSourceId = config.dataSourceId; - // dataSourceId가 없으면 같은 화면의 TableList 자동 감지 if (!dataSourceId && context.allComponents) { + // TableList 우선 감지 const tableListComponent = context.allComponents.find( (comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName ); @@ -845,6 +868,19 @@ export class ButtonActionExecutor { componentId: tableListComponent.id, tableName: dataSourceId, }); + } else { + // TableList가 없으면 SplitPanelLayout의 좌측 패널 감지 + const splitPanelComponent = context.allComponents.find( + (comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName + ); + + if (splitPanelComponent) { + dataSourceId = splitPanelComponent.componentConfig.leftPanel.tableName; + console.log("✨ 분할 패널 좌측 테이블 자동 감지:", { + componentId: splitPanelComponent.id, + tableName: dataSourceId, + }); + } } } @@ -853,21 +889,30 @@ export class ButtonActionExecutor { dataSourceId = context.tableName || "default"; } - // 2. modalDataStore에서 데이터 확인 + // 🆕 2. modalDataStore에서 현재 선택된 데이터 확인 try { const { useModalDataStore } = await import("@/stores/modalDataStore"); - const modalData = useModalDataStore.getState().dataRegistry[dataSourceId] || []; + const dataRegistry = useModalDataStore.getState().dataRegistry; + + const modalData = dataRegistry[dataSourceId] || []; + + console.log("📊 현재 화면 데이터 확인:", { + dataSourceId, + count: modalData.length, + allKeys: Object.keys(dataRegistry), // 🆕 전체 데이터 키 확인 + }); if (modalData.length === 0) { - console.warn("⚠️ 전달할 데이터가 없습니다:", dataSourceId); + console.warn("⚠️ 선택된 데이터가 없습니다:", dataSourceId); toast.warning("선택된 데이터가 없습니다. 먼저 항목을 선택해주세요."); return false; } - console.log("✅ 전달할 데이터:", { - dataSourceId, - count: modalData.length, - data: modalData, + console.log("✅ 모달 데이터 준비 완료:", { + currentData: { id: dataSourceId, count: modalData.length }, + previousData: Object.entries(dataRegistry) + .filter(([key]) => key !== dataSourceId) + .map(([key, data]: [string, any]) => ({ id: key, count: data.length })), }); } catch (error) { console.error("❌ 데이터 확인 실패:", error); @@ -875,7 +920,79 @@ export class ButtonActionExecutor { return false; } - // 3. 모달 열기 + URL 파라미터로 dataSourceId 전달 + // 6. 동적 모달 제목 생성 + const { useModalDataStore } = await import("@/stores/modalDataStore"); + const dataRegistry = useModalDataStore.getState().dataRegistry; + + let finalTitle = "데이터 입력"; + + // 🆕 블록 기반 제목 (우선순위 1) + if (config.modalTitleBlocks && config.modalTitleBlocks.length > 0) { + const titleParts: string[] = []; + + config.modalTitleBlocks.forEach((block) => { + if (block.type === "text") { + // 텍스트 블록: 그대로 추가 + titleParts.push(block.value); + } else if (block.type === "field") { + // 필드 블록: 데이터에서 값 가져오기 + const tableName = block.tableName; + const columnName = block.value; + + if (tableName && columnName) { + const tableData = dataRegistry[tableName]; + if (tableData && tableData.length > 0) { + const firstItem = tableData[0].originalData || tableData[0]; + const value = firstItem[columnName]; + + if (value !== undefined && value !== null) { + titleParts.push(String(value)); + console.log(`✨ 동적 필드: ${tableName}.${columnName} → ${value}`); + } else { + // 데이터 없으면 라벨 표시 + titleParts.push(block.label || columnName); + } + } else { + // 테이블 데이터 없으면 라벨 표시 + titleParts.push(block.label || columnName); + } + } + } + }); + + finalTitle = titleParts.join(""); + console.log("📋 블록 기반 제목 생성:", finalTitle); + } + // 기존 방식: {tableName.columnName} 패턴 (우선순위 2) + else if (config.modalTitle) { + finalTitle = config.modalTitle; + + if (finalTitle.includes("{")) { + const matches = finalTitle.match(/\{([^}]+)\}/g); + + if (matches) { + matches.forEach((match) => { + const path = match.slice(1, -1); // {item_info.item_name} → item_info.item_name + const [tableName, columnName] = path.split("."); + + if (tableName && columnName) { + const tableData = dataRegistry[tableName]; + if (tableData && tableData.length > 0) { + const firstItem = tableData[0].originalData || tableData[0]; + const value = firstItem[columnName]; + + if (value !== undefined && value !== null) { + finalTitle = finalTitle.replace(match, String(value)); + console.log(`✨ 동적 제목: ${match} → ${value}`); + } + } + } + }); + } + } + } + + // 7. 모달 열기 + URL 파라미터로 dataSourceId 전달 if (config.targetScreenId) { // config에 modalDescription이 있으면 우선 사용 let description = config.modalDescription || ""; @@ -894,10 +1011,10 @@ export class ButtonActionExecutor { const modalEvent = new CustomEvent("openScreenModal", { detail: { screenId: config.targetScreenId, - title: config.modalTitle || "데이터 입력", + title: finalTitle, // 🆕 동적 제목 사용 description: description, size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large - urlParams: { dataSourceId }, // 🆕 URL 파라미터로 dataSourceId 전달 + urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음) }, }); diff --git a/frontend/types/unified-core.ts b/frontend/types/unified-core.ts index cba1c3f7..4da2280a 100644 --- a/frontend/types/unified-core.ts +++ b/frontend/types/unified-core.ts @@ -85,8 +85,7 @@ export type ComponentType = | "area" | "layout" | "flow" - | "component" - | "category-manager"; + | "component"; /** * 기본 위치 정보