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:
kjs 2025-11-18 16:12:47 +09:00
parent 967b76591b
commit e1a5befdf7
10 changed files with 1966 additions and 186 deletions

View File

@ -1604,10 +1604,14 @@ export async function toggleLogTable(
}
/**
*
* ( )
*
* @route GET /api/table-management/menu/:menuObjid/category-columns
* @description input_type='category'
* @description category_column_mapping의
*
* :
* - 2 "고객사관리" discount_type, rounding_type
* - 3 "고객등록", "고객조회" ()
*/
export async function getCategoryColumnsByMenu(
req: AuthenticatedRequest,
@ -1627,16 +1631,100 @@ export async function getCategoryColumnsByMenu(
return;
}
// 1. 형제 메뉴 조회
const { getSiblingMenuObjids } = await import("../services/menuService");
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
logger.info("✅ 형제 메뉴 조회 완료", { siblingObjids });
// 2. 형제 메뉴들이 사용하는 테이블 조회
const { getPool } = await import("../database/db");
const pool = getPool();
// 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 = `
SELECT DISTINCT sd.table_name
FROM screen_menu_assignments sma
@ -1660,80 +1748,6 @@ export async function getCategoryColumnsByMenu(
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 = `
SELECT
ttc.table_name AS "tableName",

View File

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

View File

@ -15,6 +15,7 @@ import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/types/screen";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
interface ScreenModalState {
isOpen: boolean;
@ -394,6 +395,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div>
</div>
) : screenData ? (
<TableOptionsProvider>
<div
className="relative bg-white mx-auto"
style={{
@ -448,6 +450,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
);
})}
</div>
</TableOptionsProvider>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p>

View File

@ -8,7 +8,8 @@ import { Switch } from "@/components/ui/switch";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown, Search } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Check, ChevronsUpDown, Search, Plus, X, ChevronUp, ChevronDown, Type, Database } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData } from "@/types/screen";
import { apiClient } from "@/lib/api/client";
@ -16,6 +17,15 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
// 🆕 제목 블록 타입
interface TitleBlock {
id: string;
type: "text" | "field";
value: string; // text: 텍스트 내용, field: 컬럼명
tableName?: string; // field일 때 테이블명
label?: string; // field일 때 표시용 라벨
}
interface ButtonConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
@ -64,6 +74,15 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
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 found = allComponents.some((comp: any) => {
@ -95,9 +114,150 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
editModalDescription: String(latestAction.editModalDescription || ""),
targetUrl: String(latestAction.targetUrl || ""),
});
// 🆕 제목 블록 초기화
if (latestAction.modalTitleBlocks && latestAction.modalTitleBlocks.length > 0) {
setTitleBlocks(latestAction.modalTitleBlocks);
} else {
// 기본값: 빈 배열
setTitleBlocks([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [component.id]);
// 🆕 제목 블록 핸들러
const addTextBlock = () => {
const newBlock: TitleBlock = {
id: `text-${Date.now()}`,
type: "text",
value: "",
};
const updatedBlocks = [...titleBlocks, newBlock];
setTitleBlocks(updatedBlocks);
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
};
const addFieldBlock = () => {
const newBlock: TitleBlock = {
id: `field-${Date.now()}`,
type: "field",
value: "",
tableName: "",
label: "",
};
const updatedBlocks = [...titleBlocks, newBlock];
setTitleBlocks(updatedBlocks);
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
};
const updateBlock = (id: string, updates: Partial<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(() => {
const fetchScreens = async () => {
@ -431,25 +591,284 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
}}
/>
<p className="mt-1 text-xs text-primary font-medium">
TableList를
TableList를
</p>
<p className="mt-1 text-xs text-muted-foreground">
(: item_info)
감지: 현재 TableList <br/>
전달: 이전 <br/>
tableName으로 <br/>
설정: 필요시 (: item_info)
</p>
</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
id="modal-title-with-data"
placeholder="예: 상세 정보 입력"
value={localInputs.modalTitle}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
onUpdateProperty("componentConfig.action.modalTitle", newValue);
placeholder="텍스트 입력 (예: 품목 상세정보 - )"
value={block.value}
onChange={(e) => updateBlock(block.id, { value: e.target.value })}
className="h-7 text-xs"
/>
) : (
// 필드 블록
<>
{/* 테이블 선택 - 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>

View File

@ -74,6 +74,15 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
[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);
// 🆕 새로운 데이터 구조: 품목별로 여러 개의 상세 데이터
@ -138,6 +147,33 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}
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가 있으면 사용
let codeCategory = field.codeCategory;
@ -157,20 +193,17 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
continue;
}
// 이미 로드된 옵션이면 스킵
if (newOptions[codeCategory]) continue;
try {
const response = await commonCodeApi.options.getOptions(codeCategory);
if (response.success && response.data) {
newOptions[codeCategory] = response.data.map((opt) => ({
newOptions[field.name] = response.data.map((opt) => ({
label: opt.label,
value: opt.value,
}));
console.log(`✅ 코드 옵션 로드 완료: ${codeCategory}`, newOptions[codeCategory]);
console.log(`✅ 코드 옵션 로드 완료 (${codeCategory}):`, newOptions[field.name]);
}
}
} catch (error) {
console.error(`코드 옵션 로드 실패: ${codeCategory}`, error);
console.error(`옵션 로드 실패 (${field.name}):`, error);
}
}
@ -262,6 +295,51 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
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
const handleFieldChange = useCallback((itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => {
setItems((prevItems) => {
@ -274,10 +352,38 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (existingEntryIndex >= 0) {
// 기존 entry 업데이트 (항상 이 경로로만 진입)
const updatedEntries = [...groupEntries];
updatedEntries[existingEntryIndex] = {
const updatedEntry = {
...updatedEntries[existingEntryIndex],
[fieldName]: value,
};
// 🆕 가격 관련 필드가 변경되면 자동 계산
if (componentConfig.autoCalculation) {
const { inputFields, targetField } = componentConfig.autoCalculation;
const priceRelatedFields = [
inputFields.basePrice,
inputFields.discountType,
inputFields.discountValue,
inputFields.roundingType,
inputFields.roundingUnit,
];
if (priceRelatedFields.includes(fieldName)) {
const calculatedPrice = calculatePrice(updatedEntry);
updatedEntry[targetField] = calculatedPrice;
console.log("💰 [자동 계산]", {
basePrice: updatedEntry[inputFields.basePrice],
discountType: updatedEntry[inputFields.discountType],
discountValue: updatedEntry[inputFields.discountValue],
roundingType: updatedEntry[inputFields.roundingType],
roundingUnit: updatedEntry[inputFields.roundingUnit],
calculatedPrice,
targetField,
});
}
}
updatedEntries[existingEntryIndex] = updatedEntry;
return {
...item,
fieldGroups: {
@ -292,7 +398,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}
});
});
}, []);
}, [calculatePrice]);
// 🆕 품목 제거 핸들러
const handleRemoveItem = (itemId: string) => {
@ -303,7 +409,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const handleAddGroupEntry = (itemId: string, groupId: string) => {
const newEntryId = `entry-${Date.now()}`;
// 🔧 미리 빈 entry를 추가하여 리렌더링 방지
// 🔧 미리 빈 entry를 추가하여 리렌더링 방지 (autoFillFrom 처리)
setItems((prevItems) => {
return prevItems.map((item) => {
if (item.id !== itemId) return item;
@ -311,6 +417,36 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const groupEntries = item.fieldGroups[groupId] || [];
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 {
...item,
fieldGroups: {
@ -377,6 +513,9 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const renderField = (field: AdditionalFieldDefinition, itemId: string, groupId: string, entryId: string, entry: GroupEntry) => {
const value = entry[field.name] || field.defaultValue || "";
// 🆕 계산된 필드는 읽기 전용 (자동 계산 설정 기반)
const isCalculatedField = componentConfig.autoCalculation?.targetField === field.name;
const commonProps = {
value: value || "",
disabled: componentConfig.disabled || componentConfig.readonly,
@ -399,7 +538,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
type="text"
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
maxLength={field.validation?.maxLength}
className="h-8 text-xs sm:h-10 sm:text-sm"
className="h-10 text-sm"
/>
);
@ -409,6 +548,30 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
case "bigint":
case "decimal":
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 (
<Input
{...commonProps}
@ -416,7 +579,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
min={field.validation?.min}
max={field.validation?.max}
className="h-8 text-xs sm:h-10 sm:text-sm"
className="h-10 text-sm"
/>
);
@ -428,7 +591,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
{...commonProps}
type="date"
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들
case "code":
case "category":
// 🆕 codeCategory를 field.codeCategory 또는 codeOptions에서 찾기
// 🆕 옵션을 field.name 또는 field.codeCategory 키로 찾기
let categoryOptions = field.options; // 기본값
if (field.codeCategory && codeOptions[field.codeCategory]) {
categoryOptions = codeOptions[field.codeCategory];
} else {
// 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];
// 1순위: 필드 이름으로 직접 찾기 (category 타입에서 사용)
if (codeOptions[field.name]) {
categoryOptions = codeOptions[field.name];
}
// 2순위: codeCategory로 찾기 (code 타입에서 사용)
else if (field.codeCategory && codeOptions[field.codeCategory]) {
categoryOptions = codeOptions[field.codeCategory];
}
return (
@ -478,7 +644,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)}
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 || "선택하세요"} />
</SelectTrigger>
<SelectContent>
@ -769,11 +935,11 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const isEditingThisEntry = isEditingThisGroup && editingDetailId === entry.id;
if (isEditingThisEntry) {
// 편집 모드: 입력 필드 표시
// 편집 모드: 입력 필드 표시 (가로 배치)
return (
<Card key={entry.id} className="border-dashed border-primary">
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium"> </span>
<Button
type="button"
@ -790,6 +956,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
</Button>
</div>
{/* 🆕 가로 Grid 배치 (2~3열) */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{groupFields.map((field) => (
<div key={field.name} className="space-y-1">
<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)}
</div>
))}
</div>
</CardContent>
</Card>
);

View File

@ -1,8 +1,9 @@
"use client";
import React, { useState, useMemo } from "react";
import React, { useState, useMemo, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -14,6 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { getSecondLevelMenus, getCategoryColumns, getCategoryValues } from "@/lib/api/tableCategoryValue";
export interface SelectedItemsDetailInputConfigPanelProps {
config: SelectedItemsDetailInputConfig;
@ -47,6 +49,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
// 🆕 필드 그룹 상태
const [localFieldGroups, setLocalFieldGroups] = useState<FieldGroup[]>(config.fieldGroups || []);
// 🆕 그룹별 펼침/접힘 상태
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
@ -61,6 +64,77 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
const [tableSelectOpen, setTableSelectOpen] = useState(false);
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로 자동 설정
React.useEffect(() => {
if (screenTableName && !config.targetTable) {
@ -568,6 +642,85 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</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 && (
<div className="space-y-1">
@ -970,6 +1123,478 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</p>
</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} &gt; {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} &gt; {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} &gt; {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="flex items-center space-x-2">

View File

@ -22,6 +22,10 @@ export interface AdditionalFieldDefinition {
placeholder?: string;
/** 기본값 */
defaultValue?: any;
/** 🆕 원본 데이터에서 자동으로 값을 가져올 필드명 */
autoFillFrom?: string;
/** 🆕 자동 채우기할 데이터의 테이블명 (비워두면 주 데이터 소스 사용) */
autoFillFromTable?: string;
/** 선택 옵션 (type이 select일 때) */
options?: Array<{ label: string; value: string }>;
/** 필드 너비 (px 또는 %) */
@ -54,6 +58,39 @@ export interface FieldGroup {
displayItems?: DisplayItem[];
}
/**
* 🆕
*/
export interface AutoCalculationConfig {
/** 계산 대상 필드명 (예: calculated_price) */
targetField: string;
/** 계산에 사용할 입력 필드들 */
inputFields: {
basePrice: string; // 기본 단가 필드명
discountType: string; // 할인 방식 필드명
discountValue: string; // 할인값 필드명
roundingType: string; // 반올림 방식 필드명
roundingUnit: string; // 반올림 단위 필드명
};
/** 계산 함수 타입 */
calculationType: "price" | "custom";
/** 🆕 카테고리 값 → 연산 매핑 */
valueMapping?: {
/** 할인 방식 매핑 */
discountType?: {
[valueCode: string]: "none" | "rate" | "amount"; // 예: { "CATEGORY_544740": "rate" }
};
/** 반올림 방식 매핑 */
roundingType?: {
[valueCode: string]: "none" | "round" | "floor" | "ceil";
};
/** 반올림 단위 매핑 (숫자로 변환) */
roundingUnit?: {
[valueCode: string]: number; // 예: { "10": 10, "100": 100 }
};
};
}
/**
* SelectedItemsDetailInput
*/
@ -93,6 +130,12 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
*/
targetTable?: string;
/**
* 🆕
*
*/
autoCalculation?: AutoCalculationConfig;
/**
*
* - grid: 테이블 ()

View File

@ -418,8 +418,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setSelectedLeftItem(item);
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
loadRightData(item);
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
const leftTableName = componentConfig.leftPanel?.tableName;
if (leftTableName && !isDesignMode) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().setData(leftTableName, [item]);
console.log(`✅ 분할 패널 좌측 선택: ${leftTableName}`, item);
});
}
},
[loadRightData],
[loadRightData, componentConfig.leftPanel?.tableName, isDesignMode],
);
// 우측 항목 확장/축소 토글

View File

@ -41,6 +41,13 @@ export interface ButtonActionConfig {
// 모달/팝업 관련
modalTitle?: string;
modalTitleBlocks?: Array<{ // 🆕 블록 기반 제목 (우선순위 높음)
id: string;
type: "text" | "field";
value: string; // type=text: 텍스트 내용, type=field: 컬럼명
tableName?: string; // type=field일 때 테이블명
label?: string; // type=field일 때 표시용 라벨
}>;
modalDescription?: string;
modalSize?: "sm" | "md" | "lg" | "xl";
popupWidth?: number;
@ -207,6 +214,20 @@ export class ButtonActionExecutor {
await new Promise(resolve => setTimeout(resolve, 100));
// 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조)
console.log("🔍 [handleSave] formData 구조 확인:", {
keys: Object.keys(context.formData),
values: Object.entries(context.formData).map(([key, value]) => ({
key,
isArray: Array.isArray(value),
length: Array.isArray(value) ? value.length : 0,
firstItem: Array.isArray(value) && value.length > 0 ? {
hasOriginalData: !!value[0]?.originalData,
hasFieldGroups: !!value[0]?.fieldGroups,
keys: Object.keys(value[0] || {})
} : null
}))
});
const selectedItemsKeys = Object.keys(context.formData).filter(key => {
const value = context.formData[key];
return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups;
@ -215,6 +236,8 @@ export class ButtonActionExecutor {
if (selectedItemsKeys.length > 0) {
console.log("🔄 [handleSave] SelectedItemsDetailInput 배치 저장 감지:", selectedItemsKeys);
return await this.handleBatchSave(config, context, selectedItemsKeys);
} else {
console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행");
}
// 폼 유효성 검사
@ -830,11 +853,11 @@ export class ButtonActionExecutor {
dataSourceId: config.dataSourceId,
});
// 🆕 1. dataSourceId 자동 결정
// 🆕 1. 현재 화면의 TableList 또는 SplitPanelLayout 자동 감지
let dataSourceId = config.dataSourceId;
// dataSourceId가 없으면 같은 화면의 TableList 자동 감지
if (!dataSourceId && context.allComponents) {
// TableList 우선 감지
const tableListComponent = context.allComponents.find(
(comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName
);
@ -845,6 +868,19 @@ export class ButtonActionExecutor {
componentId: tableListComponent.id,
tableName: dataSourceId,
});
} else {
// TableList가 없으면 SplitPanelLayout의 좌측 패널 감지
const splitPanelComponent = context.allComponents.find(
(comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName
);
if (splitPanelComponent) {
dataSourceId = splitPanelComponent.componentConfig.leftPanel.tableName;
console.log("✨ 분할 패널 좌측 테이블 자동 감지:", {
componentId: splitPanelComponent.id,
tableName: dataSourceId,
});
}
}
}
@ -853,21 +889,30 @@ export class ButtonActionExecutor {
dataSourceId = context.tableName || "default";
}
// 2. modalDataStore에서 데이터 확인
// 🆕 2. modalDataStore에서 현재 선택된 데이터 확인
try {
const { useModalDataStore } = await import("@/stores/modalDataStore");
const modalData = useModalDataStore.getState().dataRegistry[dataSourceId] || [];
const dataRegistry = useModalDataStore.getState().dataRegistry;
const modalData = dataRegistry[dataSourceId] || [];
console.log("📊 현재 화면 데이터 확인:", {
dataSourceId,
count: modalData.length,
allKeys: Object.keys(dataRegistry), // 🆕 전체 데이터 키 확인
});
if (modalData.length === 0) {
console.warn("⚠️ 전달할 데이터가 없습니다:", dataSourceId);
console.warn("⚠️ 선택된 데이터가 없습니다:", dataSourceId);
toast.warning("선택된 데이터가 없습니다. 먼저 항목을 선택해주세요.");
return false;
}
console.log("✅ 전달할 데이터:", {
dataSourceId,
count: modalData.length,
data: modalData,
console.log("✅ 모달 데이터 준비 완료:", {
currentData: { id: dataSourceId, count: modalData.length },
previousData: Object.entries(dataRegistry)
.filter(([key]) => key !== dataSourceId)
.map(([key, data]: [string, any]) => ({ id: key, count: data.length })),
});
} catch (error) {
console.error("❌ 데이터 확인 실패:", error);
@ -875,7 +920,79 @@ export class ButtonActionExecutor {
return false;
}
// 3. 모달 열기 + URL 파라미터로 dataSourceId 전달
// 6. 동적 모달 제목 생성
const { useModalDataStore } = await import("@/stores/modalDataStore");
const dataRegistry = useModalDataStore.getState().dataRegistry;
let finalTitle = "데이터 입력";
// 🆕 블록 기반 제목 (우선순위 1)
if (config.modalTitleBlocks && config.modalTitleBlocks.length > 0) {
const titleParts: string[] = [];
config.modalTitleBlocks.forEach((block) => {
if (block.type === "text") {
// 텍스트 블록: 그대로 추가
titleParts.push(block.value);
} else if (block.type === "field") {
// 필드 블록: 데이터에서 값 가져오기
const tableName = block.tableName;
const columnName = block.value;
if (tableName && columnName) {
const tableData = dataRegistry[tableName];
if (tableData && tableData.length > 0) {
const firstItem = tableData[0].originalData || tableData[0];
const value = firstItem[columnName];
if (value !== undefined && value !== null) {
titleParts.push(String(value));
console.log(`✨ 동적 필드: ${tableName}.${columnName}${value}`);
} else {
// 데이터 없으면 라벨 표시
titleParts.push(block.label || columnName);
}
} else {
// 테이블 데이터 없으면 라벨 표시
titleParts.push(block.label || columnName);
}
}
}
});
finalTitle = titleParts.join("");
console.log("📋 블록 기반 제목 생성:", finalTitle);
}
// 기존 방식: {tableName.columnName} 패턴 (우선순위 2)
else if (config.modalTitle) {
finalTitle = config.modalTitle;
if (finalTitle.includes("{")) {
const matches = finalTitle.match(/\{([^}]+)\}/g);
if (matches) {
matches.forEach((match) => {
const path = match.slice(1, -1); // {item_info.item_name} → item_info.item_name
const [tableName, columnName] = path.split(".");
if (tableName && columnName) {
const tableData = dataRegistry[tableName];
if (tableData && tableData.length > 0) {
const firstItem = tableData[0].originalData || tableData[0];
const value = firstItem[columnName];
if (value !== undefined && value !== null) {
finalTitle = finalTitle.replace(match, String(value));
console.log(`✨ 동적 제목: ${match}${value}`);
}
}
}
});
}
}
}
// 7. 모달 열기 + URL 파라미터로 dataSourceId 전달
if (config.targetScreenId) {
// config에 modalDescription이 있으면 우선 사용
let description = config.modalDescription || "";
@ -894,10 +1011,10 @@ export class ButtonActionExecutor {
const modalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: config.modalTitle || "데이터 입력",
title: finalTitle, // 🆕 동적 제목 사용
description: description,
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
urlParams: { dataSourceId }, // 🆕 URL 파라미터로 dataSourceId 전달
urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음)
},
});

View File

@ -85,8 +85,7 @@ export type ComponentType =
| "area"
| "layout"
| "flow"
| "component"
| "category-manager";
| "component";
/**
*