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:
kjs 2025-11-18 16:16:23 +09:00
commit ade71313b4
16 changed files with 3879 additions and 310 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,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

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

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

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

View File

@ -77,3 +77,4 @@ export const numberingRuleTemplate = {

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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();
}

View File

@ -129,7 +129,7 @@
```tsx
{
type: "button-primary",
config: {
config: {
text: "저장",
action: {
type: "save",

View File

@ -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
*/

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;
@ -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에서 자동으로 찾음)
},
});

View File

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

View File

@ -377,3 +377,4 @@ interface TablePermission {