feat: 기간별 단가 설정 기능 구현 - 자동 계산 시스템

- 선택항목 상세입력 컴포넌트 확장
  - 실시간 가격 계산 기능 추가 (할인율/할인금액, 반올림 방식)
  - 카테고리 값 기반 연산 매핑 시스템
  - 3단계 드릴다운 방식 설정 UI (메뉴 → 카테고리 → 값 매핑)

- 설정 가능한 계산 로직
  - autoCalculation 설정으로 계산 필드명 동적 지정
  - valueMapping으로 카테고리 코드와 연산 타입 매핑
  - 할인 방식: none/rate/amount
  - 반올림 방식: none/round/floor/ceil
  - 반올림 단위: 1/10/100/1000

- UI 개선
  - 입력 필드 가로 배치 (반응형 Grid)
  - 카테고리 타입 필드 옵션 로딩 개선
  - 계산 결과 필드 자동 표시 및 읽기 전용 처리
  - 날짜 입력 필드 네이티브 피커 지원

- API 연동
  - 2레벨 메뉴 목록 조회
  - 메뉴별 카테고리 컬럼 조회
  - 카테고리별 값 목록 조회

- 문서화
  - 기간별 단가 설정 가이드 작성
This commit is contained in:
kjs
2025-11-18 16:12:47 +09:00
parent 967b76591b
commit e1a5befdf7
10 changed files with 1966 additions and 186 deletions

View File

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