feat: 기간별 단가 설정 기능 구현 - 자동 계산 시스템
- 선택항목 상세입력 컴포넌트 확장 - 실시간 가격 계산 기능 추가 (할인율/할인금액, 반올림 방식) - 카테고리 값 기반 연산 매핑 시스템 - 3단계 드릴다운 방식 설정 UI (메뉴 → 카테고리 → 값 매핑) - 설정 가능한 계산 로직 - autoCalculation 설정으로 계산 필드명 동적 지정 - valueMapping으로 카테고리 코드와 연산 타입 매핑 - 할인 방식: none/rate/amount - 반올림 방식: none/round/floor/ceil - 반올림 단위: 1/10/100/1000 - UI 개선 - 입력 필드 가로 배치 (반응형 Grid) - 카테고리 타입 필드 옵션 로딩 개선 - 계산 결과 필드 자동 표시 및 읽기 전용 처리 - 날짜 입력 필드 네이티브 피커 지원 - API 연동 - 2레벨 메뉴 목록 조회 - 메뉴별 카테고리 컬럼 조회 - 카테고리별 값 목록 조회 - 문서화 - 기간별 단가 설정 가이드 작성
This commit is contained in:
parent
967b76591b
commit
e1a5befdf7
|
|
@ -1604,10 +1604,14 @@ export async function toggleLogTable(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회
|
* 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속)
|
||||||
*
|
*
|
||||||
* @route GET /api/table-management/menu/:menuObjid/category-columns
|
* @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(
|
export async function getCategoryColumnsByMenu(
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
@ -1627,16 +1631,100 @@ export async function getCategoryColumnsByMenu(
|
||||||
return;
|
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 { getPool } = await import("../database/db");
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
|
// 1. category_column_mapping 테이블 존재 여부 확인
|
||||||
|
const tableExistsResult = await pool.query(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_name = 'category_column_mapping'
|
||||||
|
) as table_exists
|
||||||
|
`);
|
||||||
|
const mappingTableExists = tableExistsResult.rows[0]?.table_exists === true;
|
||||||
|
|
||||||
|
let columnsResult;
|
||||||
|
|
||||||
|
if (mappingTableExists) {
|
||||||
|
// 🆕 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, menu_name_kor
|
||||||
|
FROM menu_info
|
||||||
|
WHERE objid = $1
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- 부모 메뉴 재귀 조회
|
||||||
|
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,
|
||||||
|
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",
|
||||||
|
COALESCE(
|
||||||
|
tl.table_label,
|
||||||
|
initcap(replace(ttc.table_name, '_', ' '))
|
||||||
|
) AS "tableLabel",
|
||||||
|
ccm.logical_column_name AS "columnName",
|
||||||
|
COALESCE(
|
||||||
|
cl.column_label,
|
||||||
|
initcap(replace(ccm.logical_column_name, '_', ' '))
|
||||||
|
) AS "columnLabel",
|
||||||
|
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
|
||||||
|
AND ccm.physical_column_name = ttc.column_name
|
||||||
|
LEFT JOIN column_labels cl
|
||||||
|
ON ttc.table_name = cl.table_name
|
||||||
|
AND ttc.column_name = cl.column_name
|
||||||
|
LEFT JOIN table_labels tl
|
||||||
|
ON ttc.table_name = tl.table_name
|
||||||
|
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, [companyCode, ancestorMenuObjids]);
|
||||||
|
logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", {
|
||||||
|
rowCount: columnsResult.rows.length,
|
||||||
|
columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회
|
||||||
|
logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
|
||||||
|
|
||||||
|
// 형제 메뉴 조회
|
||||||
|
const { getSiblingMenuObjids } = await import("../services/menuService");
|
||||||
|
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
|
||||||
|
|
||||||
|
// 형제 메뉴들이 사용하는 테이블 조회
|
||||||
const tablesQuery = `
|
const tablesQuery = `
|
||||||
SELECT DISTINCT sd.table_name
|
SELECT DISTINCT sd.table_name
|
||||||
FROM screen_menu_assignments sma
|
FROM screen_menu_assignments sma
|
||||||
|
|
@ -1660,80 +1748,6 @@ export async function getCategoryColumnsByMenu(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. category_column_mapping 테이블 존재 여부 확인
|
|
||||||
const tableExistsResult = await pool.query(`
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_name = 'category_column_mapping'
|
|
||||||
) as table_exists
|
|
||||||
`);
|
|
||||||
const mappingTableExists = tableExistsResult.rows[0]?.table_exists === true;
|
|
||||||
|
|
||||||
let columnsResult;
|
|
||||||
|
|
||||||
if (mappingTableExists) {
|
|
||||||
// 🆕 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
|
|
||||||
FROM menu_info
|
|
||||||
WHERE objid = $1
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
-- 부모 메뉴 재귀 조회
|
|
||||||
SELECT m.objid, m.parent_obj_id, m.menu_type
|
|
||||||
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
|
|
||||||
FROM menu_hierarchy
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]);
|
|
||||||
const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)];
|
|
||||||
|
|
||||||
|
|
||||||
const columnsQuery = `
|
|
||||||
SELECT DISTINCT
|
|
||||||
ttc.table_name AS "tableName",
|
|
||||||
COALESCE(
|
|
||||||
tl.table_label,
|
|
||||||
initcap(replace(ttc.table_name, '_', ' '))
|
|
||||||
) AS "tableLabel",
|
|
||||||
ccm.logical_column_name AS "columnName",
|
|
||||||
COALESCE(
|
|
||||||
cl.column_label,
|
|
||||||
initcap(replace(ccm.logical_column_name, '_', ' '))
|
|
||||||
) AS "columnLabel",
|
|
||||||
ttc.input_type AS "inputType"
|
|
||||||
FROM category_column_mapping ccm
|
|
||||||
INNER JOIN table_type_columns ttc
|
|
||||||
ON ccm.table_name = ttc.table_name
|
|
||||||
AND ccm.physical_column_name = ttc.column_name
|
|
||||||
LEFT JOIN column_labels cl
|
|
||||||
ON ttc.table_name = cl.table_name
|
|
||||||
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)
|
|
||||||
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 });
|
|
||||||
} else {
|
|
||||||
// 🔄 기존 방식: table_type_columns에서 모든 카테고리 컬럼 조회
|
|
||||||
logger.info("🔍 레거시 방식: table_type_columns 기반 카테고리 컬럼 조회", { tableNames, companyCode });
|
|
||||||
|
|
||||||
const columnsQuery = `
|
const columnsQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
ttc.table_name AS "tableName",
|
ttc.table_name AS "tableName",
|
||||||
|
|
|
||||||
|
|
@ -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 컴포넌트에서 품목 선택
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const selectedItems = tableData.filter(item => selectedRowIds.includes(item.id));
|
||||||
|
|
||||||
|
// modalDataStore에 데이터 저장
|
||||||
|
useModalDataStore.getState().setData("item_info", selectedItems);
|
||||||
|
|
||||||
|
// 다음 화면으로 이동 (dataSourceId 전달)
|
||||||
|
router.push("/screen/period-price-settings?dataSourceId=item_info");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<string, number> = {
|
||||||
|
"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`
|
||||||
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { screenApi } from "@/lib/api/screen";
|
||||||
import { ComponentData } from "@/types/screen";
|
import { ComponentData } from "@/types/screen";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||||
|
|
||||||
interface ScreenModalState {
|
interface ScreenModalState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -394,6 +395,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : screenData ? (
|
) : screenData ? (
|
||||||
|
<TableOptionsProvider>
|
||||||
<div
|
<div
|
||||||
className="relative bg-white mx-auto"
|
className="relative bg-white mx-auto"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -448,6 +450,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</TableOptionsProvider>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import { Switch } from "@/components/ui/switch";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { cn } from "@/lib/utils";
|
||||||
import { ComponentData } from "@/types/screen";
|
import { ComponentData } from "@/types/screen";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
@ -16,6 +17,15 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
|
||||||
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
||||||
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
|
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
|
||||||
|
|
||||||
|
// 🆕 제목 블록 타입
|
||||||
|
interface TitleBlock {
|
||||||
|
id: string;
|
||||||
|
type: "text" | "field";
|
||||||
|
value: string; // text: 텍스트 내용, field: 컬럼명
|
||||||
|
tableName?: string; // field일 때 테이블명
|
||||||
|
label?: string; // field일 때 표시용 라벨
|
||||||
|
}
|
||||||
|
|
||||||
interface ButtonConfigPanelProps {
|
interface ButtonConfigPanelProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
onUpdateProperty: (path: string, value: any) => void;
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
|
|
@ -64,6 +74,15 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
|
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
|
||||||
const [displayColumnSearch, setDisplayColumnSearch] = useState("");
|
const [displayColumnSearch, setDisplayColumnSearch] = useState("");
|
||||||
|
|
||||||
|
// 🆕 제목 블록 빌더 상태
|
||||||
|
const [titleBlocks, setTitleBlocks] = useState<TitleBlock[]>([]);
|
||||||
|
const [availableTables, setAvailableTables] = useState<Array<{ name: string; label: string }>>([]); // 시스템의 모든 테이블 목록
|
||||||
|
const [tableColumnsMap, setTableColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
|
||||||
|
const [blockTableSearches, setBlockTableSearches] = useState<Record<string, string>>({}); // 블록별 테이블 검색어
|
||||||
|
const [blockColumnSearches, setBlockColumnSearches] = useState<Record<string, string>>({}); // 블록별 컬럼 검색어
|
||||||
|
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
|
||||||
|
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
|
||||||
|
|
||||||
// 🎯 플로우 위젯이 화면에 있는지 확인
|
// 🎯 플로우 위젯이 화면에 있는지 확인
|
||||||
const hasFlowWidget = useMemo(() => {
|
const hasFlowWidget = useMemo(() => {
|
||||||
const found = allComponents.some((comp: any) => {
|
const found = allComponents.some((comp: any) => {
|
||||||
|
|
@ -95,9 +114,150 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
editModalDescription: String(latestAction.editModalDescription || ""),
|
editModalDescription: String(latestAction.editModalDescription || ""),
|
||||||
targetUrl: String(latestAction.targetUrl || ""),
|
targetUrl: String(latestAction.targetUrl || ""),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🆕 제목 블록 초기화
|
||||||
|
if (latestAction.modalTitleBlocks && latestAction.modalTitleBlocks.length > 0) {
|
||||||
|
setTitleBlocks(latestAction.modalTitleBlocks);
|
||||||
|
} else {
|
||||||
|
// 기본값: 빈 배열
|
||||||
|
setTitleBlocks([]);
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [component.id]);
|
}, [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<TitleBlock>) => {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const fetchScreens = async () => {
|
const fetchScreens = async () => {
|
||||||
|
|
@ -431,25 +591,284 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-primary font-medium">
|
<p className="mt-1 text-xs text-primary font-medium">
|
||||||
✨ 비워두면 같은 화면의 TableList를 자동으로 감지합니다
|
✨ 비워두면 현재 화면의 TableList를 자동으로 감지합니다
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
직접 지정하려면 테이블명을 입력하세요 (예: item_info)
|
• 자동 감지: 현재 화면의 TableList 선택 데이터<br/>
|
||||||
|
• 누적 전달: 이전 모달의 모든 데이터도 자동으로 함께 전달<br/>
|
||||||
|
• 다음 화면에서 tableName으로 바로 사용 가능<br/>
|
||||||
|
• 수동 설정: 필요시 직접 테이블명 입력 (예: item_info)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* 🆕 블록 기반 제목 빌더 */}
|
||||||
<Label htmlFor="modal-title-with-data">모달 제목</Label>
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>모달 제목 구성</Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addTextBlock}
|
||||||
|
className="h-6 text-xs"
|
||||||
|
>
|
||||||
|
<Type className="mr-1 h-3 w-3" />
|
||||||
|
텍스트 추가
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addFieldBlock}
|
||||||
|
className="h-6 text-xs"
|
||||||
|
>
|
||||||
|
<Database className="mr-1 h-3 w-3" />
|
||||||
|
필드 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 블록 목록 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{titleBlocks.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-xs text-muted-foreground border-2 border-dashed rounded">
|
||||||
|
텍스트나 필드를 추가하여 제목을 구성하세요
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
titleBlocks.map((block, index) => (
|
||||||
|
<Card key={block.id} className="p-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{/* 순서 변경 버튼 */}
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => moveBlockUp(block.id)}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="h-5 w-5 p-0"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => moveBlockDown(block.id)}
|
||||||
|
disabled={index === titleBlocks.length - 1}
|
||||||
|
className="h-5 w-5 p-0"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 블록 타입 표시 */}
|
||||||
|
<div className="flex-shrink-0 mt-1">
|
||||||
|
{block.type === "text" ? (
|
||||||
|
<Type className="h-4 w-4 text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<Database className="h-4 w-4 text-green-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 블록 설정 */}
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{block.type === "text" ? (
|
||||||
|
// 텍스트 블록
|
||||||
<Input
|
<Input
|
||||||
id="modal-title-with-data"
|
placeholder="텍스트 입력 (예: 품목 상세정보 - )"
|
||||||
placeholder="예: 상세 정보 입력"
|
value={block.value}
|
||||||
value={localInputs.modalTitle}
|
onChange={(e) => updateBlock(block.id, { value: e.target.value })}
|
||||||
onChange={(e) => {
|
className="h-7 text-xs"
|
||||||
const newValue = e.target.value;
|
/>
|
||||||
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
|
) : (
|
||||||
onUpdateProperty("componentConfig.action.modalTitle", newValue);
|
// 필드 블록
|
||||||
|
<>
|
||||||
|
{/* 테이블 선택 - Combobox */}
|
||||||
|
<Popover
|
||||||
|
open={blockTablePopoverOpen[block.id] || false}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: open }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-7 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{block.tableName
|
||||||
|
? (availableTables.find((t) => t.name === block.tableName)?.label || block.tableName)
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="테이블 검색 (라벨 또는 이름)..."
|
||||||
|
className="h-7 text-xs"
|
||||||
|
value={blockTableSearches[block.id] || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setBlockTableSearches((prev) => ({ ...prev, [block.id]: value }));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{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) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.name}
|
||||||
|
value={table.name}
|
||||||
|
onSelect={() => {
|
||||||
|
updateBlock(block.id, { tableName: table.name, value: "" });
|
||||||
|
loadTableColumns(table.name);
|
||||||
|
setBlockTableSearches((prev) => ({ ...prev, [block.id]: "" }));
|
||||||
|
setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: false }));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
block.tableName === table.name ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{table.label}</span>
|
||||||
|
<span className="ml-2 text-[10px] text-muted-foreground">({table.name})</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{block.tableName && (
|
||||||
|
<>
|
||||||
|
{/* 컬럼 선택 - Combobox (라벨명 표시) */}
|
||||||
|
<Popover
|
||||||
|
open={blockColumnPopoverOpen[block.id] || false}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: open }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-7 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{block.value
|
||||||
|
? (tableColumnsMap[block.tableName]?.find((c) => c.name === block.value)?.label || block.value)
|
||||||
|
: "컬럼 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="컬럼 검색 (라벨 또는 이름)..."
|
||||||
|
className="h-7 text-xs"
|
||||||
|
value={blockColumnSearches[block.id] || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setBlockColumnSearches((prev) => ({ ...prev, [block.id]: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{(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) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.name}
|
||||||
|
value={col.name}
|
||||||
|
onSelect={() => {
|
||||||
|
updateBlock(block.id, {
|
||||||
|
value: col.name,
|
||||||
|
label: col.label,
|
||||||
|
});
|
||||||
|
setBlockColumnSearches((prev) => ({ ...prev, [block.id]: "" }));
|
||||||
|
setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: false }));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
block.value === col.name ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{col.label}</span>
|
||||||
|
<span className="ml-2 text-[10px] text-muted-foreground">({col.name})</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="표시 라벨 (예: 품목명)"
|
||||||
|
value={block.label || ""}
|
||||||
|
onChange={(e) => updateBlock(block.id, { label: e.target.value })}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeBlock(block.id)}
|
||||||
|
className="h-7 w-7 p-0 text-red-500"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 미리보기 */}
|
||||||
|
{titleBlocks.length > 0 && (
|
||||||
|
<div className="mt-2 p-2 bg-muted rounded text-xs">
|
||||||
|
<span className="text-muted-foreground">미리보기: </span>
|
||||||
|
<span className="font-medium">{generateTitlePreview()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
• 텍스트: 고정 텍스트 입력 (예: "품목 상세정보 - ")<br/>
|
||||||
|
• 필드: 이전 화면 데이터로 자동 채워짐 (예: 품목명, 규격)<br/>
|
||||||
|
• 순서 변경: ↑↓ 버튼으로 자유롭게 배치<br/>
|
||||||
|
• 데이터가 없으면 "표시 라벨"이 대신 표시됩니다
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,15 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
[dataRegistry, dataSourceId]
|
[dataRegistry, dataSourceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 전체 dataRegistry를 사용 (모든 누적 데이터에 접근 가능)
|
||||||
|
console.log("📦 [SelectedItemsDetailInput] 사용 가능한 모든 데이터:", {
|
||||||
|
keys: Object.keys(dataRegistry),
|
||||||
|
counts: Object.entries(dataRegistry).map(([key, data]: [string, any]) => ({
|
||||||
|
table: key,
|
||||||
|
count: data.length,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
const updateItemData = useModalDataStore((state) => state.updateItemData);
|
const updateItemData = useModalDataStore((state) => state.updateItemData);
|
||||||
|
|
||||||
// 🆕 새로운 데이터 구조: 품목별로 여러 개의 상세 데이터
|
// 🆕 새로운 데이터 구조: 품목별로 여러 개의 상세 데이터
|
||||||
|
|
@ -138,6 +147,33 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const field of codeFields) {
|
for (const field of codeFields) {
|
||||||
|
// 이미 로드된 옵션이면 스킵
|
||||||
|
if (newOptions[field.name]) {
|
||||||
|
console.log(`⏭️ 이미 로드된 옵션 (${field.name})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 🆕 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가 있으면 사용
|
// 이미 codeCategory가 있으면 사용
|
||||||
let codeCategory = field.codeCategory;
|
let codeCategory = field.codeCategory;
|
||||||
|
|
||||||
|
|
@ -157,20 +193,17 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미 로드된 옵션이면 스킵
|
|
||||||
if (newOptions[codeCategory]) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await commonCodeApi.options.getOptions(codeCategory);
|
const response = await commonCodeApi.options.getOptions(codeCategory);
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
newOptions[codeCategory] = response.data.map((opt) => ({
|
newOptions[field.name] = response.data.map((opt) => ({
|
||||||
label: opt.label,
|
label: opt.label,
|
||||||
value: opt.value,
|
value: opt.value,
|
||||||
}));
|
}));
|
||||||
console.log(`✅ 코드 옵션 로드 완료: ${codeCategory}`, newOptions[codeCategory]);
|
console.log(`✅ 코드 옵션 로드 완료 (${codeCategory}):`, newOptions[field.name]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ 코드 옵션 로드 실패: ${codeCategory}`, error);
|
console.error(`❌ 옵션 로드 실패 (${field.name}):`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -262,6 +295,51 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
onClick?.();
|
onClick?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 실시간 단가 계산 함수 (설정 기반 + 카테고리 매핑)
|
||||||
|
const calculatePrice = useCallback((entry: GroupEntry): number => {
|
||||||
|
// 자동 계산 설정이 없으면 계산하지 않음
|
||||||
|
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
|
// 🆕 그룹별 필드 변경 핸들러: itemId + groupId + entryId + fieldName
|
||||||
const handleFieldChange = useCallback((itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => {
|
const handleFieldChange = useCallback((itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => {
|
||||||
setItems((prevItems) => {
|
setItems((prevItems) => {
|
||||||
|
|
@ -274,10 +352,38 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
if (existingEntryIndex >= 0) {
|
if (existingEntryIndex >= 0) {
|
||||||
// 기존 entry 업데이트 (항상 이 경로로만 진입)
|
// 기존 entry 업데이트 (항상 이 경로로만 진입)
|
||||||
const updatedEntries = [...groupEntries];
|
const updatedEntries = [...groupEntries];
|
||||||
updatedEntries[existingEntryIndex] = {
|
const updatedEntry = {
|
||||||
...updatedEntries[existingEntryIndex],
|
...updatedEntries[existingEntryIndex],
|
||||||
[fieldName]: value,
|
[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 {
|
return {
|
||||||
...item,
|
...item,
|
||||||
fieldGroups: {
|
fieldGroups: {
|
||||||
|
|
@ -292,7 +398,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, []);
|
}, [calculatePrice]);
|
||||||
|
|
||||||
// 🆕 품목 제거 핸들러
|
// 🆕 품목 제거 핸들러
|
||||||
const handleRemoveItem = (itemId: string) => {
|
const handleRemoveItem = (itemId: string) => {
|
||||||
|
|
@ -303,7 +409,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
const handleAddGroupEntry = (itemId: string, groupId: string) => {
|
const handleAddGroupEntry = (itemId: string, groupId: string) => {
|
||||||
const newEntryId = `entry-${Date.now()}`;
|
const newEntryId = `entry-${Date.now()}`;
|
||||||
|
|
||||||
// 🔧 미리 빈 entry를 추가하여 리렌더링 방지
|
// 🔧 미리 빈 entry를 추가하여 리렌더링 방지 (autoFillFrom 처리)
|
||||||
setItems((prevItems) => {
|
setItems((prevItems) => {
|
||||||
return prevItems.map((item) => {
|
return prevItems.map((item) => {
|
||||||
if (item.id !== itemId) return item;
|
if (item.id !== itemId) return item;
|
||||||
|
|
@ -311,6 +417,36 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
const groupEntries = item.fieldGroups[groupId] || [];
|
const groupEntries = item.fieldGroups[groupId] || [];
|
||||||
const newEntry: GroupEntry = { id: newEntryId };
|
const newEntry: GroupEntry = { id: newEntryId };
|
||||||
|
|
||||||
|
// 🆕 autoFillFrom 필드 자동 채우기 (tableName으로 직접 접근)
|
||||||
|
const groupFields = (componentConfig.additionalFields || []).filter(
|
||||||
|
(f) => 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 {
|
return {
|
||||||
...item,
|
...item,
|
||||||
fieldGroups: {
|
fieldGroups: {
|
||||||
|
|
@ -377,6 +513,9 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
const renderField = (field: AdditionalFieldDefinition, itemId: string, groupId: string, entryId: string, entry: GroupEntry) => {
|
const renderField = (field: AdditionalFieldDefinition, itemId: string, groupId: string, entryId: string, entry: GroupEntry) => {
|
||||||
const value = entry[field.name] || field.defaultValue || "";
|
const value = entry[field.name] || field.defaultValue || "";
|
||||||
|
|
||||||
|
// 🆕 계산된 필드는 읽기 전용 (자동 계산 설정 기반)
|
||||||
|
const isCalculatedField = componentConfig.autoCalculation?.targetField === field.name;
|
||||||
|
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
value: value || "",
|
value: value || "",
|
||||||
disabled: componentConfig.disabled || componentConfig.readonly,
|
disabled: componentConfig.disabled || componentConfig.readonly,
|
||||||
|
|
@ -399,7 +538,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
type="text"
|
type="text"
|
||||||
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
|
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
|
||||||
maxLength={field.validation?.maxLength}
|
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<SelectedItemsDetailInpu
|
||||||
case "bigint":
|
case "bigint":
|
||||||
case "decimal":
|
case "decimal":
|
||||||
case "numeric":
|
case "numeric":
|
||||||
|
// 🆕 계산된 단가는 천 단위 구분 및 강조 표시
|
||||||
|
if (isCalculatedField) {
|
||||||
|
const numericValue = parseFloat(value) || 0;
|
||||||
|
const formattedValue = new Intl.NumberFormat("ko-KR").format(numericValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={formattedValue}
|
||||||
|
readOnly
|
||||||
|
disabled
|
||||||
|
className={cn(
|
||||||
|
"h-10 text-sm",
|
||||||
|
"bg-primary/10 border-primary/30 font-semibold text-primary",
|
||||||
|
"cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-primary/70">
|
||||||
|
자동 계산
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
|
|
@ -416,7 +579,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
|
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
|
||||||
min={field.validation?.min}
|
min={field.validation?.min}
|
||||||
max={field.validation?.max}
|
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<SelectedItemsDetailInpu
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
type="date"
|
type="date"
|
||||||
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
|
onChange={(e) => 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<SelectedItemsDetailInpu
|
||||||
// 🆕 추가 inputType들
|
// 🆕 추가 inputType들
|
||||||
case "code":
|
case "code":
|
||||||
case "category":
|
case "category":
|
||||||
// 🆕 codeCategory를 field.codeCategory 또는 codeOptions에서 찾기
|
// 🆕 옵션을 field.name 또는 field.codeCategory 키로 찾기
|
||||||
let categoryOptions = field.options; // 기본값
|
let categoryOptions = field.options; // 기본값
|
||||||
|
|
||||||
if (field.codeCategory && codeOptions[field.codeCategory]) {
|
// 1순위: 필드 이름으로 직접 찾기 (category 타입에서 사용)
|
||||||
categoryOptions = codeOptions[field.codeCategory];
|
if (codeOptions[field.name]) {
|
||||||
} else {
|
categoryOptions = codeOptions[field.name];
|
||||||
// codeCategory가 없으면 모든 codeOptions에서 이 필드에 맞는 옵션 찾기
|
|
||||||
const matchedCategory = Object.keys(codeOptions).find((cat) => {
|
|
||||||
// 필드명과 매칭되는 카테고리 찾기 (예: currency_code → CURRENCY)
|
|
||||||
return field.name.toLowerCase().includes(cat.toLowerCase().replace('_', ''));
|
|
||||||
});
|
|
||||||
if (matchedCategory) {
|
|
||||||
categoryOptions = codeOptions[matchedCategory];
|
|
||||||
}
|
}
|
||||||
|
// 2순위: codeCategory로 찾기 (code 타입에서 사용)
|
||||||
|
else if (field.codeCategory && codeOptions[field.codeCategory]) {
|
||||||
|
categoryOptions = codeOptions[field.codeCategory];
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -478,7 +644,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)}
|
onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)}
|
||||||
disabled={componentConfig.disabled || componentConfig.readonly}
|
disabled={componentConfig.disabled || componentConfig.readonly}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
|
<SelectTrigger size="default" className="w-full">
|
||||||
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -769,11 +935,11 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
const isEditingThisEntry = isEditingThisGroup && editingDetailId === entry.id;
|
const isEditingThisEntry = isEditingThisGroup && editingDetailId === entry.id;
|
||||||
|
|
||||||
if (isEditingThisEntry) {
|
if (isEditingThisEntry) {
|
||||||
// 편집 모드: 입력 필드 표시
|
// 편집 모드: 입력 필드 표시 (가로 배치)
|
||||||
return (
|
return (
|
||||||
<Card key={entry.id} className="border-dashed border-primary">
|
<Card key={entry.id} className="border-dashed border-primary">
|
||||||
<CardContent className="p-3 space-y-2">
|
<CardContent className="p-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs font-medium">수정 중</span>
|
<span className="text-xs font-medium">수정 중</span>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -790,6 +956,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
완료
|
완료
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 🆕 가로 Grid 배치 (2~3열) */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{groupFields.map((field) => (
|
{groupFields.map((field) => (
|
||||||
<div key={field.name} className="space-y-1">
|
<div key={field.name} className="space-y-1">
|
||||||
<label className="text-xs font-medium">
|
<label className="text-xs font-medium">
|
||||||
|
|
@ -799,6 +967,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
{renderField(field, item.id, group.id, entry.id, entry)}
|
{renderField(field, item.id, group.id, entry.id, entry)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useMemo } from "react";
|
import React, { useState, useMemo, useEffect } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getSecondLevelMenus, getCategoryColumns, getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||||
|
|
||||||
export interface SelectedItemsDetailInputConfigPanelProps {
|
export interface SelectedItemsDetailInputConfigPanelProps {
|
||||||
config: SelectedItemsDetailInputConfig;
|
config: SelectedItemsDetailInputConfig;
|
||||||
|
|
@ -47,6 +49,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
// 🆕 필드 그룹 상태
|
// 🆕 필드 그룹 상태
|
||||||
const [localFieldGroups, setLocalFieldGroups] = useState<FieldGroup[]>(config.fieldGroups || []);
|
const [localFieldGroups, setLocalFieldGroups] = useState<FieldGroup[]>(config.fieldGroups || []);
|
||||||
|
|
||||||
|
|
||||||
// 🆕 그룹별 펼침/접힘 상태
|
// 🆕 그룹별 펼침/접힘 상태
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
|
@ -61,6 +64,77 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
const [tableSelectOpen, setTableSelectOpen] = useState(false);
|
const [tableSelectOpen, setTableSelectOpen] = useState(false);
|
||||||
const [tableSearchValue, setTableSearchValue] = useState("");
|
const [tableSearchValue, setTableSearchValue] = useState("");
|
||||||
|
|
||||||
|
// 🆕 카테고리 매핑을 위한 상태
|
||||||
|
const [secondLevelMenus, setSecondLevelMenus] = useState<Array<{ menuObjid: number; menuName: string; parentMenuName: string }>>([]);
|
||||||
|
const [categoryColumns, setCategoryColumns] = useState<Record<string, Array<{ columnName: string; columnLabel: string }>>>({});
|
||||||
|
const [categoryValues, setCategoryValues] = useState<Record<string, Array<{ valueCode: string; valueLabel: string }>>>({});
|
||||||
|
|
||||||
|
// 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로 자동 설정
|
// 🆕 초기 로드 시 screenTableName을 targetTable로 자동 설정
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (screenTableName && !config.targetTable) {
|
if (screenTableName && !config.targetTable) {
|
||||||
|
|
@ -568,6 +642,85 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 원본 데이터 자동 채우기 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] sm:text-xs">자동 채우기 (선택)</Label>
|
||||||
|
|
||||||
|
{/* 테이블명 입력 */}
|
||||||
|
<Input
|
||||||
|
value={field.autoFillFromTable || ""}
|
||||||
|
onChange={(e) => updateField(index, { autoFillFromTable: e.target.value })}
|
||||||
|
placeholder="비워두면 주 데이터 (예: item_price)"
|
||||||
|
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[9px] text-gray-500 sm:text-[10px]">
|
||||||
|
다른 테이블에서 가져올 경우 테이블명 입력
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 필드 선택 */}
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs"
|
||||||
|
>
|
||||||
|
{field.autoFillFrom
|
||||||
|
? sourceTableColumns.find(c => c.columnName === field.autoFillFrom)?.columnLabel || field.autoFillFrom
|
||||||
|
: "필드 선택 안 함"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[180px] p-0 sm:w-[200px]">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" />
|
||||||
|
<CommandEmpty className="text-[10px] sm:text-xs">원본 테이블을 먼저 선택하세요.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[150px] overflow-auto sm:max-h-[200px]">
|
||||||
|
<CommandItem
|
||||||
|
value=""
|
||||||
|
onSelect={() => updateField(index, { autoFillFrom: undefined })}
|
||||||
|
className="text-[10px] sm:text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
|
||||||
|
!field.autoFillFrom ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
선택 안 함
|
||||||
|
</CommandItem>
|
||||||
|
{sourceTableColumns.map((column) => (
|
||||||
|
<CommandItem
|
||||||
|
key={column.columnName}
|
||||||
|
value={column.columnName}
|
||||||
|
onSelect={() => updateField(index, { autoFillFrom: column.columnName })}
|
||||||
|
className="text-[10px] sm:text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
|
||||||
|
field.autoFillFrom === column.columnName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{column.columnLabel}</div>
|
||||||
|
<div className="text-[9px] text-gray-500">{column.columnName}</div>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<p className="text-[9px] text-primary sm:text-[10px]">
|
||||||
|
{field.autoFillFromTable
|
||||||
|
? `"${field.autoFillFromTable}" 테이블에서 자동 채우기`
|
||||||
|
: "주 데이터 소스에서 자동 채우기 (수정 가능)"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 🆕 필드 그룹 선택 */}
|
{/* 🆕 필드 그룹 선택 */}
|
||||||
{localFieldGroups.length > 0 && (
|
{localFieldGroups.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -970,6 +1123,478 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 자동 계산 설정 */}
|
||||||
|
<div className="space-y-3 rounded-lg border p-3 sm:p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold sm:text-sm">자동 계산 설정</Label>
|
||||||
|
<Checkbox
|
||||||
|
id="enable-auto-calc"
|
||||||
|
checked={!!config.autoCalculation}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.autoCalculation && (
|
||||||
|
<div className="space-y-2 border-t pt-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] sm:text-xs">계산 결과 필드</Label>
|
||||||
|
<Input
|
||||||
|
value={config.autoCalculation.targetField || ""}
|
||||||
|
onChange={(e) => handleChange("autoCalculation", {
|
||||||
|
...config.autoCalculation,
|
||||||
|
targetField: e.target.value,
|
||||||
|
})}
|
||||||
|
placeholder="calculated_price"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] sm:text-xs">기준 단가 필드</Label>
|
||||||
|
<Input
|
||||||
|
value={config.autoCalculation.inputFields?.basePrice || ""}
|
||||||
|
onChange={(e) => handleChange("autoCalculation", {
|
||||||
|
...config.autoCalculation,
|
||||||
|
inputFields: {
|
||||||
|
...config.autoCalculation.inputFields,
|
||||||
|
basePrice: e.target.value,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
placeholder="current_unit_price"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] sm:text-xs">할인 방식</Label>
|
||||||
|
<Input
|
||||||
|
value={config.autoCalculation.inputFields?.discountType || ""}
|
||||||
|
onChange={(e) => handleChange("autoCalculation", {
|
||||||
|
...config.autoCalculation,
|
||||||
|
inputFields: {
|
||||||
|
...config.autoCalculation.inputFields,
|
||||||
|
discountType: e.target.value,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
placeholder="discount_type"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] sm:text-xs">할인값</Label>
|
||||||
|
<Input
|
||||||
|
value={config.autoCalculation.inputFields?.discountValue || ""}
|
||||||
|
onChange={(e) => handleChange("autoCalculation", {
|
||||||
|
...config.autoCalculation,
|
||||||
|
inputFields: {
|
||||||
|
...config.autoCalculation.inputFields,
|
||||||
|
discountValue: e.target.value,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
placeholder="discount_value"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] sm:text-xs">반올림 방식</Label>
|
||||||
|
<Input
|
||||||
|
value={config.autoCalculation.inputFields?.roundingType || ""}
|
||||||
|
onChange={(e) => handleChange("autoCalculation", {
|
||||||
|
...config.autoCalculation,
|
||||||
|
inputFields: {
|
||||||
|
...config.autoCalculation.inputFields,
|
||||||
|
roundingType: e.target.value,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
placeholder="rounding_type"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] sm:text-xs">반올림 단위</Label>
|
||||||
|
<Input
|
||||||
|
value={config.autoCalculation.inputFields?.roundingUnit || ""}
|
||||||
|
onChange={(e) => handleChange("autoCalculation", {
|
||||||
|
...config.autoCalculation,
|
||||||
|
inputFields: {
|
||||||
|
...config.autoCalculation.inputFields,
|
||||||
|
roundingUnit: e.target.value,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
placeholder="rounding_unit_value"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[9px] text-amber-600 sm:text-[10px]">
|
||||||
|
💡 위 필드명들이 추가 입력 필드에 있어야 자동 계산이 작동합니다
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 카테고리 값 매핑 */}
|
||||||
|
<div className="space-y-3 border-t pt-3 mt-3">
|
||||||
|
<Label className="text-[10px] font-semibold sm:text-xs">카테고리 값 매핑</Label>
|
||||||
|
|
||||||
|
{/* 할인 방식 매핑 */}
|
||||||
|
<Collapsible>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex w-full items-center justify-between p-2 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium">할인 방식 연산 매핑</span>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="space-y-2 pt-2">
|
||||||
|
{/* 1단계: 메뉴 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[9px] sm:text-[10px]">1단계: 메뉴 선택</Label>
|
||||||
|
<Select
|
||||||
|
value={String((config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType || "")}
|
||||||
|
onValueChange={(value) => handleMenuSelect(Number(value), "discountType")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="2레벨 메뉴 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{secondLevelMenus.map((menu) => (
|
||||||
|
<SelectItem key={menu.menuObjid} value={String(menu.menuObjid)}>
|
||||||
|
{menu.parentMenuName} > {menu.menuName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2단계: 카테고리 선택 */}
|
||||||
|
{(config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[9px] sm:text-[10px]">2단계: 카테고리 선택</Label>
|
||||||
|
<Select
|
||||||
|
value={(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType || ""}
|
||||||
|
onValueChange={(value) => handleCategorySelect(
|
||||||
|
value,
|
||||||
|
(config.autoCalculation.valueMapping as any)._selectedMenus.discountType,
|
||||||
|
"discountType"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="카테고리 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryColumns.discountType || []).map((col: any) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3단계: 값 매핑 */}
|
||||||
|
{(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[9px] sm:text-[10px]">3단계: 카테고리 값 → 연산 매핑</Label>
|
||||||
|
{["할인없음", "할인율(%)", "할인금액"].map((label, idx) => {
|
||||||
|
const operations = ["none", "rate", "amount"];
|
||||||
|
return (
|
||||||
|
<div key={label} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs w-20">{label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">→</span>
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
Object.entries(config.autoCalculation.valueMapping?.discountType || {})
|
||||||
|
.find(([_, op]) => op === operations[idx])?.[0] || ""
|
||||||
|
}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newMapping = { ...config.autoCalculation.valueMapping?.discountType };
|
||||||
|
Object.keys(newMapping).forEach(key => {
|
||||||
|
if (newMapping[key] === operations[idx]) delete newMapping[key];
|
||||||
|
});
|
||||||
|
if (value) {
|
||||||
|
newMapping[value] = operations[idx];
|
||||||
|
}
|
||||||
|
handleChange("autoCalculation", {
|
||||||
|
...config.autoCalculation,
|
||||||
|
valueMapping: {
|
||||||
|
...config.autoCalculation.valueMapping,
|
||||||
|
discountType: newMapping,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="값 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryValues.discountType || []).map((val: any) => (
|
||||||
|
<SelectItem key={val.valueCode} value={val.valueCode}>
|
||||||
|
{val.valueLabel}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-[10px] text-muted-foreground w-12">{operations[idx]}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* 반올림 방식 매핑 */}
|
||||||
|
<Collapsible>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex w-full items-center justify-between p-2 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium">반올림 방식 연산 매핑</span>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="space-y-2 pt-2">
|
||||||
|
{/* 1단계: 메뉴 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[9px] sm:text-[10px]">1단계: 메뉴 선택</Label>
|
||||||
|
<Select
|
||||||
|
value={String((config.autoCalculation.valueMapping as any)?._selectedMenus?.roundingType || "")}
|
||||||
|
onValueChange={(value) => handleMenuSelect(Number(value), "roundingType")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="2레벨 메뉴 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{secondLevelMenus.map((menu) => (
|
||||||
|
<SelectItem key={menu.menuObjid} value={String(menu.menuObjid)}>
|
||||||
|
{menu.parentMenuName} > {menu.menuName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2단계: 카테고리 선택 */}
|
||||||
|
{(config.autoCalculation.valueMapping as any)?._selectedMenus?.roundingType && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[9px] sm:text-[10px]">2단계: 카테고리 선택</Label>
|
||||||
|
<Select
|
||||||
|
value={(config.autoCalculation.valueMapping as any)?._selectedCategories?.roundingType || ""}
|
||||||
|
onValueChange={(value) => handleCategorySelect(
|
||||||
|
value,
|
||||||
|
(config.autoCalculation.valueMapping as any)._selectedMenus.roundingType,
|
||||||
|
"roundingType"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="카테고리 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryColumns.roundingType || []).map((col: any) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3단계: 값 매핑 */}
|
||||||
|
{(config.autoCalculation.valueMapping as any)?._selectedCategories?.roundingType && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[9px] sm:text-[10px]">3단계: 카테고리 값 → 연산 매핑</Label>
|
||||||
|
{["반올림없음", "반올림", "절삭", "올림"].map((label, idx) => {
|
||||||
|
const operations = ["none", "round", "floor", "ceil"];
|
||||||
|
return (
|
||||||
|
<div key={label} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs w-20">{label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">→</span>
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
Object.entries(config.autoCalculation.valueMapping?.roundingType || {})
|
||||||
|
.find(([_, op]) => op === operations[idx])?.[0] || ""
|
||||||
|
}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newMapping = { ...config.autoCalculation.valueMapping?.roundingType };
|
||||||
|
Object.keys(newMapping).forEach(key => {
|
||||||
|
if (newMapping[key] === operations[idx]) delete newMapping[key];
|
||||||
|
});
|
||||||
|
if (value) {
|
||||||
|
newMapping[value] = operations[idx];
|
||||||
|
}
|
||||||
|
handleChange("autoCalculation", {
|
||||||
|
...config.autoCalculation,
|
||||||
|
valueMapping: {
|
||||||
|
...config.autoCalculation.valueMapping,
|
||||||
|
roundingType: newMapping,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="값 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryValues.roundingType || []).map((val: any) => (
|
||||||
|
<SelectItem key={val.valueCode} value={val.valueCode}>
|
||||||
|
{val.valueLabel}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-[10px] text-muted-foreground w-12">{operations[idx]}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* 반올림 단위 매핑 */}
|
||||||
|
<Collapsible>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex w-full items-center justify-between p-2 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium">반올림 단위 값 매핑</span>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="space-y-2 pt-2">
|
||||||
|
{/* 1단계: 메뉴 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[9px] sm:text-[10px]">1단계: 메뉴 선택</Label>
|
||||||
|
<Select
|
||||||
|
value={String((config.autoCalculation.valueMapping as any)?._selectedMenus?.roundingUnit || "")}
|
||||||
|
onValueChange={(value) => handleMenuSelect(Number(value), "roundingUnit")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="2레벨 메뉴 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{secondLevelMenus.map((menu) => (
|
||||||
|
<SelectItem key={menu.menuObjid} value={String(menu.menuObjid)}>
|
||||||
|
{menu.parentMenuName} > {menu.menuName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2단계: 카테고리 선택 */}
|
||||||
|
{(config.autoCalculation.valueMapping as any)?._selectedMenus?.roundingUnit && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[9px] sm:text-[10px]">2단계: 카테고리 선택</Label>
|
||||||
|
<Select
|
||||||
|
value={(config.autoCalculation.valueMapping as any)?._selectedCategories?.roundingUnit || ""}
|
||||||
|
onValueChange={(value) => handleCategorySelect(
|
||||||
|
value,
|
||||||
|
(config.autoCalculation.valueMapping as any)._selectedMenus.roundingUnit,
|
||||||
|
"roundingUnit"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="카테고리 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryColumns.roundingUnit || []).map((col: any) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3단계: 값 매핑 */}
|
||||||
|
{(config.autoCalculation.valueMapping as any)?._selectedCategories?.roundingUnit && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[9px] sm:text-[10px]">3단계: 카테고리 값 → 단위 값 매핑</Label>
|
||||||
|
{["1원", "10원", "100원", "1,000원"].map((label) => {
|
||||||
|
const unitValue = label === "1,000원" ? 1000 : parseInt(label);
|
||||||
|
return (
|
||||||
|
<div key={label} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs w-20">{label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">→</span>
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
Object.entries(config.autoCalculation.valueMapping?.roundingUnit || {})
|
||||||
|
.find(([_, val]) => val === unitValue)?.[0] || ""
|
||||||
|
}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newMapping = { ...config.autoCalculation.valueMapping?.roundingUnit };
|
||||||
|
Object.keys(newMapping).forEach(key => {
|
||||||
|
if (newMapping[key] === unitValue) delete newMapping[key];
|
||||||
|
});
|
||||||
|
if (value) {
|
||||||
|
newMapping[value] = unitValue;
|
||||||
|
}
|
||||||
|
handleChange("autoCalculation", {
|
||||||
|
...config.autoCalculation,
|
||||||
|
valueMapping: {
|
||||||
|
...config.autoCalculation.valueMapping,
|
||||||
|
roundingUnit: newMapping,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="값 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryValues.roundingUnit || []).map((val: any) => (
|
||||||
|
<SelectItem key={val.valueCode} value={val.valueCode}>
|
||||||
|
{val.valueLabel}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-[10px] text-muted-foreground w-12">{unitValue}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<p className="text-[9px] text-muted-foreground sm:text-[10px]">
|
||||||
|
💡 1단계: 메뉴 선택 → 2단계: 카테고리 선택 → 3단계: 값 매핑
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 옵션 */}
|
{/* 옵션 */}
|
||||||
<div className="space-y-2 rounded-lg border p-3 sm:p-4">
|
<div className="space-y-2 rounded-lg border p-3 sm:p-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ export interface AdditionalFieldDefinition {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/** 기본값 */
|
/** 기본값 */
|
||||||
defaultValue?: any;
|
defaultValue?: any;
|
||||||
|
/** 🆕 원본 데이터에서 자동으로 값을 가져올 필드명 */
|
||||||
|
autoFillFrom?: string;
|
||||||
|
/** 🆕 자동 채우기할 데이터의 테이블명 (비워두면 주 데이터 소스 사용) */
|
||||||
|
autoFillFromTable?: string;
|
||||||
/** 선택 옵션 (type이 select일 때) */
|
/** 선택 옵션 (type이 select일 때) */
|
||||||
options?: Array<{ label: string; value: string }>;
|
options?: Array<{ label: string; value: string }>;
|
||||||
/** 필드 너비 (px 또는 %) */
|
/** 필드 너비 (px 또는 %) */
|
||||||
|
|
@ -54,6 +58,39 @@ export interface FieldGroup {
|
||||||
displayItems?: DisplayItem[];
|
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 컴포넌트 설정 타입
|
* SelectedItemsDetailInput 컴포넌트 설정 타입
|
||||||
*/
|
*/
|
||||||
|
|
@ -93,6 +130,12 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
||||||
*/
|
*/
|
||||||
targetTable?: string;
|
targetTable?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 자동 계산 설정
|
||||||
|
* 특정 필드가 변경되면 다른 필드를 자동으로 계산
|
||||||
|
*/
|
||||||
|
autoCalculation?: AutoCalculationConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 레이아웃 모드
|
* 레이아웃 모드
|
||||||
* - grid: 테이블 형식 (기본)
|
* - grid: 테이블 형식 (기본)
|
||||||
|
|
|
||||||
|
|
@ -418,8 +418,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
setSelectedLeftItem(item);
|
setSelectedLeftItem(item);
|
||||||
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
||||||
loadRightData(item);
|
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],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 우측 항목 확장/축소 토글
|
// 우측 항목 확장/축소 토글
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,13 @@ export interface ButtonActionConfig {
|
||||||
|
|
||||||
// 모달/팝업 관련
|
// 모달/팝업 관련
|
||||||
modalTitle?: string;
|
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;
|
modalDescription?: string;
|
||||||
modalSize?: "sm" | "md" | "lg" | "xl";
|
modalSize?: "sm" | "md" | "lg" | "xl";
|
||||||
popupWidth?: number;
|
popupWidth?: number;
|
||||||
|
|
@ -207,6 +214,20 @@ export class ButtonActionExecutor {
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조)
|
// 🆕 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 selectedItemsKeys = Object.keys(context.formData).filter(key => {
|
||||||
const value = context.formData[key];
|
const value = context.formData[key];
|
||||||
return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups;
|
return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups;
|
||||||
|
|
@ -215,6 +236,8 @@ export class ButtonActionExecutor {
|
||||||
if (selectedItemsKeys.length > 0) {
|
if (selectedItemsKeys.length > 0) {
|
||||||
console.log("🔄 [handleSave] SelectedItemsDetailInput 배치 저장 감지:", selectedItemsKeys);
|
console.log("🔄 [handleSave] SelectedItemsDetailInput 배치 저장 감지:", selectedItemsKeys);
|
||||||
return await this.handleBatchSave(config, context, selectedItemsKeys);
|
return await this.handleBatchSave(config, context, selectedItemsKeys);
|
||||||
|
} else {
|
||||||
|
console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 폼 유효성 검사
|
// 폼 유효성 검사
|
||||||
|
|
@ -830,11 +853,11 @@ export class ButtonActionExecutor {
|
||||||
dataSourceId: config.dataSourceId,
|
dataSourceId: config.dataSourceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 1. dataSourceId 자동 결정
|
// 🆕 1. 현재 화면의 TableList 또는 SplitPanelLayout 자동 감지
|
||||||
let dataSourceId = config.dataSourceId;
|
let dataSourceId = config.dataSourceId;
|
||||||
|
|
||||||
// dataSourceId가 없으면 같은 화면의 TableList 자동 감지
|
|
||||||
if (!dataSourceId && context.allComponents) {
|
if (!dataSourceId && context.allComponents) {
|
||||||
|
// TableList 우선 감지
|
||||||
const tableListComponent = context.allComponents.find(
|
const tableListComponent = context.allComponents.find(
|
||||||
(comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName
|
(comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName
|
||||||
);
|
);
|
||||||
|
|
@ -845,6 +868,19 @@ export class ButtonActionExecutor {
|
||||||
componentId: tableListComponent.id,
|
componentId: tableListComponent.id,
|
||||||
tableName: dataSourceId,
|
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";
|
dataSourceId = context.tableName || "default";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. modalDataStore에서 데이터 확인
|
// 🆕 2. modalDataStore에서 현재 선택된 데이터 확인
|
||||||
try {
|
try {
|
||||||
const { useModalDataStore } = await import("@/stores/modalDataStore");
|
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) {
|
if (modalData.length === 0) {
|
||||||
console.warn("⚠️ 전달할 데이터가 없습니다:", dataSourceId);
|
console.warn("⚠️ 선택된 데이터가 없습니다:", dataSourceId);
|
||||||
toast.warning("선택된 데이터가 없습니다. 먼저 항목을 선택해주세요.");
|
toast.warning("선택된 데이터가 없습니다. 먼저 항목을 선택해주세요.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ 전달할 데이터:", {
|
console.log("✅ 모달 데이터 준비 완료:", {
|
||||||
dataSourceId,
|
currentData: { id: dataSourceId, count: modalData.length },
|
||||||
count: modalData.length,
|
previousData: Object.entries(dataRegistry)
|
||||||
data: modalData,
|
.filter(([key]) => key !== dataSourceId)
|
||||||
|
.map(([key, data]: [string, any]) => ({ id: key, count: data.length })),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 데이터 확인 실패:", error);
|
console.error("❌ 데이터 확인 실패:", error);
|
||||||
|
|
@ -875,7 +920,79 @@ export class ButtonActionExecutor {
|
||||||
return false;
|
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) {
|
if (config.targetScreenId) {
|
||||||
// config에 modalDescription이 있으면 우선 사용
|
// config에 modalDescription이 있으면 우선 사용
|
||||||
let description = config.modalDescription || "";
|
let description = config.modalDescription || "";
|
||||||
|
|
@ -894,10 +1011,10 @@ export class ButtonActionExecutor {
|
||||||
const modalEvent = new CustomEvent("openScreenModal", {
|
const modalEvent = new CustomEvent("openScreenModal", {
|
||||||
detail: {
|
detail: {
|
||||||
screenId: config.targetScreenId,
|
screenId: config.targetScreenId,
|
||||||
title: config.modalTitle || "데이터 입력",
|
title: finalTitle, // 🆕 동적 제목 사용
|
||||||
description: description,
|
description: description,
|
||||||
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
|
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
|
||||||
urlParams: { dataSourceId }, // 🆕 URL 파라미터로 dataSourceId 전달
|
urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,7 @@ export type ComponentType =
|
||||||
| "area"
|
| "area"
|
||||||
| "layout"
|
| "layout"
|
||||||
| "flow"
|
| "flow"
|
||||||
| "component"
|
| "component";
|
||||||
| "category-manager";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기본 위치 정보
|
* 기본 위치 정보
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue