Merge pull request 'feature/screen-management' (#212) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/212
This commit is contained in:
commit
ade71313b4
|
|
@ -1604,10 +1604,14 @@ export async function toggleLogTable(
|
|||
}
|
||||
|
||||
/**
|
||||
* 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회
|
||||
* 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속)
|
||||
*
|
||||
* @route GET /api/table-management/menu/:menuObjid/category-columns
|
||||
* @description 형제 메뉴들의 화면에서 사용하는 테이블의 input_type='category' 컬럼 조회
|
||||
* @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회
|
||||
*
|
||||
* 예시:
|
||||
* - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정
|
||||
* - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속)
|
||||
*/
|
||||
export async function getCategoryColumnsByMenu(
|
||||
req: AuthenticatedRequest,
|
||||
|
|
@ -1627,40 +1631,10 @@ export async function getCategoryColumnsByMenu(
|
|||
return;
|
||||
}
|
||||
|
||||
// 1. 형제 메뉴 조회
|
||||
const { getSiblingMenuObjids } = await import("../services/menuService");
|
||||
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
|
||||
|
||||
logger.info("✅ 형제 메뉴 조회 완료", { siblingObjids });
|
||||
|
||||
// 2. 형제 메뉴들이 사용하는 테이블 조회
|
||||
const { getPool } = await import("../database/db");
|
||||
const pool = getPool();
|
||||
|
||||
const tablesQuery = `
|
||||
SELECT DISTINCT sd.table_name
|
||||
FROM screen_menu_assignments sma
|
||||
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
|
||||
WHERE sma.menu_objid = ANY($1)
|
||||
AND sma.company_code = $2
|
||||
AND sd.table_name IS NOT NULL
|
||||
`;
|
||||
|
||||
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
|
||||
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
|
||||
|
||||
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
|
||||
|
||||
if (tableNames.length === 0) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: [],
|
||||
message: "형제 메뉴에 연결된 테이블이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. category_column_mapping 테이블 존재 여부 확인
|
||||
// 1. category_column_mapping 테이블 존재 여부 확인
|
||||
const tableExistsResult = await pool.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
|
|
@ -1672,33 +1646,42 @@ export async function getCategoryColumnsByMenu(
|
|||
let columnsResult;
|
||||
|
||||
if (mappingTableExists) {
|
||||
// 🆕 category_column_mapping을 사용한 필터링
|
||||
logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
|
||||
// 🆕 category_column_mapping을 사용한 계층 구조 기반 조회
|
||||
logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode });
|
||||
|
||||
// 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀)
|
||||
const ancestorMenuQuery = `
|
||||
WITH RECURSIVE menu_hierarchy AS (
|
||||
-- 현재 메뉴
|
||||
SELECT objid, parent_obj_id, menu_type
|
||||
SELECT objid, parent_obj_id, menu_type, menu_name_kor
|
||||
FROM menu_info
|
||||
WHERE objid = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 부모 메뉴 재귀 조회
|
||||
SELECT m.objid, m.parent_obj_id, m.menu_type
|
||||
SELECT m.objid, m.parent_obj_id, m.menu_type, m.menu_name_kor
|
||||
FROM menu_info m
|
||||
INNER JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id
|
||||
WHERE m.parent_obj_id != 0 -- 최상위 메뉴(parent_obj_id=0) 제외
|
||||
)
|
||||
SELECT ARRAY_AGG(objid) as menu_objids
|
||||
SELECT
|
||||
ARRAY_AGG(objid) as menu_objids,
|
||||
ARRAY_AGG(menu_name_kor) as menu_names
|
||||
FROM menu_hierarchy
|
||||
`;
|
||||
|
||||
const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]);
|
||||
const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)];
|
||||
const ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || [];
|
||||
|
||||
logger.info("✅ 상위 메뉴 계층 조회 완료", {
|
||||
ancestorMenuObjids,
|
||||
ancestorMenuNames,
|
||||
hierarchyDepth: ancestorMenuObjids.length
|
||||
});
|
||||
|
||||
// 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거)
|
||||
const columnsQuery = `
|
||||
SELECT DISTINCT
|
||||
ttc.table_name AS "tableName",
|
||||
|
|
@ -1711,7 +1694,8 @@ export async function getCategoryColumnsByMenu(
|
|||
cl.column_label,
|
||||
initcap(replace(ccm.logical_column_name, '_', ' '))
|
||||
) AS "columnLabel",
|
||||
ttc.input_type AS "inputType"
|
||||
ttc.input_type AS "inputType",
|
||||
ccm.menu_objid AS "definedAtMenuObjid"
|
||||
FROM category_column_mapping ccm
|
||||
INNER JOIN table_type_columns ttc
|
||||
ON ccm.table_name = ttc.table_name
|
||||
|
|
@ -1721,18 +1705,48 @@ export async function getCategoryColumnsByMenu(
|
|||
AND ttc.column_name = cl.column_name
|
||||
LEFT JOIN table_labels tl
|
||||
ON ttc.table_name = tl.table_name
|
||||
WHERE ccm.table_name = ANY($1)
|
||||
AND ccm.company_code = $2
|
||||
AND ccm.menu_objid = ANY($3)
|
||||
WHERE ccm.company_code = $1
|
||||
AND ccm.menu_objid = ANY($2)
|
||||
AND ttc.input_type = 'category'
|
||||
ORDER BY ttc.table_name, ccm.logical_column_name
|
||||
`;
|
||||
|
||||
columnsResult = await pool.query(columnsQuery, [tableNames, companyCode, ancestorMenuObjids]);
|
||||
logger.info("✅ category_column_mapping 기반 조회 완료", { rowCount: columnsResult.rows.length });
|
||||
columnsResult = await pool.query(columnsQuery, [companyCode, ancestorMenuObjids]);
|
||||
logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", {
|
||||
rowCount: columnsResult.rows.length,
|
||||
columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`)
|
||||
});
|
||||
} else {
|
||||
// 🔄 기존 방식: table_type_columns에서 모든 카테고리 컬럼 조회
|
||||
logger.info("🔍 레거시 방식: table_type_columns 기반 카테고리 컬럼 조회", { tableNames, companyCode });
|
||||
// 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회
|
||||
logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
|
||||
|
||||
// 형제 메뉴 조회
|
||||
const { getSiblingMenuObjids } = await import("../services/menuService");
|
||||
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
|
||||
|
||||
// 형제 메뉴들이 사용하는 테이블 조회
|
||||
const tablesQuery = `
|
||||
SELECT DISTINCT sd.table_name
|
||||
FROM screen_menu_assignments sma
|
||||
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
|
||||
WHERE sma.menu_objid = ANY($1)
|
||||
AND sma.company_code = $2
|
||||
AND sd.table_name IS NOT NULL
|
||||
`;
|
||||
|
||||
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
|
||||
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
|
||||
|
||||
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
|
||||
|
||||
if (tableNames.length === 0) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: [],
|
||||
message: "형제 메뉴에 연결된 테이블이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const columnsQuery = `
|
||||
SELECT
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
@ -56,11 +56,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
// 폼 데이터 상태 추가
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
|
||||
// 연속 등록 모드 상태 (localStorage에 저장하여 리렌더링에 영향받지 않도록)
|
||||
const continuousModeRef = useRef(false);
|
||||
const [, setForceUpdate] = useState(0); // 강제 리렌더링용 (값은 사용하지 않음)
|
||||
|
||||
|
||||
// localStorage에서 연속 모드 상태 복원
|
||||
useEffect(() => {
|
||||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||
|
|
@ -122,7 +122,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
useEffect(() => {
|
||||
const handleOpenModal = (event: CustomEvent) => {
|
||||
const { screenId, title, description, size, urlParams } = event.detail;
|
||||
|
||||
|
||||
// 🆕 URL 파라미터가 있으면 현재 URL에 추가
|
||||
if (urlParams && typeof window !== "undefined") {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
|
|
@ -133,7 +133,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
window.history.pushState({}, "", currentUrl.toString());
|
||||
console.log("✅ URL 파라미터 추가:", urlParams);
|
||||
}
|
||||
|
||||
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
screenId,
|
||||
|
|
@ -152,7 +152,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
window.history.pushState({}, "", currentUrl.toString());
|
||||
console.log("🧹 URL 파라미터 제거");
|
||||
}
|
||||
|
||||
|
||||
setModalState({
|
||||
isOpen: false,
|
||||
screenId: null,
|
||||
|
|
@ -173,14 +173,14 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// console.log("💾 저장 성공 이벤트 수신");
|
||||
// console.log("📌 현재 연속 모드 상태 (ref):", isContinuousMode);
|
||||
// console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode"));
|
||||
|
||||
|
||||
if (isContinuousMode) {
|
||||
// 연속 모드: 폼만 초기화하고 모달은 유지
|
||||
// console.log("✅ 연속 모드 활성화 - 폼만 초기화");
|
||||
|
||||
|
||||
// 폼만 초기화 (연속 모드 상태는 localStorage에 저장되어 있으므로 유지됨)
|
||||
setFormData({});
|
||||
|
||||
|
||||
toast.success("저장되었습니다. 계속 입력하세요.");
|
||||
} else {
|
||||
// 일반 모드: 모달 닫기
|
||||
|
|
@ -227,7 +227,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
// 화면 관리에서 설정한 해상도 사용 (우선순위)
|
||||
const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution;
|
||||
|
||||
|
||||
let dimensions;
|
||||
if (screenResolution && screenResolution.width && screenResolution.height) {
|
||||
// 화면 관리에서 설정한 해상도 사용
|
||||
|
|
@ -243,7 +243,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
dimensions = calculateScreenDimensions(components);
|
||||
console.log("⚠️ 자동 계산된 크기 사용:", dimensions);
|
||||
}
|
||||
|
||||
|
||||
setScreenDimensions(dimensions);
|
||||
|
||||
setScreenData({
|
||||
|
|
@ -303,17 +303,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
};
|
||||
|
||||
const modalStyle = getModalStyle();
|
||||
|
||||
|
||||
// 안정적인 modalId를 상태로 저장 (모달이 닫혀도 유지)
|
||||
const [persistedModalId, setPersistedModalId] = useState<string | undefined>(undefined);
|
||||
|
||||
|
||||
// modalId 생성 및 업데이트
|
||||
useEffect(() => {
|
||||
// 모달이 열려있고 screenId가 있을 때만 업데이트
|
||||
if (!modalState.isOpen) return;
|
||||
|
||||
|
||||
let newModalId: string | undefined;
|
||||
|
||||
|
||||
// 1순위: screenId (가장 안정적)
|
||||
if (modalState.screenId) {
|
||||
newModalId = `screen-modal-${modalState.screenId}`;
|
||||
|
|
@ -351,11 +351,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// result: newModalId,
|
||||
// });
|
||||
}
|
||||
|
||||
|
||||
if (newModalId) {
|
||||
setPersistedModalId(newModalId);
|
||||
}
|
||||
}, [modalState.isOpen, modalState.screenId, modalState.title, screenData?.screenInfo?.tableName, screenData?.screenInfo?.screenName]);
|
||||
}, [
|
||||
modalState.isOpen,
|
||||
modalState.screenId,
|
||||
modalState.title,
|
||||
screenData?.screenInfo?.tableName,
|
||||
screenData?.screenInfo?.screenName,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
|
|
@ -397,62 +403,63 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
) : screenData ? (
|
||||
<TableOptionsProvider>
|
||||
<TableSearchWidgetHeightProvider>
|
||||
<div
|
||||
className="relative bg-white mx-auto"
|
||||
style={{
|
||||
width: `${screenDimensions?.width || 800}px`,
|
||||
height: `${screenDimensions?.height || 600}px`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
{screenData.components.map((component) => {
|
||||
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
<div
|
||||
className="relative mx-auto bg-white"
|
||||
style={{
|
||||
width: `${screenDimensions?.width || 800}px`,
|
||||
height: `${screenDimensions?.height || 600}px`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
{screenData.components.map((component) => {
|
||||
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
|
||||
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
|
||||
const adjustedComponent = (offsetX === 0 && offsetY === 0) ? component : {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
||||
},
|
||||
};
|
||||
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
|
||||
const adjustedComponent =
|
||||
offsetX === 0 && offsetY === 0
|
||||
? component
|
||||
: {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
// console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||
// console.log("📋 현재 formData:", formData);
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
// console.log("📝 ScreenModal 업데이트된 formData:", newFormData);
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
onRefresh={() => {
|
||||
// 부모 화면의 테이블 새로고침 이벤트 발송
|
||||
console.log("🔄 모달에서 부모 화면 테이블 새로고침 이벤트 발송");
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TableSearchWidgetHeightProvider>
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
// console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||
// console.log("📋 현재 formData:", formData);
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
// console.log("📝 ScreenModal 업데이트된 formData:", newFormData);
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
onRefresh={() => {
|
||||
// 부모 화면의 테이블 새로고침 이벤트 발송
|
||||
console.log("🔄 모달에서 부모 화면 테이블 새로고침 이벤트 발송");
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
@ -475,10 +482,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// console.log("🔄 연속 모드 변경:", isChecked);
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="continuous-mode"
|
||||
className="text-sm font-normal cursor-pointer select-none"
|
||||
>
|
||||
<Label htmlFor="continuous-mode" className="cursor-pointer text-sm font-normal select-none">
|
||||
저장 후 계속 입력 (연속 등록 모드)
|
||||
</Label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
{/* 🆕 블록 기반 제목 빌더 */}
|
||||
<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
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -77,3 +77,4 @@ export const numberingRuleTemplate = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { AutocompleteSearchInputDefinition } from "./index";
|
||||
import { AutocompleteSearchInputComponent } from "./AutocompleteSearchInputComponent";
|
||||
|
||||
// 파일 로드 시 즉시 등록
|
||||
ComponentRegistry.registerComponent(AutocompleteSearchInputDefinition);
|
||||
console.log("✅ AutocompleteSearchInput 컴포넌트 등록 완료");
|
||||
/**
|
||||
* AutocompleteSearchInput 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class AutocompleteSearchInputRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = AutocompleteSearchInputDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <AutocompleteSearchInputComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 변경 처리
|
||||
*/
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
AutocompleteSearchInputRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
AutocompleteSearchInputRenderer.enableHotReload();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { EntitySearchInputDefinition } from "./index";
|
||||
import { EntitySearchInputComponent } from "./EntitySearchInputComponent";
|
||||
|
||||
// 파일 로드 시 즉시 등록
|
||||
ComponentRegistry.registerComponent(EntitySearchInputDefinition);
|
||||
console.log("✅ EntitySearchInput 컴포넌트 등록 완료");
|
||||
/**
|
||||
* EntitySearchInput 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class EntitySearchInputRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = EntitySearchInputDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <EntitySearchInputComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 변경 처리
|
||||
*/
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
EntitySearchInputRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
EntitySearchInputRenderer.enableHotReload();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { ModalRepeaterTableDefinition } from "./index";
|
||||
import { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent";
|
||||
|
||||
// 파일 로드 시 즉시 등록
|
||||
ComponentRegistry.registerComponent(ModalRepeaterTableDefinition);
|
||||
console.log("✅ ModalRepeaterTable 컴포넌트 등록 완료");
|
||||
/**
|
||||
* ModalRepeaterTable 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class ModalRepeaterTableRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = ModalRepeaterTableDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <ModalRepeaterTableComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 변경 처리
|
||||
*/
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
ModalRepeaterTableRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
ModalRepeaterTableRenderer.enableHotReload();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@
|
|||
```tsx
|
||||
{
|
||||
type: "button-primary",
|
||||
config: {
|
||||
config: {
|
||||
text: "저장",
|
||||
action: {
|
||||
type: "save",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -22,10 +22,16 @@ export interface AdditionalFieldDefinition {
|
|||
placeholder?: string;
|
||||
/** 기본값 */
|
||||
defaultValue?: any;
|
||||
/** 🆕 원본 데이터에서 자동으로 값을 가져올 필드명 */
|
||||
autoFillFrom?: string;
|
||||
/** 🆕 자동 채우기할 데이터의 테이블명 (비워두면 주 데이터 소스 사용) */
|
||||
autoFillFromTable?: string;
|
||||
/** 선택 옵션 (type이 select일 때) */
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
/** 필드 너비 (px 또는 %) */
|
||||
width?: string;
|
||||
/** 🆕 필드 그룹 ID (같은 그룹ID를 가진 필드들은 같은 카드에 표시) */
|
||||
groupId?: string;
|
||||
/** 검증 규칙 */
|
||||
validation?: {
|
||||
min?: number;
|
||||
|
|
@ -36,6 +42,55 @@ export interface AdditionalFieldDefinition {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 그룹 정의
|
||||
*/
|
||||
export interface FieldGroup {
|
||||
/** 그룹 ID */
|
||||
id: string;
|
||||
/** 그룹 제목 */
|
||||
title: string;
|
||||
/** 그룹 설명 (선택사항) */
|
||||
description?: string;
|
||||
/** 그룹 표시 순서 */
|
||||
order?: number;
|
||||
/** 🆕 이 그룹의 항목 표시 설정 (그룹별로 다른 표시 형식 가능) */
|
||||
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 컴포넌트 설정 타입
|
||||
*/
|
||||
|
|
@ -64,11 +119,23 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
|||
*/
|
||||
additionalFields?: AdditionalFieldDefinition[];
|
||||
|
||||
/**
|
||||
* 🆕 필드 그룹 정의
|
||||
* 추가 입력 필드를 여러 카드로 나눠서 표시
|
||||
*/
|
||||
fieldGroups?: FieldGroup[];
|
||||
|
||||
/**
|
||||
* 저장 대상 테이블
|
||||
*/
|
||||
targetTable?: string;
|
||||
|
||||
/**
|
||||
* 🆕 자동 계산 설정
|
||||
* 특정 필드가 변경되면 다른 필드를 자동으로 계산
|
||||
*/
|
||||
autoCalculation?: AutoCalculationConfig;
|
||||
|
||||
/**
|
||||
* 레이아웃 모드
|
||||
* - grid: 테이블 형식 (기본)
|
||||
|
|
@ -86,6 +153,13 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
|||
*/
|
||||
allowRemove?: boolean;
|
||||
|
||||
/**
|
||||
* 🆕 입력 모드
|
||||
* - inline: 항상 입력창 표시 (기본)
|
||||
* - modal: 추가 버튼 클릭 시 입력창 표시, 완료 후 작은 카드로 표시
|
||||
*/
|
||||
inputMode?: "inline" | "modal";
|
||||
|
||||
/**
|
||||
* 빈 상태 메시지
|
||||
*/
|
||||
|
|
@ -96,6 +170,92 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
|||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 그룹별 입력 항목 (예: 그룹1의 한 줄)
|
||||
*/
|
||||
export interface GroupEntry {
|
||||
/** 입력 항목 고유 ID */
|
||||
id: string;
|
||||
/** 입력된 필드 데이터 */
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 표시 항목 타입
|
||||
*/
|
||||
export type DisplayItemType = "icon" | "field" | "text" | "badge";
|
||||
|
||||
/**
|
||||
* 🆕 빈 값 처리 방식
|
||||
*/
|
||||
export type EmptyBehavior = "hide" | "default" | "blank";
|
||||
|
||||
/**
|
||||
* 🆕 필드 표시 형식
|
||||
*/
|
||||
export type DisplayFieldFormat = "text" | "date" | "currency" | "number" | "badge";
|
||||
|
||||
/**
|
||||
* 🆕 표시 항목 정의 (아이콘, 필드, 텍스트, 배지)
|
||||
*/
|
||||
export interface DisplayItem {
|
||||
/** 항목 타입 */
|
||||
type: DisplayItemType;
|
||||
|
||||
/** 고유 ID */
|
||||
id: string;
|
||||
|
||||
// === type: "field" 인 경우 ===
|
||||
/** 필드명 (컬럼명) */
|
||||
fieldName?: string;
|
||||
/** 라벨 (예: "거래처:", "단가:") */
|
||||
label?: string;
|
||||
/** 표시 형식 */
|
||||
format?: DisplayFieldFormat;
|
||||
/** 빈 값일 때 동작 */
|
||||
emptyBehavior?: EmptyBehavior;
|
||||
/** 기본값 (빈 값일 때 표시) */
|
||||
defaultValue?: string;
|
||||
|
||||
// === type: "icon" 인 경우 ===
|
||||
/** 아이콘 이름 (lucide-react 아이콘명) */
|
||||
icon?: string;
|
||||
|
||||
// === type: "text" 인 경우 ===
|
||||
/** 텍스트 내용 */
|
||||
value?: string;
|
||||
|
||||
// === type: "badge" 인 경우 ===
|
||||
/** 배지 스타일 */
|
||||
badgeVariant?: "default" | "secondary" | "destructive" | "outline";
|
||||
|
||||
// === 공통 스타일 ===
|
||||
/** 굵게 표시 */
|
||||
bold?: boolean;
|
||||
/** 밑줄 표시 */
|
||||
underline?: boolean;
|
||||
/** 기울임 표시 */
|
||||
italic?: boolean;
|
||||
/** 텍스트 색상 */
|
||||
color?: string;
|
||||
/** 배경 색상 */
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 품목 + 그룹별 여러 입력 항목
|
||||
* 각 필드 그룹마다 독립적으로 여러 개의 입력을 추가할 수 있음
|
||||
* 예: { "group1": [entry1, entry2], "group2": [entry1, entry2, entry3] }
|
||||
*/
|
||||
export interface ItemData {
|
||||
/** 품목 고유 ID */
|
||||
id: string;
|
||||
/** 원본 데이터 (품목 정보) */
|
||||
originalData: Record<string, any>;
|
||||
/** 필드 그룹별 입력 항목들 { groupId: [entry1, entry2, ...] } */
|
||||
fieldGroups: Record<string, GroupEntry[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 컴포넌트 Props 타입
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
||||
// 우측 항목 확장/축소 토글
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -198,6 +205,41 @@ export class ButtonActionExecutor {
|
|||
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
const { formData, originalData, tableName, screenId } = context;
|
||||
|
||||
console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId });
|
||||
|
||||
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
|
||||
window.dispatchEvent(new CustomEvent("beforeFormSave"));
|
||||
|
||||
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
|
||||
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;
|
||||
});
|
||||
|
||||
if (selectedItemsKeys.length > 0) {
|
||||
console.log("🔄 [handleSave] SelectedItemsDetailInput 배치 저장 감지:", selectedItemsKeys);
|
||||
return await this.handleBatchSave(config, context, selectedItemsKeys);
|
||||
} else {
|
||||
console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행");
|
||||
}
|
||||
|
||||
// 폼 유효성 검사
|
||||
if (config.validateForm) {
|
||||
const validation = this.validateFormData(formData);
|
||||
|
|
@ -446,6 +488,128 @@ export class ButtonActionExecutor {
|
|||
return await this.handleSave(config, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 배치 저장 액션 처리 (SelectedItemsDetailInput용 - 새로운 데이터 구조)
|
||||
* ItemData[] → 각 품목의 details 배열을 개별 레코드로 저장
|
||||
*/
|
||||
private static async handleBatchSave(
|
||||
config: ButtonActionConfig,
|
||||
context: ButtonActionContext,
|
||||
selectedItemsKeys: string[]
|
||||
): Promise<boolean> {
|
||||
const { formData, tableName, screenId } = context;
|
||||
|
||||
if (!tableName || !screenId) {
|
||||
toast.error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리
|
||||
for (const key of selectedItemsKeys) {
|
||||
// 🆕 새로운 데이터 구조: ItemData[] with fieldGroups
|
||||
const items = formData[key] as Array<{
|
||||
id: string;
|
||||
originalData: any;
|
||||
fieldGroups: Record<string, Array<{ id: string; [key: string]: any }>>;
|
||||
}>;
|
||||
|
||||
console.log(`📦 [handleBatchSave] ${key} 처리 중 (${items.length}개 품목)`);
|
||||
|
||||
// 각 품목의 모든 그룹의 모든 항목을 개별 저장
|
||||
for (const item of items) {
|
||||
const allGroupEntries = Object.values(item.fieldGroups).flat();
|
||||
console.log(`🔍 [handleBatchSave] 품목 처리: ${item.id} (${allGroupEntries.length}개 입력 항목)`);
|
||||
|
||||
// 모든 그룹의 모든 항목을 개별 레코드로 저장
|
||||
for (const entry of allGroupEntries) {
|
||||
try {
|
||||
// 원본 데이터 + 입력 데이터 병합
|
||||
const mergedData = {
|
||||
...item.originalData,
|
||||
...entry,
|
||||
};
|
||||
|
||||
// id 필드 제거 (entry.id는 임시 ID이므로)
|
||||
delete mergedData.id;
|
||||
|
||||
// 사용자 정보 추가
|
||||
if (!context.userId) {
|
||||
throw new Error("사용자 정보를 불러올 수 없습니다.");
|
||||
}
|
||||
|
||||
const writerValue = context.userId;
|
||||
const companyCodeValue = context.companyCode || "";
|
||||
|
||||
const dataWithUserInfo = {
|
||||
...mergedData,
|
||||
writer: mergedData.writer || writerValue,
|
||||
created_by: writerValue,
|
||||
updated_by: writerValue,
|
||||
company_code: mergedData.company_code || companyCodeValue,
|
||||
};
|
||||
|
||||
console.log(`💾 [handleBatchSave] 입력 항목 저장:`, {
|
||||
itemId: item.id,
|
||||
entryId: entry.id,
|
||||
data: dataWithUserInfo
|
||||
});
|
||||
|
||||
// INSERT 실행
|
||||
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||
const saveResult = await DynamicFormApi.saveFormData({
|
||||
screenId,
|
||||
tableName,
|
||||
data: dataWithUserInfo,
|
||||
});
|
||||
|
||||
if (saveResult.success) {
|
||||
successCount++;
|
||||
console.log(`✅ [handleBatchSave] 입력 항목 저장 성공: ${item.id} > ${entry.id}`);
|
||||
} else {
|
||||
failCount++;
|
||||
errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${saveResult.message}`);
|
||||
console.error(`❌ [handleBatchSave] 입력 항목 저장 실패: ${item.id} > ${entry.id}`, saveResult.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
failCount++;
|
||||
errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${error.message}`);
|
||||
console.error(`❌ [handleBatchSave] 입력 항목 저장 오류: ${item.id} > ${entry.id}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 토스트
|
||||
if (failCount === 0) {
|
||||
toast.success(`${successCount}개 항목이 저장되었습니다.`);
|
||||
} else if (successCount === 0) {
|
||||
toast.error(`저장 실패: ${errors.join(", ")}`);
|
||||
return false;
|
||||
} else {
|
||||
toast.warning(`${successCount}개 성공, ${failCount}개 실패: ${errors.join(", ")}`);
|
||||
}
|
||||
|
||||
// 테이블과 플로우 새로고침
|
||||
context.onRefresh?.();
|
||||
context.onFlowRefresh?.();
|
||||
|
||||
// 저장 성공 후 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error("배치 저장 오류:", error);
|
||||
toast.error(`저장 오류: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 액션 처리
|
||||
*/
|
||||
|
|
@ -689,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
|
||||
);
|
||||
|
|
@ -704,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -712,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);
|
||||
|
|
@ -734,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 || "";
|
||||
|
|
@ -753,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에서 자동으로 찾음)
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -85,8 +85,7 @@ export type ComponentType =
|
|||
| "area"
|
||||
| "layout"
|
||||
| "flow"
|
||||
| "component"
|
||||
| "category-manager";
|
||||
| "component";
|
||||
|
||||
/**
|
||||
* 기본 위치 정보
|
||||
|
|
|
|||
|
|
@ -377,3 +377,4 @@ interface TablePermission {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue