Merge remote-tracking branch 'origin/main' into ksh
This commit is contained in:
commit
c11e80a43c
|
|
@ -231,7 +231,7 @@ export const deleteFormData = async (
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { companyCode, userId } = req.user as any;
|
const { companyCode, userId } = req.user as any;
|
||||||
const { tableName } = req.body;
|
const { tableName, screenId } = req.body;
|
||||||
|
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
@ -240,7 +240,16 @@ export const deleteFormData = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가
|
// screenId를 숫자로 변환 (문자열로 전달될 수 있음)
|
||||||
|
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
|
||||||
|
|
||||||
|
await dynamicFormService.deleteFormData(
|
||||||
|
id,
|
||||||
|
tableName,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
parsedScreenId // screenId 추가 (제어관리 실행용)
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,262 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { dataService } from "../services/dataService";
|
import { dataService } from "../services/dataService";
|
||||||
|
import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 마스터-디테일 엑셀 API
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 관계 정보 조회
|
||||||
|
* GET /api/data/master-detail/relation/:screenId
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/master-detail/relation/:screenId",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId } = req.params;
|
||||||
|
|
||||||
|
if (!screenId || isNaN(parseInt(screenId))) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "유효한 screenId가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
|
||||||
|
|
||||||
|
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||||
|
parseInt(screenId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: null,
|
||||||
|
message: "마스터-디테일 구조가 아닙니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 관계 발견:`, {
|
||||||
|
masterTable: relation.masterTable,
|
||||||
|
detailTable: relation.detailTable,
|
||||||
|
joinKey: relation.masterKeyColumn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: relation,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 관계 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 다운로드 데이터 조회
|
||||||
|
* POST /api/data/master-detail/download
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/master-detail/download",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId, filters } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!screenId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "screenId가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`);
|
||||||
|
|
||||||
|
// 1. 마스터-디테일 관계 조회
|
||||||
|
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||||
|
parseInt(screenId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 구조가 아닙니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. JOIN 데이터 조회
|
||||||
|
const data = await masterDetailExcelService.getJoinedData(
|
||||||
|
relation,
|
||||||
|
companyCode,
|
||||||
|
filters
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}행`);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 다운로드 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 다운로드 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 업로드
|
||||||
|
* POST /api/data/master-detail/upload
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/master-detail/upload",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId, data } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!screenId || !data || !Array.isArray(data)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "screenId와 data 배열이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
|
||||||
|
|
||||||
|
// 1. 마스터-디테일 관계 조회
|
||||||
|
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||||
|
parseInt(screenId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 구조가 아닙니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 데이터 업로드
|
||||||
|
const result = await masterDetailExcelService.uploadJoinedData(
|
||||||
|
relation,
|
||||||
|
data,
|
||||||
|
companyCode,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 업로드 완료:`, {
|
||||||
|
masterInserted: result.masterInserted,
|
||||||
|
masterUpdated: result.masterUpdated,
|
||||||
|
detailInserted: result.detailInserted,
|
||||||
|
errors: result.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: result.success,
|
||||||
|
data: result,
|
||||||
|
message: result.success
|
||||||
|
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
|
||||||
|
: "업로드 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 업로드 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 간단 모드 엑셀 업로드
|
||||||
|
* - 마스터 정보는 UI에서 선택
|
||||||
|
* - 디테일 정보만 엑셀에서 업로드
|
||||||
|
* - 채번 규칙을 통해 마스터 키 자동 생성
|
||||||
|
*
|
||||||
|
* POST /api/data/master-detail/upload-simple
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/master-detail/upload-simple",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
|
||||||
|
if (!screenId || !detailData || !Array.isArray(detailData)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "screenId와 detailData 배열이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`);
|
||||||
|
console.log(` 마스터 필드 값:`, masterFieldValues);
|
||||||
|
console.log(` 채번 규칙 ID:`, numberingRuleId);
|
||||||
|
console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}개` : afterUploadFlowId || "없음");
|
||||||
|
|
||||||
|
// 업로드 실행
|
||||||
|
const result = await masterDetailExcelService.uploadSimple(
|
||||||
|
parseInt(screenId),
|
||||||
|
detailData,
|
||||||
|
masterFieldValues || {},
|
||||||
|
numberingRuleId,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성)
|
||||||
|
afterUploadFlows // 업로드 후 제어 실행 (다중)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, {
|
||||||
|
masterInserted: result.masterInserted,
|
||||||
|
detailInserted: result.detailInserted,
|
||||||
|
generatedKey: result.generatedKey,
|
||||||
|
errors: result.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: result.success,
|
||||||
|
data: result,
|
||||||
|
message: result.success
|
||||||
|
? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.`
|
||||||
|
: "업로드 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 간단 모드 업로드 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 기존 데이터 API
|
||||||
|
// ================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
||||||
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
||||||
|
|
|
||||||
|
|
@ -1192,12 +1192,18 @@ export class DynamicFormService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
||||||
|
* @param id 삭제할 레코드 ID
|
||||||
|
* @param tableName 테이블명
|
||||||
|
* @param companyCode 회사 코드
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @param screenId 화면 ID (제어관리 실행용, 선택사항)
|
||||||
*/
|
*/
|
||||||
async deleteFormData(
|
async deleteFormData(
|
||||||
id: string | number,
|
id: string | number,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
companyCode?: string,
|
companyCode?: string,
|
||||||
userId?: string
|
userId?: string,
|
||||||
|
screenId?: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||||||
|
|
@ -1310,14 +1316,19 @@ export class DynamicFormService {
|
||||||
const recordCompanyCode =
|
const recordCompanyCode =
|
||||||
deletedRecord?.company_code || companyCode || "*";
|
deletedRecord?.company_code || companyCode || "*";
|
||||||
|
|
||||||
await this.executeDataflowControlIfConfigured(
|
// screenId가 전달되지 않으면 제어관리를 실행하지 않음
|
||||||
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
if (screenId && screenId > 0) {
|
||||||
tableName,
|
await this.executeDataflowControlIfConfigured(
|
||||||
deletedRecord,
|
screenId,
|
||||||
"delete",
|
tableName,
|
||||||
userId || "system",
|
deletedRecord,
|
||||||
recordCompanyCode
|
"delete",
|
||||||
);
|
userId || "system",
|
||||||
|
recordCompanyCode
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("ℹ️ screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (controlError) {
|
} catch (controlError) {
|
||||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||||
|
|
@ -1662,10 +1673,16 @@ export class DynamicFormService {
|
||||||
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
// 버튼 컴포넌트이고 제어관리가 활성화된 경우
|
||||||
|
// triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete
|
||||||
|
const buttonActionType = properties?.componentConfig?.action?.type;
|
||||||
|
const isMatchingAction =
|
||||||
|
(triggerType === "delete" && buttonActionType === "delete") ||
|
||||||
|
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
|
||||||
|
|
||||||
if (
|
if (
|
||||||
properties?.componentType === "button-primary" &&
|
properties?.componentType === "button-primary" &&
|
||||||
properties?.componentConfig?.action?.type === "save" &&
|
isMatchingAction &&
|
||||||
properties?.webTypeConfig?.enableDataflowControl === true
|
properties?.webTypeConfig?.enableDataflowControl === true
|
||||||
) {
|
) {
|
||||||
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,868 @@
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 처리 서비스
|
||||||
|
*
|
||||||
|
* 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고
|
||||||
|
* 엑셀 다운로드/업로드 시 JOIN 및 그룹화 처리를 수행합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne, transaction, getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 인터페이스 정의
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 관계 정보
|
||||||
|
*/
|
||||||
|
export interface MasterDetailRelation {
|
||||||
|
masterTable: string;
|
||||||
|
detailTable: string;
|
||||||
|
masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no)
|
||||||
|
detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no)
|
||||||
|
masterColumns: ColumnInfo[];
|
||||||
|
detailColumns: ColumnInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 정보
|
||||||
|
*/
|
||||||
|
export interface ColumnInfo {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
inputType: string;
|
||||||
|
isFromMaster: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분할 패널 설정
|
||||||
|
*/
|
||||||
|
export interface SplitPanelConfig {
|
||||||
|
leftPanel: {
|
||||||
|
tableName: string;
|
||||||
|
columns: Array<{ name: string; label: string; width?: number }>;
|
||||||
|
};
|
||||||
|
rightPanel: {
|
||||||
|
tableName: string;
|
||||||
|
columns: Array<{ name: string; label: string; width?: number }>;
|
||||||
|
relation?: {
|
||||||
|
type: string;
|
||||||
|
foreignKey: string;
|
||||||
|
leftColumn: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 다운로드 결과
|
||||||
|
*/
|
||||||
|
export interface ExcelDownloadData {
|
||||||
|
headers: string[]; // 컬럼 라벨들
|
||||||
|
columns: string[]; // 컬럼명들
|
||||||
|
data: Record<string, any>[];
|
||||||
|
masterColumns: string[]; // 마스터 컬럼 목록
|
||||||
|
detailColumns: string[]; // 디테일 컬럼 목록
|
||||||
|
joinKey: string; // 조인 키
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 업로드 결과
|
||||||
|
*/
|
||||||
|
export interface ExcelUploadResult {
|
||||||
|
success: boolean;
|
||||||
|
masterInserted: number;
|
||||||
|
masterUpdated: number;
|
||||||
|
detailInserted: number;
|
||||||
|
detailDeleted: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 서비스 클래스
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
class MasterDetailExcelService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 ID로 분할 패널 설정 조회
|
||||||
|
*/
|
||||||
|
async getSplitPanelConfig(screenId: number): Promise<SplitPanelConfig | null> {
|
||||||
|
try {
|
||||||
|
logger.info(`분할 패널 설정 조회: screenId=${screenId}`);
|
||||||
|
|
||||||
|
// screen_layouts에서 split-panel-layout 컴포넌트 찾기
|
||||||
|
const result = await queryOne<any>(
|
||||||
|
`SELECT properties->>'componentConfig' as config
|
||||||
|
FROM screen_layouts
|
||||||
|
WHERE screen_id = $1
|
||||||
|
AND component_type = 'component'
|
||||||
|
AND properties->>'componentType' = 'split-panel-layout'
|
||||||
|
LIMIT 1`,
|
||||||
|
[screenId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result || !result.config) {
|
||||||
|
logger.info(`분할 패널 없음: screenId=${screenId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = typeof result.config === "string"
|
||||||
|
? JSON.parse(result.config)
|
||||||
|
: result.config;
|
||||||
|
|
||||||
|
logger.info(`분할 패널 설정 발견:`, {
|
||||||
|
leftTable: config.leftPanel?.tableName,
|
||||||
|
rightTable: config.rightPanel?.tableName,
|
||||||
|
relation: config.rightPanel?.relation,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
leftPanel: config.leftPanel,
|
||||||
|
rightPanel: config.rightPanel,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`분할 패널 설정 조회 실패: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* column_labels에서 Entity 관계 정보 조회
|
||||||
|
* 디테일 테이블에서 마스터 테이블을 참조하는 컬럼 찾기
|
||||||
|
*/
|
||||||
|
async getEntityRelation(
|
||||||
|
detailTable: string,
|
||||||
|
masterTable: string
|
||||||
|
): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> {
|
||||||
|
try {
|
||||||
|
logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`);
|
||||||
|
|
||||||
|
const result = await queryOne<any>(
|
||||||
|
`SELECT column_name, reference_column
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND input_type = 'entity'
|
||||||
|
AND reference_table = $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[detailTable, masterTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
detailFkColumn: result.column_name,
|
||||||
|
masterKeyColumn: result.reference_column,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`Entity 관계 조회 실패: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블의 컬럼 라벨 정보 조회
|
||||||
|
*/
|
||||||
|
async getColumnLabels(tableName: string): Promise<Map<string, string>> {
|
||||||
|
try {
|
||||||
|
const result = await query<any>(
|
||||||
|
`SELECT column_name, column_label
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = $1`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelMap = new Map<string, string>();
|
||||||
|
for (const row of result) {
|
||||||
|
labelMap.set(row.column_name, row.column_label || row.column_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return labelMap;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`컬럼 라벨 조회 실패: ${error.message}`);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 관계 정보 조합
|
||||||
|
*/
|
||||||
|
async getMasterDetailRelation(
|
||||||
|
screenId: number
|
||||||
|
): Promise<MasterDetailRelation | null> {
|
||||||
|
try {
|
||||||
|
// 1. 분할 패널 설정 조회
|
||||||
|
const splitPanel = await this.getSplitPanelConfig(screenId);
|
||||||
|
if (!splitPanel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterTable = splitPanel.leftPanel.tableName;
|
||||||
|
const detailTable = splitPanel.rightPanel.tableName;
|
||||||
|
|
||||||
|
if (!masterTable || !detailTable) {
|
||||||
|
logger.warn("마스터 또는 디테일 테이블명 없음");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 분할 패널의 relation 정보가 있으면 우선 사용
|
||||||
|
let masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
|
||||||
|
let detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
|
||||||
|
|
||||||
|
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
|
||||||
|
if (!masterKeyColumn || !detailFkColumn) {
|
||||||
|
const entityRelation = await this.getEntityRelation(detailTable, masterTable);
|
||||||
|
if (entityRelation) {
|
||||||
|
masterKeyColumn = entityRelation.masterKeyColumn;
|
||||||
|
detailFkColumn = entityRelation.detailFkColumn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!masterKeyColumn || !detailFkColumn) {
|
||||||
|
logger.warn("조인 키 정보를 찾을 수 없음");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 컬럼 라벨 정보 조회
|
||||||
|
const masterLabels = await this.getColumnLabels(masterTable);
|
||||||
|
const detailLabels = await this.getColumnLabels(detailTable);
|
||||||
|
|
||||||
|
// 5. 마스터 컬럼 정보 구성
|
||||||
|
const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({
|
||||||
|
name: col.name,
|
||||||
|
label: masterLabels.get(col.name) || col.label || col.name,
|
||||||
|
inputType: "text",
|
||||||
|
isFromMaster: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외)
|
||||||
|
const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns
|
||||||
|
.filter(col => col.name !== detailFkColumn) // FK 컬럼 제외
|
||||||
|
.map(col => ({
|
||||||
|
name: col.name,
|
||||||
|
label: detailLabels.get(col.name) || col.label || col.name,
|
||||||
|
inputType: "text",
|
||||||
|
isFromMaster: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 관계 구성 완료:`, {
|
||||||
|
masterTable,
|
||||||
|
detailTable,
|
||||||
|
masterKeyColumn,
|
||||||
|
detailFkColumn,
|
||||||
|
masterColumnCount: masterColumns.length,
|
||||||
|
detailColumnCount: detailColumns.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
masterTable,
|
||||||
|
detailTable,
|
||||||
|
masterKeyColumn,
|
||||||
|
detailFkColumn,
|
||||||
|
masterColumns,
|
||||||
|
detailColumns,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 JOIN 데이터 조회 (엑셀 다운로드용)
|
||||||
|
*/
|
||||||
|
async getJoinedData(
|
||||||
|
relation: MasterDetailRelation,
|
||||||
|
companyCode: string,
|
||||||
|
filters?: Record<string, any>
|
||||||
|
): Promise<ExcelDownloadData> {
|
||||||
|
try {
|
||||||
|
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||||||
|
|
||||||
|
// 조인 컬럼과 일반 컬럼 분리
|
||||||
|
// 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name)
|
||||||
|
const entityJoins: Array<{
|
||||||
|
refTable: string;
|
||||||
|
refColumn: string;
|
||||||
|
sourceColumn: string;
|
||||||
|
alias: string;
|
||||||
|
displayColumn: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// SELECT 절 구성
|
||||||
|
const selectParts: string[] = [];
|
||||||
|
let aliasIndex = 0;
|
||||||
|
|
||||||
|
// 마스터 컬럼 처리
|
||||||
|
for (const col of masterColumns) {
|
||||||
|
if (col.name.includes(".")) {
|
||||||
|
// 조인 컬럼: 테이블명.컬럼명
|
||||||
|
const [refTable, displayColumn] = col.name.split(".");
|
||||||
|
const alias = `ej${aliasIndex++}`;
|
||||||
|
|
||||||
|
// column_labels에서 FK 컬럼 찾기
|
||||||
|
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
|
||||||
|
if (fkColumn) {
|
||||||
|
entityJoins.push({
|
||||||
|
refTable,
|
||||||
|
refColumn: fkColumn.referenceColumn,
|
||||||
|
sourceColumn: fkColumn.sourceColumn,
|
||||||
|
alias,
|
||||||
|
displayColumn,
|
||||||
|
});
|
||||||
|
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||||
|
} else {
|
||||||
|
// FK를 못 찾으면 NULL로 처리
|
||||||
|
selectParts.push(`NULL AS "${col.name}"`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 일반 컬럼
|
||||||
|
selectParts.push(`m."${col.name}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디테일 컬럼 처리
|
||||||
|
for (const col of detailColumns) {
|
||||||
|
if (col.name.includes(".")) {
|
||||||
|
// 조인 컬럼: 테이블명.컬럼명
|
||||||
|
const [refTable, displayColumn] = col.name.split(".");
|
||||||
|
const alias = `ej${aliasIndex++}`;
|
||||||
|
|
||||||
|
// column_labels에서 FK 컬럼 찾기
|
||||||
|
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
|
||||||
|
if (fkColumn) {
|
||||||
|
entityJoins.push({
|
||||||
|
refTable,
|
||||||
|
refColumn: fkColumn.referenceColumn,
|
||||||
|
sourceColumn: fkColumn.sourceColumn,
|
||||||
|
alias,
|
||||||
|
displayColumn,
|
||||||
|
});
|
||||||
|
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||||
|
} else {
|
||||||
|
selectParts.push(`NULL AS "${col.name}"`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 일반 컬럼
|
||||||
|
selectParts.push(`d."${col.name}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectClause = selectParts.join(", ");
|
||||||
|
|
||||||
|
// 엔티티 조인 절 구성
|
||||||
|
const entityJoinClauses = entityJoins.map(ej =>
|
||||||
|
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
|
||||||
|
).join("\n ");
|
||||||
|
|
||||||
|
// WHERE 절 구성
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 회사 코드 필터 (최고 관리자 제외)
|
||||||
|
if (companyCode && companyCode !== "*") {
|
||||||
|
whereConditions.push(`m.company_code = $${paramIndex}`);
|
||||||
|
params.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추가 필터 적용
|
||||||
|
if (filters) {
|
||||||
|
for (const [key, value] of Object.entries(filters)) {
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
// 조인 컬럼인지 확인
|
||||||
|
if (key.includes(".")) continue;
|
||||||
|
// 마스터 테이블 컬럼인지 확인
|
||||||
|
const isMasterCol = masterColumns.some(c => c.name === key);
|
||||||
|
const tableAlias = isMasterCol ? "m" : "d";
|
||||||
|
whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereConditions.length > 0
|
||||||
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// JOIN 쿼리 실행
|
||||||
|
const sql = `
|
||||||
|
SELECT ${selectClause}
|
||||||
|
FROM "${masterTable}" m
|
||||||
|
LEFT JOIN "${detailTable}" d
|
||||||
|
ON m."${masterKeyColumn}" = d."${detailFkColumn}"
|
||||||
|
AND m.company_code = d.company_code
|
||||||
|
${entityJoinClauses}
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY m."${masterKeyColumn}", d.id
|
||||||
|
`;
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
|
||||||
|
|
||||||
|
const data = await query<any>(sql, params);
|
||||||
|
|
||||||
|
// 헤더 및 컬럼 정보 구성
|
||||||
|
const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)];
|
||||||
|
const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)];
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}행`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers,
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
masterColumns: masterColumns.map(c => c.name),
|
||||||
|
detailColumns: detailColumns.map(c => c.name),
|
||||||
|
joinKey: masterKeyColumn,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 테이블에서 참조 테이블로의 FK 컬럼 찾기
|
||||||
|
*/
|
||||||
|
private async findForeignKeyColumn(
|
||||||
|
sourceTable: string,
|
||||||
|
referenceTable: string
|
||||||
|
): Promise<{ sourceColumn: string; referenceColumn: string } | null> {
|
||||||
|
try {
|
||||||
|
const result = await query<{ column_name: string; reference_column: string }>(
|
||||||
|
`SELECT column_name, reference_column
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND reference_table = $2
|
||||||
|
AND input_type = 'entity'
|
||||||
|
LIMIT 1`,
|
||||||
|
[sourceTable, referenceTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
return {
|
||||||
|
sourceColumn: result[0].column_name,
|
||||||
|
referenceColumn: result[0].reference_column,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
|
||||||
|
*
|
||||||
|
* 처리 로직:
|
||||||
|
* 1. 엑셀 데이터를 마스터 키로 그룹화
|
||||||
|
* 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT
|
||||||
|
* 3. 해당 마스터 키의 기존 디테일 삭제
|
||||||
|
* 4. 새 디테일 데이터 INSERT
|
||||||
|
*/
|
||||||
|
async uploadJoinedData(
|
||||||
|
relation: MasterDetailRelation,
|
||||||
|
data: Record<string, any>[],
|
||||||
|
companyCode: string,
|
||||||
|
userId?: string
|
||||||
|
): Promise<ExcelUploadResult> {
|
||||||
|
const result: ExcelUploadResult = {
|
||||||
|
success: false,
|
||||||
|
masterInserted: 0,
|
||||||
|
masterUpdated: 0,
|
||||||
|
detailInserted: 0,
|
||||||
|
detailDeleted: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||||||
|
|
||||||
|
// 1. 데이터를 마스터 키로 그룹화
|
||||||
|
const groupedData = new Map<string, Record<string, any>[]>();
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
const masterKey = row[masterKeyColumn];
|
||||||
|
if (!masterKey) {
|
||||||
|
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groupedData.has(masterKey)) {
|
||||||
|
groupedData.set(masterKey, []);
|
||||||
|
}
|
||||||
|
groupedData.get(masterKey)!.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
|
||||||
|
|
||||||
|
// 2. 각 그룹 처리
|
||||||
|
for (const [masterKey, rows] of groupedData.entries()) {
|
||||||
|
try {
|
||||||
|
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
|
||||||
|
const masterData: Record<string, any> = {};
|
||||||
|
for (const col of masterColumns) {
|
||||||
|
if (rows[0][col.name] !== undefined) {
|
||||||
|
masterData[col.name] = rows[0][col.name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사 코드, 작성자 추가
|
||||||
|
masterData.company_code = companyCode;
|
||||||
|
if (userId) {
|
||||||
|
masterData.writer = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. 마스터 UPSERT
|
||||||
|
const existingMaster = await client.query(
|
||||||
|
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
||||||
|
[masterKey, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingMaster.rows.length > 0) {
|
||||||
|
// UPDATE
|
||||||
|
const updateCols = Object.keys(masterData)
|
||||||
|
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||||
|
.map((k, i) => `"${k}" = $${i + 1}`);
|
||||||
|
const updateValues = Object.keys(masterData)
|
||||||
|
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||||
|
.map(k => masterData[k]);
|
||||||
|
|
||||||
|
if (updateCols.length > 0) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE "${masterTable}"
|
||||||
|
SET ${updateCols.join(", ")}, updated_date = NOW()
|
||||||
|
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
||||||
|
[...updateValues, masterKey, companyCode]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result.masterUpdated++;
|
||||||
|
} else {
|
||||||
|
// INSERT
|
||||||
|
const insertCols = Object.keys(masterData);
|
||||||
|
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||||
|
const insertValues = insertCols.map(k => masterData[k]);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||||
|
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||||
|
insertValues
|
||||||
|
);
|
||||||
|
result.masterInserted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2c. 기존 디테일 삭제
|
||||||
|
const deleteResult = await client.query(
|
||||||
|
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
|
||||||
|
[masterKey, companyCode]
|
||||||
|
);
|
||||||
|
result.detailDeleted += deleteResult.rowCount || 0;
|
||||||
|
|
||||||
|
// 2d. 새 디테일 INSERT
|
||||||
|
for (const row of rows) {
|
||||||
|
const detailData: Record<string, any> = {};
|
||||||
|
|
||||||
|
// FK 컬럼 추가
|
||||||
|
detailData[detailFkColumn] = masterKey;
|
||||||
|
detailData.company_code = companyCode;
|
||||||
|
if (userId) {
|
||||||
|
detailData.writer = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디테일 컬럼 데이터 추출
|
||||||
|
for (const col of detailColumns) {
|
||||||
|
if (row[col.name] !== undefined) {
|
||||||
|
detailData[col.name] = row[col.name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertCols = Object.keys(detailData);
|
||||||
|
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||||
|
const insertValues = insertCols.map(k => detailData[k]);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||||
|
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||||
|
insertValues
|
||||||
|
);
|
||||||
|
result.detailInserted++;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
|
||||||
|
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0;
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 업로드 완료:`, {
|
||||||
|
masterInserted: result.masterInserted,
|
||||||
|
masterUpdated: result.masterUpdated,
|
||||||
|
detailInserted: result.detailInserted,
|
||||||
|
detailDeleted: result.detailDeleted,
|
||||||
|
errors: result.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
||||||
|
logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 간단 모드 업로드
|
||||||
|
*
|
||||||
|
* 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함
|
||||||
|
* 채번 규칙을 통해 마스터 키 자동 생성
|
||||||
|
*
|
||||||
|
* @param screenId 화면 ID
|
||||||
|
* @param detailData 디테일 데이터 배열
|
||||||
|
* @param masterFieldValues UI에서 선택한 마스터 필드 값
|
||||||
|
* @param numberingRuleId 채번 규칙 ID (optional)
|
||||||
|
* @param companyCode 회사 코드
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @param afterUploadFlowId 업로드 후 실행할 노드 플로우 ID (optional, 하위 호환성)
|
||||||
|
* @param afterUploadFlows 업로드 후 실행할 노드 플로우 배열 (optional)
|
||||||
|
*/
|
||||||
|
async uploadSimple(
|
||||||
|
screenId: number,
|
||||||
|
detailData: Record<string, any>[],
|
||||||
|
masterFieldValues: Record<string, any>,
|
||||||
|
numberingRuleId: string | undefined,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string,
|
||||||
|
afterUploadFlowId?: string,
|
||||||
|
afterUploadFlows?: Array<{ flowId: string; order: number }>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
masterInserted: number;
|
||||||
|
detailInserted: number;
|
||||||
|
generatedKey: string;
|
||||||
|
errors: string[];
|
||||||
|
controlResult?: any;
|
||||||
|
}> {
|
||||||
|
const result: {
|
||||||
|
success: boolean;
|
||||||
|
masterInserted: number;
|
||||||
|
detailInserted: number;
|
||||||
|
generatedKey: string;
|
||||||
|
errors: string[];
|
||||||
|
controlResult?: any;
|
||||||
|
} = {
|
||||||
|
success: false,
|
||||||
|
masterInserted: 0,
|
||||||
|
detailInserted: 0,
|
||||||
|
generatedKey: "",
|
||||||
|
errors: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 1. 마스터-디테일 관계 정보 조회
|
||||||
|
const relation = await this.getMasterDetailRelation(screenId);
|
||||||
|
if (!relation) {
|
||||||
|
throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation;
|
||||||
|
|
||||||
|
// 2. 채번 처리
|
||||||
|
let generatedKey: string;
|
||||||
|
|
||||||
|
if (numberingRuleId) {
|
||||||
|
// 채번 규칙으로 키 생성
|
||||||
|
generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode);
|
||||||
|
} else {
|
||||||
|
// 채번 규칙 없으면 마스터 필드에서 키 값 사용
|
||||||
|
generatedKey = masterFieldValues[masterKeyColumn];
|
||||||
|
if (!generatedKey) {
|
||||||
|
throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.generatedKey = generatedKey;
|
||||||
|
logger.info(`채번 결과: ${generatedKey}`);
|
||||||
|
|
||||||
|
// 3. 마스터 레코드 생성
|
||||||
|
const masterData: Record<string, any> = {
|
||||||
|
...masterFieldValues,
|
||||||
|
[masterKeyColumn]: generatedKey,
|
||||||
|
company_code: companyCode,
|
||||||
|
writer: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 마스터 컬럼명 목록 구성
|
||||||
|
const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined);
|
||||||
|
const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`);
|
||||||
|
const masterValues = masterCols.map(k => masterData[k]);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||||
|
VALUES (${masterPlaceholders.join(", ")}, NOW())`,
|
||||||
|
masterValues
|
||||||
|
);
|
||||||
|
result.masterInserted = 1;
|
||||||
|
logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`);
|
||||||
|
|
||||||
|
// 4. 디테일 레코드들 생성
|
||||||
|
for (const row of detailData) {
|
||||||
|
try {
|
||||||
|
const detailRowData: Record<string, any> = {
|
||||||
|
...row,
|
||||||
|
[detailFkColumn]: generatedKey,
|
||||||
|
company_code: companyCode,
|
||||||
|
writer: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 빈 값 필터링 및 id 제외
|
||||||
|
const detailCols = Object.keys(detailRowData).filter(k =>
|
||||||
|
k !== "id" &&
|
||||||
|
detailRowData[k] !== undefined &&
|
||||||
|
detailRowData[k] !== null &&
|
||||||
|
detailRowData[k] !== ""
|
||||||
|
);
|
||||||
|
const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`);
|
||||||
|
const detailValues = detailCols.map(k => detailRowData[k]);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||||
|
VALUES (${detailPlaceholders.join(", ")}, NOW())`,
|
||||||
|
detailValues
|
||||||
|
);
|
||||||
|
result.detailInserted++;
|
||||||
|
} catch (error: any) {
|
||||||
|
result.errors.push(`디테일 행 처리 실패: ${error.message}`);
|
||||||
|
logger.error(`디테일 행 처리 실패:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
result.success = result.errors.length === 0 || result.detailInserted > 0;
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 간단 모드 업로드 완료:`, {
|
||||||
|
masterInserted: result.masterInserted,
|
||||||
|
detailInserted: result.detailInserted,
|
||||||
|
generatedKey: result.generatedKey,
|
||||||
|
errors: result.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 업로드 후 제어 실행 (단일 또는 다중)
|
||||||
|
const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0
|
||||||
|
? afterUploadFlows // 다중 제어
|
||||||
|
: afterUploadFlowId
|
||||||
|
? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (flowsToExecute.length > 0 && result.success) {
|
||||||
|
try {
|
||||||
|
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
|
||||||
|
|
||||||
|
// 마스터 데이터를 제어에 전달
|
||||||
|
const masterData = {
|
||||||
|
...masterFieldValues,
|
||||||
|
[relation!.masterKeyColumn]: result.generatedKey,
|
||||||
|
company_code: companyCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const controlResults: any[] = [];
|
||||||
|
|
||||||
|
// 순서대로 제어 실행
|
||||||
|
for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) {
|
||||||
|
logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`);
|
||||||
|
|
||||||
|
const controlResult = await NodeFlowExecutionService.executeFlow(
|
||||||
|
parseInt(flow.flowId),
|
||||||
|
{
|
||||||
|
sourceData: [masterData],
|
||||||
|
dataSourceType: "formData",
|
||||||
|
buttonId: "excel-upload-button",
|
||||||
|
screenId: screenId,
|
||||||
|
userId: userId,
|
||||||
|
companyCode: companyCode,
|
||||||
|
formData: masterData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
controlResults.push({
|
||||||
|
flowId: flow.flowId,
|
||||||
|
order: flow.order,
|
||||||
|
success: controlResult.success,
|
||||||
|
message: controlResult.message,
|
||||||
|
executedNodes: controlResult.nodes?.length || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
result.controlResult = {
|
||||||
|
success: controlResults.every(r => r.success),
|
||||||
|
executedFlows: controlResults.length,
|
||||||
|
results: controlResults,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult);
|
||||||
|
} catch (controlError: any) {
|
||||||
|
logger.error(`업로드 후 제어 실행 실패:`, controlError);
|
||||||
|
result.controlResult = {
|
||||||
|
success: false,
|
||||||
|
message: `제어 실행 실패: ${controlError.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
||||||
|
logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채번 규칙으로 번호 생성 (기존 numberingRuleService 사용)
|
||||||
|
*/
|
||||||
|
private async generateNumberWithRule(
|
||||||
|
client: any,
|
||||||
|
ruleId: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
// 기존 numberingRuleService를 사용하여 코드 할당
|
||||||
|
const { numberingRuleService } = await import("./numberingRuleService");
|
||||||
|
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
||||||
|
|
||||||
|
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);
|
||||||
|
|
||||||
|
return generatedCode;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const masterDetailExcelService = new MasterDetailExcelService();
|
||||||
|
|
||||||
|
|
@ -969,21 +969,56 @@ export class NodeFlowExecutionService {
|
||||||
const insertedData = { ...data };
|
const insertedData = { ...data };
|
||||||
|
|
||||||
console.log("🗺️ 필드 매핑 처리 중...");
|
console.log("🗺️ 필드 매핑 처리 중...");
|
||||||
fieldMappings.forEach((mapping: any) => {
|
|
||||||
|
// 🔥 채번 규칙 서비스 동적 import
|
||||||
|
const { numberingRuleService } = await import("./numberingRuleService");
|
||||||
|
|
||||||
|
for (const mapping of fieldMappings) {
|
||||||
fields.push(mapping.targetField);
|
fields.push(mapping.targetField);
|
||||||
const value =
|
let value: any;
|
||||||
mapping.staticValue !== undefined
|
|
||||||
? mapping.staticValue
|
// 🔥 값 생성 유형에 따른 처리
|
||||||
: data[mapping.sourceField];
|
const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source");
|
||||||
|
|
||||||
console.log(
|
if (valueType === "autoGenerate" && mapping.numberingRuleId) {
|
||||||
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
// 자동 생성 (채번 규칙)
|
||||||
);
|
const companyCode = context.buttonContext?.companyCode || "*";
|
||||||
|
try {
|
||||||
|
value = await numberingRuleService.allocateCode(
|
||||||
|
mapping.numberingRuleId,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})`
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`채번 규칙 적용 실패: ${error.message}`);
|
||||||
|
console.error(
|
||||||
|
` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}`
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (valueType === "static" || mapping.staticValue !== undefined) {
|
||||||
|
// 고정값
|
||||||
|
value = mapping.staticValue;
|
||||||
|
console.log(
|
||||||
|
` 📌 고정값: ${mapping.targetField} = ${value}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 소스 필드
|
||||||
|
value = data[mapping.sourceField];
|
||||||
|
console.log(
|
||||||
|
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
values.push(value);
|
values.push(value);
|
||||||
|
|
||||||
// 🔥 삽입된 값을 데이터에 반영
|
// 🔥 삽입된 값을 데이터에 반영
|
||||||
insertedData[mapping.targetField] = value;
|
insertedData[mapping.targetField] = value;
|
||||||
});
|
}
|
||||||
|
|
||||||
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
|
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
|
||||||
const hasWriterMapping = fieldMappings.some(
|
const hasWriterMapping = fieldMappings.some(
|
||||||
|
|
@ -1528,16 +1563,24 @@ export class NodeFlowExecutionService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
// 🔑 Primary Key 자동 추가 여부 결정:
|
||||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
||||||
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
||||||
whereConditions,
|
let finalWhereConditions: any[];
|
||||||
data,
|
if (whereConditions && whereConditions.length > 0) {
|
||||||
targetTable
|
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
||||||
);
|
finalWhereConditions = whereConditions;
|
||||||
|
} else {
|
||||||
|
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||||
|
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||||
|
whereConditions,
|
||||||
|
data,
|
||||||
|
targetTable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const whereResult = this.buildWhereClause(
|
const whereResult = this.buildWhereClause(
|
||||||
enhancedWhereConditions,
|
finalWhereConditions,
|
||||||
data,
|
data,
|
||||||
paramIndex
|
paramIndex
|
||||||
);
|
);
|
||||||
|
|
@ -1907,22 +1950,30 @@ export class NodeFlowExecutionService {
|
||||||
return deletedDataArray;
|
return deletedDataArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 context-data 모드: 개별 삭제 (PK 자동 추가)
|
// 🆕 context-data 모드: 개별 삭제
|
||||||
console.log("🎯 context-data 모드: 개별 삭제 시작");
|
console.log("🎯 context-data 모드: 개별 삭제 시작");
|
||||||
|
|
||||||
for (const data of dataArray) {
|
for (const data of dataArray) {
|
||||||
console.log("🔍 WHERE 조건 처리 중...");
|
console.log("🔍 WHERE 조건 처리 중...");
|
||||||
|
|
||||||
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
// 🔑 Primary Key 자동 추가 여부 결정:
|
||||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
||||||
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
||||||
whereConditions,
|
let finalWhereConditions: any[];
|
||||||
data,
|
if (whereConditions && whereConditions.length > 0) {
|
||||||
targetTable
|
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
||||||
);
|
finalWhereConditions = whereConditions;
|
||||||
|
} else {
|
||||||
|
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||||
|
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||||
|
whereConditions,
|
||||||
|
data,
|
||||||
|
targetTable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const whereResult = this.buildWhereClause(
|
const whereResult = this.buildWhereClause(
|
||||||
enhancedWhereConditions,
|
finalWhereConditions,
|
||||||
data,
|
data,
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
@ -2865,10 +2916,11 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
|
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
|
||||||
logger.info(
|
logger.info(
|
||||||
`⚠️ EXISTS 조건: 필드값이 비어있어 ${operator === "NOT_EXISTS_IN" ? "TRUE" : "FALSE"} 반환`
|
`⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)`
|
||||||
);
|
);
|
||||||
// 값이 비어있으면: EXISTS_IN은 false, NOT_EXISTS_IN은 true
|
// 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환
|
||||||
return operator === "NOT_EXISTS_IN";
|
// 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2711,6 +2711,12 @@ export class TableManagementService {
|
||||||
filterColumn?: string;
|
filterColumn?: string;
|
||||||
filterValue?: any;
|
filterValue?: any;
|
||||||
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
|
deduplication?: {
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
}; // 🆕 중복 제거 설정
|
||||||
}
|
}
|
||||||
): Promise<EntityJoinResponse> {
|
): Promise<EntityJoinResponse> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
@ -2761,33 +2767,64 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const additionalColumn of options.additionalJoinColumns) {
|
for (const additionalColumn of options.additionalJoinColumns) {
|
||||||
// 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기)
|
// 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기
|
||||||
const baseJoinConfig = joinConfigs.find(
|
let baseJoinConfig = joinConfigs.find(
|
||||||
(config) => config.sourceColumn === additionalColumn.sourceColumn
|
(config) => config.sourceColumn === additionalColumn.sourceColumn
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때)
|
||||||
|
// 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응
|
||||||
|
if (!baseJoinConfig && (additionalColumn as any).referenceTable) {
|
||||||
|
baseJoinConfig = joinConfigs.find(
|
||||||
|
(config) => config.referenceTable === (additionalColumn as any).referenceTable
|
||||||
|
);
|
||||||
|
if (baseJoinConfig) {
|
||||||
|
logger.info(`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable} → ${baseJoinConfig.sourceColumn}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (baseJoinConfig) {
|
if (baseJoinConfig) {
|
||||||
// joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name)
|
// joinAlias에서 실제 컬럼명 추출
|
||||||
// sourceColumn을 제거한 나머지 부분이 실제 컬럼명
|
const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id)
|
||||||
const sourceColumn = baseJoinConfig.sourceColumn; // dept_code
|
const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name)
|
||||||
const joinAlias = additionalColumn.joinAlias; // dept_code_company_name
|
|
||||||
const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name
|
// 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리
|
||||||
|
// customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거)
|
||||||
|
// 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거)
|
||||||
|
let actualColumnName: string;
|
||||||
|
|
||||||
|
// 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출
|
||||||
|
const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id)
|
||||||
|
if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) {
|
||||||
|
// 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거
|
||||||
|
actualColumnName = originalJoinAlias.replace(`${frontendSourceColumn}_`, "");
|
||||||
|
} else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) {
|
||||||
|
// 실제 소스 컬럼으로 시작하면 그 부분 제거
|
||||||
|
actualColumnName = originalJoinAlias.replace(`${sourceColumn}_`, "");
|
||||||
|
} else {
|
||||||
|
// 어느 것도 아니면 원본 사용
|
||||||
|
actualColumnName = originalJoinAlias;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반)
|
||||||
|
const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`;
|
||||||
|
|
||||||
logger.info(`🔍 조인 컬럼 상세 분석:`, {
|
logger.info(`🔍 조인 컬럼 상세 분석:`, {
|
||||||
sourceColumn,
|
sourceColumn,
|
||||||
joinAlias,
|
frontendSourceColumn,
|
||||||
|
originalJoinAlias,
|
||||||
|
correctedJoinAlias,
|
||||||
actualColumnName,
|
actualColumnName,
|
||||||
referenceTable: additionalColumn.sourceTable,
|
referenceTable: (additionalColumn as any).referenceTable,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
|
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
|
||||||
const isBasicEntityJoin =
|
const isBasicEntityJoin =
|
||||||
additionalColumn.joinAlias ===
|
correctedJoinAlias === `${sourceColumn}_name`;
|
||||||
`${baseJoinConfig.sourceColumn}_name`;
|
|
||||||
|
|
||||||
if (isBasicEntityJoin) {
|
if (isBasicEntityJoin) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀`
|
`⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀`
|
||||||
);
|
);
|
||||||
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
|
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
|
||||||
}
|
}
|
||||||
|
|
@ -2795,14 +2832,14 @@ export class TableManagementService {
|
||||||
// 추가 조인 컬럼 설정 생성
|
// 추가 조인 컬럼 설정 생성
|
||||||
const additionalJoinConfig: EntityJoinConfig = {
|
const additionalJoinConfig: EntityJoinConfig = {
|
||||||
sourceTable: tableName,
|
sourceTable: tableName,
|
||||||
sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code)
|
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
|
||||||
referenceTable:
|
referenceTable:
|
||||||
(additionalColumn as any).referenceTable ||
|
(additionalColumn as any).referenceTable ||
|
||||||
baseJoinConfig.referenceTable, // 참조 테이블 (dept_info)
|
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
|
||||||
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code)
|
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
|
||||||
displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name)
|
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
|
||||||
displayColumn: actualColumnName, // 하위 호환성
|
displayColumn: actualColumnName, // 하위 호환성
|
||||||
aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name)
|
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
|
||||||
separator: " - ", // 기본 구분자
|
separator: " - ", // 기본 구분자
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,35 @@ import { cn } from "@/lib/utils";
|
||||||
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
|
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
|
||||||
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
||||||
|
|
||||||
|
// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정)
|
||||||
|
export interface MasterDetailExcelConfig {
|
||||||
|
// 테이블 정보
|
||||||
|
masterTable?: string;
|
||||||
|
detailTable?: string;
|
||||||
|
masterKeyColumn?: string;
|
||||||
|
detailFkColumn?: string;
|
||||||
|
// 채번
|
||||||
|
numberingRuleId?: string;
|
||||||
|
// 업로드 전 사용자가 선택할 마스터 테이블 필드
|
||||||
|
masterSelectFields?: Array<{
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
required: boolean;
|
||||||
|
inputType: "entity" | "date" | "text" | "select";
|
||||||
|
referenceTable?: string;
|
||||||
|
referenceColumn?: string;
|
||||||
|
displayColumn?: string;
|
||||||
|
}>;
|
||||||
|
// 엑셀에서 매핑할 디테일 테이블 필드
|
||||||
|
detailExcelFields?: Array<{
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
required: boolean;
|
||||||
|
}>;
|
||||||
|
masterDefaults?: Record<string, any>;
|
||||||
|
detailDefaults?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExcelUploadModalProps {
|
export interface ExcelUploadModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
|
@ -42,6 +71,19 @@ export interface ExcelUploadModalProps {
|
||||||
keyColumn?: string;
|
keyColumn?: string;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
// 마스터-디테일 지원
|
||||||
|
screenId?: number;
|
||||||
|
isMasterDetail?: boolean;
|
||||||
|
masterDetailRelation?: {
|
||||||
|
masterTable: string;
|
||||||
|
detailTable: string;
|
||||||
|
masterKeyColumn: string;
|
||||||
|
detailFkColumn: string;
|
||||||
|
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||||
|
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||||
|
};
|
||||||
|
// 🆕 마스터-디테일 엑셀 업로드 설정
|
||||||
|
masterDetailExcelConfig?: MasterDetailExcelConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ColumnMapping {
|
interface ColumnMapping {
|
||||||
|
|
@ -57,6 +99,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
keyColumn,
|
keyColumn,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
userId = "guest",
|
userId = "guest",
|
||||||
|
screenId,
|
||||||
|
isMasterDetail = false,
|
||||||
|
masterDetailRelation,
|
||||||
|
masterDetailExcelConfig,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
|
||||||
|
|
@ -79,6 +125,116 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
// 3단계: 확인
|
// 3단계: 확인
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
// 🆕 마스터-디테일 모드: 마스터 필드 입력값
|
||||||
|
const [masterFieldValues, setMasterFieldValues] = useState<Record<string, any>>({});
|
||||||
|
const [entitySearchData, setEntitySearchData] = useState<Record<string, any[]>>({});
|
||||||
|
const [entitySearchLoading, setEntitySearchLoading] = useState<Record<string, boolean>>({});
|
||||||
|
const [entityDisplayColumns, setEntityDisplayColumns] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 🆕 엔티티 참조 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🔍 엔티티 데이터 로드 체크:", {
|
||||||
|
masterSelectFields: masterDetailExcelConfig?.masterSelectFields,
|
||||||
|
open,
|
||||||
|
isMasterDetail,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!masterDetailExcelConfig?.masterSelectFields) return;
|
||||||
|
|
||||||
|
const loadEntityData = async () => {
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||||
|
|
||||||
|
for (const field of masterDetailExcelConfig.masterSelectFields!) {
|
||||||
|
console.log("🔍 필드 처리:", field);
|
||||||
|
|
||||||
|
if (field.inputType === "entity") {
|
||||||
|
setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: true }));
|
||||||
|
try {
|
||||||
|
let refTable = field.referenceTable;
|
||||||
|
console.log("🔍 초기 refTable:", refTable);
|
||||||
|
|
||||||
|
let displayCol = field.displayColumn;
|
||||||
|
|
||||||
|
// referenceTable 또는 displayColumn이 없으면 DB에서 동적으로 조회
|
||||||
|
if ((!refTable || !displayCol) && masterDetailExcelConfig.masterTable) {
|
||||||
|
console.log("🔍 DB에서 referenceTable/displayColumn 조회 시도:", masterDetailExcelConfig.masterTable);
|
||||||
|
const colResponse = await apiClient.get(
|
||||||
|
`/table-management/tables/${masterDetailExcelConfig.masterTable}/columns`
|
||||||
|
);
|
||||||
|
console.log("🔍 컬럼 조회 응답:", colResponse.data);
|
||||||
|
|
||||||
|
if (colResponse.data?.success && colResponse.data?.data?.columns) {
|
||||||
|
const colInfo = colResponse.data.data.columns.find(
|
||||||
|
(c: any) => (c.columnName || c.column_name) === field.columnName
|
||||||
|
);
|
||||||
|
console.log("🔍 찾은 컬럼 정보:", colInfo);
|
||||||
|
if (colInfo) {
|
||||||
|
if (!refTable) {
|
||||||
|
refTable = colInfo.referenceTable || colInfo.reference_table;
|
||||||
|
console.log("🔍 DB에서 가져온 refTable:", refTable);
|
||||||
|
}
|
||||||
|
if (!displayCol) {
|
||||||
|
displayCol = colInfo.displayColumn || colInfo.display_column;
|
||||||
|
console.log("🔍 DB에서 가져온 displayColumn:", displayCol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayColumn 저장 (Select 렌더링 시 사용)
|
||||||
|
if (displayCol) {
|
||||||
|
setEntityDisplayColumns((prev) => ({ ...prev, [field.columnName]: displayCol }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refTable) {
|
||||||
|
console.log("🔍 엔티티 데이터 조회:", refTable);
|
||||||
|
const response = await DynamicFormApi.getTableData(refTable, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1000,
|
||||||
|
});
|
||||||
|
console.log("🔍 엔티티 데이터 응답:", response);
|
||||||
|
// getTableData는 { success, data: [...] } 형식으로 반환
|
||||||
|
const rows = response.data?.rows || response.data;
|
||||||
|
if (response.success && rows && Array.isArray(rows)) {
|
||||||
|
setEntitySearchData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field.columnName]: rows,
|
||||||
|
}));
|
||||||
|
console.log("✅ 엔티티 데이터 로드 성공:", field.columnName, rows.length, "개");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("❌ 엔티티 필드의 referenceTable을 찾을 수 없음:", field.columnName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 엔티티 데이터 로드 실패:", field.columnName, error);
|
||||||
|
} finally {
|
||||||
|
setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (open && isMasterDetail && masterDetailExcelConfig?.masterSelectFields?.length > 0) {
|
||||||
|
loadEntityData();
|
||||||
|
}
|
||||||
|
}, [open, isMasterDetail, masterDetailExcelConfig]);
|
||||||
|
|
||||||
|
// 마스터-디테일 모드에서 마스터 필드 입력 여부 확인
|
||||||
|
const isSimpleMasterDetailMode = isMasterDetail && masterDetailExcelConfig;
|
||||||
|
const hasMasterSelectFields = isSimpleMasterDetailMode &&
|
||||||
|
(masterDetailExcelConfig?.masterSelectFields?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
// 마스터 필드가 모두 입력되었는지 확인
|
||||||
|
const isMasterFieldsValid = () => {
|
||||||
|
if (!hasMasterSelectFields) return true;
|
||||||
|
return masterDetailExcelConfig!.masterSelectFields!.every((field) => {
|
||||||
|
if (!field.required) return true;
|
||||||
|
const value = masterFieldValues[field.columnName];
|
||||||
|
return value !== undefined && value !== null && value !== "";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 파일 선택 핸들러
|
// 파일 선택 핸들러
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const selectedFile = e.target.files?.[0];
|
const selectedFile = e.target.files?.[0];
|
||||||
|
|
@ -184,50 +340,138 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
|
|
||||||
const loadTableSchema = async () => {
|
const loadTableSchema = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("🔍 테이블 스키마 로드 시작:", { tableName });
|
console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail, isSimpleMasterDetailMode });
|
||||||
|
|
||||||
const response = await getTableSchema(tableName);
|
let allColumns: TableColumn[] = [];
|
||||||
|
|
||||||
console.log("📊 테이블 스키마 응답:", response);
|
// 🆕 마스터-디테일 간단 모드: 디테일 테이블 컬럼만 로드 (마스터 필드는 UI에서 선택)
|
||||||
|
if (isSimpleMasterDetailMode && masterDetailRelation) {
|
||||||
|
const { detailTable, detailFkColumn } = masterDetailRelation;
|
||||||
|
|
||||||
|
console.log("📊 마스터-디테일 간단 모드 스키마 로드 (디테일만):", { detailTable });
|
||||||
|
|
||||||
if (response.success && response.data) {
|
// 디테일 테이블 스키마만 로드 (마스터 정보는 UI에서 선택)
|
||||||
// 자동 생성 컬럼 제외
|
const detailResponse = await getTableSchema(detailTable);
|
||||||
const filteredColumns = response.data.columns.filter(
|
if (detailResponse.success && detailResponse.data) {
|
||||||
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
|
// 설정된 detailExcelFields가 있으면 해당 필드만, 없으면 전체
|
||||||
);
|
const configuredFields = masterDetailExcelConfig?.detailExcelFields;
|
||||||
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", filteredColumns);
|
|
||||||
setSystemColumns(filteredColumns);
|
const detailCols = detailResponse.data.columns
|
||||||
|
.filter((col) => {
|
||||||
// 기존 매핑 템플릿 조회
|
// 자동 생성 컬럼, FK 컬럼 제외
|
||||||
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
|
if (AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())) return false;
|
||||||
const mappingResponse = await findMappingByColumns(tableName, excelColumns);
|
if (col.name === detailFkColumn) return false;
|
||||||
|
|
||||||
if (mappingResponse.success && mappingResponse.data) {
|
// 설정된 필드가 있으면 해당 필드만
|
||||||
// 저장된 매핑 템플릿이 있으면 자동 적용
|
if (configuredFields && configuredFields.length > 0) {
|
||||||
console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
|
return configuredFields.some((f) => f.columnName === col.name);
|
||||||
const savedMappings = mappingResponse.data.columnMappings;
|
}
|
||||||
|
return true;
|
||||||
const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
})
|
||||||
excelColumn: col,
|
.map((col) => {
|
||||||
systemColumn: savedMappings[col] || null,
|
// 설정에서 라벨 찾기
|
||||||
}));
|
const configField = configuredFields?.find((f) => f.columnName === col.name);
|
||||||
setColumnMappings(appliedMappings);
|
return {
|
||||||
setIsAutoMappingLoaded(true);
|
...col,
|
||||||
|
label: configField?.columnLabel || col.label || col.name,
|
||||||
const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
|
originalName: col.name,
|
||||||
toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
|
sourceTable: detailTable,
|
||||||
} else {
|
};
|
||||||
// 매핑 템플릿이 없으면 초기 상태로 설정
|
});
|
||||||
console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조");
|
allColumns = detailCols;
|
||||||
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
|
||||||
excelColumn: col,
|
|
||||||
systemColumn: null,
|
|
||||||
}));
|
|
||||||
setColumnMappings(initialMappings);
|
|
||||||
setIsAutoMappingLoaded(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("✅ 마스터-디테일 간단 모드 컬럼 로드 완료:", allColumns.length);
|
||||||
|
}
|
||||||
|
// 🆕 마스터-디테일 기존 모드: 두 테이블의 컬럼 합치기
|
||||||
|
else if (isMasterDetail && masterDetailRelation) {
|
||||||
|
const { masterTable, detailTable, detailFkColumn } = masterDetailRelation;
|
||||||
|
|
||||||
|
console.log("📊 마스터-디테일 스키마 로드:", { masterTable, detailTable });
|
||||||
|
|
||||||
|
// 마스터 테이블 스키마
|
||||||
|
const masterResponse = await getTableSchema(masterTable);
|
||||||
|
if (masterResponse.success && masterResponse.data) {
|
||||||
|
const masterCols = masterResponse.data.columns
|
||||||
|
.filter((col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()))
|
||||||
|
.map((col) => ({
|
||||||
|
...col,
|
||||||
|
// 유니크 키를 위해 테이블명 접두사 추가
|
||||||
|
name: `${masterTable}.${col.name}`,
|
||||||
|
label: `[마스터] ${col.label || col.name}`,
|
||||||
|
originalName: col.name,
|
||||||
|
sourceTable: masterTable,
|
||||||
|
}));
|
||||||
|
allColumns = [...allColumns, ...masterCols];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디테일 테이블 스키마 (FK 컬럼 제외)
|
||||||
|
const detailResponse = await getTableSchema(detailTable);
|
||||||
|
if (detailResponse.success && detailResponse.data) {
|
||||||
|
const detailCols = detailResponse.data.columns
|
||||||
|
.filter((col) =>
|
||||||
|
!AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) &&
|
||||||
|
col.name !== detailFkColumn // FK 컬럼 제외
|
||||||
|
)
|
||||||
|
.map((col) => ({
|
||||||
|
...col,
|
||||||
|
// 유니크 키를 위해 테이블명 접두사 추가
|
||||||
|
name: `${detailTable}.${col.name}`,
|
||||||
|
label: `[디테일] ${col.label || col.name}`,
|
||||||
|
originalName: col.name,
|
||||||
|
sourceTable: detailTable,
|
||||||
|
}));
|
||||||
|
allColumns = [...allColumns, ...detailCols];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ 마스터-디테일 컬럼 로드 완료:", allColumns.length);
|
||||||
} else {
|
} else {
|
||||||
console.error("❌ 테이블 스키마 로드 실패:", response);
|
// 기존 단일 테이블 모드
|
||||||
|
const response = await getTableSchema(tableName);
|
||||||
|
|
||||||
|
console.log("📊 테이블 스키마 응답:", response);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// 자동 생성 컬럼 제외
|
||||||
|
allColumns = response.data.columns.filter(
|
||||||
|
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error("❌ 테이블 스키마 로드 실패:", response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns);
|
||||||
|
setSystemColumns(allColumns);
|
||||||
|
|
||||||
|
// 기존 매핑 템플릿 조회
|
||||||
|
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
|
||||||
|
const mappingResponse = await findMappingByColumns(tableName, excelColumns);
|
||||||
|
|
||||||
|
if (mappingResponse.success && mappingResponse.data) {
|
||||||
|
// 저장된 매핑 템플릿이 있으면 자동 적용
|
||||||
|
console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
|
||||||
|
const savedMappings = mappingResponse.data.columnMappings;
|
||||||
|
|
||||||
|
const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
||||||
|
excelColumn: col,
|
||||||
|
systemColumn: savedMappings[col] || null,
|
||||||
|
}));
|
||||||
|
setColumnMappings(appliedMappings);
|
||||||
|
setIsAutoMappingLoaded(true);
|
||||||
|
|
||||||
|
const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
|
||||||
|
toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
|
||||||
|
} else {
|
||||||
|
// 매핑 템플릿이 없으면 초기 상태로 설정
|
||||||
|
console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조");
|
||||||
|
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
||||||
|
excelColumn: col,
|
||||||
|
systemColumn: null,
|
||||||
|
}));
|
||||||
|
setColumnMappings(initialMappings);
|
||||||
|
setIsAutoMappingLoaded(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 테이블 스키마 로드 실패:", error);
|
console.error("❌ 테이블 스키마 로드 실패:", error);
|
||||||
|
|
@ -239,18 +483,35 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
const handleAutoMapping = () => {
|
const handleAutoMapping = () => {
|
||||||
const newMappings = excelColumns.map((excelCol) => {
|
const newMappings = excelColumns.map((excelCol) => {
|
||||||
const normalizedExcelCol = excelCol.toLowerCase().trim();
|
const normalizedExcelCol = excelCol.toLowerCase().trim();
|
||||||
|
// [마스터], [디테일] 접두사 제거 후 비교
|
||||||
|
const cleanExcelCol = normalizedExcelCol.replace(/^\[(마스터|디테일)\]\s*/i, "");
|
||||||
|
|
||||||
// 1. 먼저 라벨로 매칭 시도
|
// 1. 먼저 라벨로 매칭 시도 (접두사 제거 후)
|
||||||
let matchedSystemCol = systemColumns.find(
|
let matchedSystemCol = systemColumns.find((sysCol) => {
|
||||||
(sysCol) =>
|
if (!sysCol.label) return false;
|
||||||
sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
|
// [마스터], [디테일] 접두사 제거 후 비교
|
||||||
);
|
const cleanLabel = sysCol.label.toLowerCase().trim().replace(/^\[(마스터|디테일)\]\s*/i, "");
|
||||||
|
return cleanLabel === normalizedExcelCol || cleanLabel === cleanExcelCol;
|
||||||
|
});
|
||||||
|
|
||||||
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
|
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
|
||||||
if (!matchedSystemCol) {
|
if (!matchedSystemCol) {
|
||||||
matchedSystemCol = systemColumns.find(
|
matchedSystemCol = systemColumns.find((sysCol) => {
|
||||||
(sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol
|
// 마스터-디테일 모드: originalName이 있으면 사용
|
||||||
);
|
const originalName = (sysCol as any).originalName;
|
||||||
|
const colName = originalName || sysCol.name;
|
||||||
|
return colName.toLowerCase().trim() === normalizedExcelCol || colName.toLowerCase().trim() === cleanExcelCol;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 여전히 매칭 안되면 전체 이름(테이블.컬럼)에서 컬럼 부분만 추출해서 비교
|
||||||
|
if (!matchedSystemCol) {
|
||||||
|
matchedSystemCol = systemColumns.find((sysCol) => {
|
||||||
|
// 테이블.컬럼 형식에서 컬럼만 추출
|
||||||
|
const nameParts = sysCol.name.split(".");
|
||||||
|
const colNameOnly = nameParts.length > 1 ? nameParts[1] : nameParts[0];
|
||||||
|
return colNameOnly.toLowerCase().trim() === normalizedExcelCol || colNameOnly.toLowerCase().trim() === cleanExcelCol;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -285,6 +546,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 마스터-디테일 간단 모드: 마스터 필드 유효성 검사
|
||||||
|
if (currentStep === 1 && hasMasterSelectFields && !isMasterFieldsValid()) {
|
||||||
|
toast.error("마스터 정보를 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1단계 → 2단계 전환 시: 빈 헤더 열 제외
|
// 1단계 → 2단계 전환 시: 빈 헤더 열 제외
|
||||||
if (currentStep === 1) {
|
if (currentStep === 1) {
|
||||||
// 빈 헤더가 아닌 열만 필터링
|
// 빈 헤더가 아닌 열만 필터링
|
||||||
|
|
@ -344,7 +611,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
const mappedRow: Record<string, any> = {};
|
const mappedRow: Record<string, any> = {};
|
||||||
columnMappings.forEach((mapping) => {
|
columnMappings.forEach((mapping) => {
|
||||||
if (mapping.systemColumn) {
|
if (mapping.systemColumn) {
|
||||||
mappedRow[mapping.systemColumn] = row[mapping.excelColumn];
|
// 마스터-디테일 모드: 테이블.컬럼 형식에서 컬럼명만 추출
|
||||||
|
let colName = mapping.systemColumn;
|
||||||
|
if (isMasterDetail && colName.includes(".")) {
|
||||||
|
colName = colName.split(".")[1];
|
||||||
|
}
|
||||||
|
mappedRow[colName] = row[mapping.excelColumn];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return mappedRow;
|
return mappedRow;
|
||||||
|
|
@ -364,60 +636,96 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
|
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
|
||||||
);
|
);
|
||||||
|
|
||||||
let successCount = 0;
|
// 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번)
|
||||||
let failCount = 0;
|
if (isSimpleMasterDetailMode && screenId && masterDetailRelation) {
|
||||||
|
console.log("📊 마스터-디테일 간단 모드 업로드:", {
|
||||||
|
masterDetailRelation,
|
||||||
|
masterFieldValues,
|
||||||
|
numberingRuleId: masterDetailExcelConfig?.numberingRuleId,
|
||||||
|
});
|
||||||
|
|
||||||
for (const row of filteredData) {
|
const uploadResult = await DynamicFormApi.uploadMasterDetailSimple(
|
||||||
try {
|
screenId,
|
||||||
if (uploadMode === "insert") {
|
filteredData,
|
||||||
const formData = { screenId: 0, tableName, data: row };
|
masterFieldValues,
|
||||||
const result = await DynamicFormApi.saveFormData(formData);
|
masterDetailExcelConfig?.numberingRuleId || undefined,
|
||||||
if (result.success) {
|
masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성
|
||||||
successCount++;
|
masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어
|
||||||
} else {
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (successCount > 0) {
|
|
||||||
toast.success(
|
|
||||||
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 매핑 템플릿 저장 (UPSERT - 자동 저장)
|
if (uploadResult.success && uploadResult.data) {
|
||||||
try {
|
const { masterInserted, detailInserted, generatedKey, errors } = uploadResult.data;
|
||||||
const mappingsToSave: Record<string, string | null> = {};
|
|
||||||
columnMappings.forEach((mapping) => {
|
toast.success(
|
||||||
mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
|
`마스터 ${masterInserted}건(${generatedKey || ""}), 디테일 ${detailInserted}건 처리되었습니다.` +
|
||||||
});
|
(errors?.length > 0 ? ` (오류: ${errors.length}건)` : "")
|
||||||
|
|
||||||
console.log("💾 매핑 템플릿 저장 중...", {
|
|
||||||
tableName,
|
|
||||||
excelColumns,
|
|
||||||
mappingsToSave,
|
|
||||||
});
|
|
||||||
const saveResult = await saveMappingTemplate(
|
|
||||||
tableName,
|
|
||||||
excelColumns,
|
|
||||||
mappingsToSave
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (saveResult.success) {
|
// 매핑 템플릿 저장
|
||||||
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
|
await saveMappingTemplateInternal();
|
||||||
} else {
|
|
||||||
console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 🆕 마스터-디테일 기존 모드 처리
|
||||||
|
else if (isMasterDetail && screenId && masterDetailRelation) {
|
||||||
|
console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation);
|
||||||
|
|
||||||
|
const uploadResult = await DynamicFormApi.uploadMasterDetailData(
|
||||||
|
screenId,
|
||||||
|
filteredData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uploadResult.success && uploadResult.data) {
|
||||||
|
const { masterInserted, masterUpdated, detailInserted, errors } = uploadResult.data;
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`마스터 ${masterInserted + masterUpdated}건, 디테일 ${detailInserted}건 처리되었습니다.` +
|
||||||
|
(errors.length > 0 ? ` (오류: ${errors.length}건)` : "")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 매핑 템플릿 저장
|
||||||
|
await saveMappingTemplateInternal();
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 기존 단일 테이블 업로드 로직
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const row of filteredData) {
|
||||||
|
try {
|
||||||
|
if (uploadMode === "insert") {
|
||||||
|
const formData = { screenId: 0, tableName, data: row };
|
||||||
|
const result = await DynamicFormApi.saveFormData(formData);
|
||||||
|
if (result.success) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failCount++;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess?.();
|
if (successCount > 0) {
|
||||||
} else {
|
toast.success(
|
||||||
toast.error("업로드에 실패했습니다.");
|
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 매핑 템플릿 저장
|
||||||
|
await saveMappingTemplateInternal();
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
toast.error("업로드에 실패했습니다.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 엑셀 업로드 실패:", error);
|
console.error("❌ 엑셀 업로드 실패:", error);
|
||||||
|
|
@ -427,6 +735,35 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 매핑 템플릿 저장 헬퍼 함수
|
||||||
|
const saveMappingTemplateInternal = async () => {
|
||||||
|
try {
|
||||||
|
const mappingsToSave: Record<string, string | null> = {};
|
||||||
|
columnMappings.forEach((mapping) => {
|
||||||
|
mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("💾 매핑 템플릿 저장 중...", {
|
||||||
|
tableName,
|
||||||
|
excelColumns,
|
||||||
|
mappingsToSave,
|
||||||
|
});
|
||||||
|
const saveResult = await saveMappingTemplate(
|
||||||
|
tableName,
|
||||||
|
excelColumns,
|
||||||
|
mappingsToSave
|
||||||
|
);
|
||||||
|
|
||||||
|
if (saveResult.success) {
|
||||||
|
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 모달 닫기 시 초기화
|
// 모달 닫기 시 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
|
@ -441,6 +778,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setExcelColumns([]);
|
setExcelColumns([]);
|
||||||
setSystemColumns([]);
|
setSystemColumns([]);
|
||||||
setColumnMappings([]);
|
setColumnMappings([]);
|
||||||
|
// 🆕 마스터-디테일 모드 초기화
|
||||||
|
setMasterFieldValues({});
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
|
@ -461,9 +800,21 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
<FileSpreadsheet className="h-5 w-5" />
|
<FileSpreadsheet className="h-5 w-5" />
|
||||||
엑셀 데이터 업로드
|
엑셀 데이터 업로드
|
||||||
|
{isMasterDetail && (
|
||||||
|
<span className="ml-2 rounded bg-blue-100 px-2 py-0.5 text-xs font-normal text-blue-700">
|
||||||
|
마스터-디테일
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요.
|
{isMasterDetail && masterDetailRelation ? (
|
||||||
|
<>
|
||||||
|
마스터({masterDetailRelation.masterTable}) + 디테일({masterDetailRelation.detailTable}) 구조입니다.
|
||||||
|
마스터 데이터는 중복 입력 시 병합됩니다.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요."
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -518,6 +869,87 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
{/* 1단계: 파일 선택 & 미리보기 (통합) */}
|
{/* 1단계: 파일 선택 & 미리보기 (통합) */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* 🆕 마스터-디테일 간단 모드: 마스터 필드 입력 */}
|
||||||
|
{hasMasterSelectFields && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{masterDetailExcelConfig?.masterSelectFields?.map((field) => (
|
||||||
|
<div key={field.columnName} className="space-y-1">
|
||||||
|
<Label className="text-xs">
|
||||||
|
{field.columnLabel}
|
||||||
|
{field.required && <span className="ml-1 text-destructive">*</span>}
|
||||||
|
</Label>
|
||||||
|
{field.inputType === "entity" ? (
|
||||||
|
<Select
|
||||||
|
value={masterFieldValues[field.columnName]?.toString() || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setMasterFieldValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field.columnName]: value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-xs">
|
||||||
|
<SelectValue placeholder={`${field.columnLabel} 선택`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{entitySearchLoading[field.columnName] ? (
|
||||||
|
<SelectItem value="loading" disabled>
|
||||||
|
로딩 중...
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
entitySearchData[field.columnName]?.map((item: any) => {
|
||||||
|
const keyValue = item[field.referenceColumn || "id"];
|
||||||
|
// displayColumn: 저장된 값 → DB에서 조회한 값 → referenceColumn → id
|
||||||
|
const displayColName =
|
||||||
|
field.displayColumn ||
|
||||||
|
entityDisplayColumns[field.columnName] ||
|
||||||
|
field.referenceColumn ||
|
||||||
|
"id";
|
||||||
|
const displayValue = item[displayColName] || keyValue;
|
||||||
|
return (
|
||||||
|
<SelectItem
|
||||||
|
key={keyValue}
|
||||||
|
value={keyValue?.toString()}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : field.inputType === "date" ? (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={masterFieldValues[field.columnName] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMasterFieldValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field.columnName]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="h-9 w-full rounded-md border px-3 text-xs"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={masterFieldValues[field.columnName] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMasterFieldValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field.columnName]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={field.columnLabel}
|
||||||
|
className="h-9 w-full rounded-md border px-3 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 파일 선택 영역 */}
|
{/* 파일 선택 영역 */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
|
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react";
|
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2, Sparkles } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -18,6 +18,8 @@ import { cn } from "@/lib/utils";
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||||
|
import { getNumberingRules } from "@/lib/api/numberingRule";
|
||||||
|
import type { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||||
import type { InsertActionNodeData } from "@/types/node-editor";
|
import type { InsertActionNodeData } from "@/types/node-editor";
|
||||||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||||
|
|
||||||
|
|
@ -89,6 +91,11 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
||||||
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
|
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
|
||||||
|
|
||||||
|
// 🔥 채번 규칙 관련 상태
|
||||||
|
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||||
|
const [numberingRulesLoading, setNumberingRulesLoading] = useState(false);
|
||||||
|
const [mappingNumberingRulesOpenState, setMappingNumberingRulesOpenState] = useState<boolean[]>([]);
|
||||||
|
|
||||||
// 데이터 변경 시 로컬 상태 업데이트
|
// 데이터 변경 시 로컬 상태 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayName(data.displayName || data.targetTable);
|
setDisplayName(data.displayName || data.targetTable);
|
||||||
|
|
@ -128,8 +135,33 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||||
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||||
|
setMappingNumberingRulesOpenState(new Array(fieldMappings.length).fill(false));
|
||||||
}, [fieldMappings.length]);
|
}, [fieldMappings.length]);
|
||||||
|
|
||||||
|
// 🔥 채번 규칙 로딩 (자동 생성 사용 시)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadNumberingRules = async () => {
|
||||||
|
setNumberingRulesLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getNumberingRules();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setNumberingRules(response.data);
|
||||||
|
console.log(`✅ 채번 규칙 ${response.data.length}개 로딩 완료`);
|
||||||
|
} else {
|
||||||
|
console.error("❌ 채번 규칙 로딩 실패:", response.error);
|
||||||
|
setNumberingRules([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 채번 규칙 로딩 오류:", error);
|
||||||
|
setNumberingRules([]);
|
||||||
|
} finally {
|
||||||
|
setNumberingRulesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadNumberingRules();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 🔥 외부 테이블 변경 시 컬럼 로드
|
// 🔥 외부 테이블 변경 시 컬럼 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
||||||
|
|
@ -540,6 +572,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
sourceField: null,
|
sourceField: null,
|
||||||
targetField: "",
|
targetField: "",
|
||||||
staticValue: undefined,
|
staticValue: undefined,
|
||||||
|
valueType: "source" as const, // 🔥 기본값: 소스 필드
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
setFieldMappings(newMappings);
|
setFieldMappings(newMappings);
|
||||||
|
|
@ -548,6 +581,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
// Combobox 열림 상태 배열 초기화
|
// Combobox 열림 상태 배열 초기화
|
||||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||||
|
setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveMapping = (index: number) => {
|
const handleRemoveMapping = (index: number) => {
|
||||||
|
|
@ -558,6 +592,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
// Combobox 열림 상태 배열도 업데이트
|
// Combobox 열림 상태 배열도 업데이트
|
||||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||||
|
setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||||
|
|
@ -586,6 +621,24 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
targetField: value,
|
targetField: value,
|
||||||
targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value,
|
targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value,
|
||||||
};
|
};
|
||||||
|
} else if (field === "valueType") {
|
||||||
|
// 🔥 값 생성 유형 변경 시 관련 필드 초기화
|
||||||
|
newMappings[index] = {
|
||||||
|
...newMappings[index],
|
||||||
|
valueType: value,
|
||||||
|
// 유형 변경 시 다른 유형의 값 초기화
|
||||||
|
...(value !== "source" && { sourceField: null, sourceFieldLabel: undefined }),
|
||||||
|
...(value !== "static" && { staticValue: undefined }),
|
||||||
|
...(value !== "autoGenerate" && { numberingRuleId: undefined, numberingRuleName: undefined }),
|
||||||
|
};
|
||||||
|
} else if (field === "numberingRuleId") {
|
||||||
|
// 🔥 채번 규칙 선택 시 이름도 함께 저장
|
||||||
|
const selectedRule = numberingRules.find((r) => r.ruleId === value);
|
||||||
|
newMappings[index] = {
|
||||||
|
...newMappings[index],
|
||||||
|
numberingRuleId: value,
|
||||||
|
numberingRuleName: selectedRule?.ruleName,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
newMappings[index] = {
|
newMappings[index] = {
|
||||||
...newMappings[index],
|
...newMappings[index],
|
||||||
|
|
@ -1165,54 +1218,203 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* 소스 필드 입력/선택 */}
|
{/* 🔥 값 생성 유형 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600">
|
<Label className="text-xs text-gray-600">값 생성 방식</Label>
|
||||||
소스 필드
|
<div className="mt-1 grid grid-cols-3 gap-1">
|
||||||
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - 직접 입력)</span>}
|
<button
|
||||||
</Label>
|
type="button"
|
||||||
{hasRestAPISource ? (
|
onClick={() => handleMappingChange(index, "valueType", "source")}
|
||||||
// REST API 소스인 경우: 직접 입력
|
className={cn(
|
||||||
|
"rounded border px-2 py-1 text-xs transition-all",
|
||||||
|
(mapping.valueType === "source" || !mapping.valueType)
|
||||||
|
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||||
|
: "border-gray-200 hover:border-gray-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
소스 필드
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMappingChange(index, "valueType", "static")}
|
||||||
|
className={cn(
|
||||||
|
"rounded border px-2 py-1 text-xs transition-all",
|
||||||
|
mapping.valueType === "static"
|
||||||
|
? "border-orange-500 bg-orange-50 text-orange-700"
|
||||||
|
: "border-gray-200 hover:border-gray-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
고정값
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMappingChange(index, "valueType", "autoGenerate")}
|
||||||
|
className={cn(
|
||||||
|
"rounded border px-2 py-1 text-xs transition-all flex items-center justify-center gap-1",
|
||||||
|
mapping.valueType === "autoGenerate"
|
||||||
|
? "border-purple-500 bg-purple-50 text-purple-700"
|
||||||
|
: "border-gray-200 hover:border-gray-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
자동생성
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 🔥 소스 필드 입력/선택 (valueType === "source" 일 때만) */}
|
||||||
|
{(mapping.valueType === "source" || !mapping.valueType) && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">
|
||||||
|
소스 필드
|
||||||
|
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - 직접 입력)</span>}
|
||||||
|
</Label>
|
||||||
|
{hasRestAPISource ? (
|
||||||
|
// REST API 소스인 경우: 직접 입력
|
||||||
|
<Input
|
||||||
|
value={mapping.sourceField || ""}
|
||||||
|
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
|
||||||
|
placeholder="필드명 입력 (예: userId, userName)"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// 일반 소스인 경우: Combobox 선택
|
||||||
|
<Popover
|
||||||
|
open={mappingSourceFieldsOpenState[index]}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
const newState = [...mappingSourceFieldsOpenState];
|
||||||
|
newState[index] = open;
|
||||||
|
setMappingSourceFieldsOpenState(newState);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={mappingSourceFieldsOpenState[index]}
|
||||||
|
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
{mapping.sourceField
|
||||||
|
? (() => {
|
||||||
|
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{field?.label || mapping.sourceField}
|
||||||
|
</span>
|
||||||
|
{field?.label && field.label !== field.name && (
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{field.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
: "소스 필드 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs sm:text-sm">
|
||||||
|
필드를 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{sourceFields.map((field) => (
|
||||||
|
<CommandItem
|
||||||
|
key={field.name}
|
||||||
|
value={field.name}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
handleMappingChange(index, "sourceField", currentValue || null);
|
||||||
|
const newState = [...mappingSourceFieldsOpenState];
|
||||||
|
newState[index] = false;
|
||||||
|
setMappingSourceFieldsOpenState(newState);
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{field.label || field.name}</span>
|
||||||
|
{field.label && field.label !== field.name && (
|
||||||
|
<span className="text-muted-foreground font-mono text-[10px]">
|
||||||
|
{field.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
{hasRestAPISource && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">API 응답 JSON의 필드명을 입력하세요</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 🔥 고정값 입력 (valueType === "static" 일 때) */}
|
||||||
|
{mapping.valueType === "static" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">고정값</Label>
|
||||||
<Input
|
<Input
|
||||||
value={mapping.sourceField || ""}
|
value={mapping.staticValue || ""}
|
||||||
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
|
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
||||||
placeholder="필드명 입력 (예: userId, userName)"
|
placeholder="고정값 입력"
|
||||||
className="mt-1 h-8 text-xs"
|
className="mt-1 h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
// 일반 소스인 경우: Combobox 선택
|
)}
|
||||||
|
|
||||||
|
{/* 🔥 채번 규칙 선택 (valueType === "autoGenerate" 일 때) */}
|
||||||
|
{mapping.valueType === "autoGenerate" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">
|
||||||
|
채번 규칙
|
||||||
|
{numberingRulesLoading && <span className="ml-1 text-gray-400">(로딩 중...)</span>}
|
||||||
|
</Label>
|
||||||
<Popover
|
<Popover
|
||||||
open={mappingSourceFieldsOpenState[index]}
|
open={mappingNumberingRulesOpenState[index]}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
const newState = [...mappingSourceFieldsOpenState];
|
const newState = [...mappingNumberingRulesOpenState];
|
||||||
newState[index] = open;
|
newState[index] = open;
|
||||||
setMappingSourceFieldsOpenState(newState);
|
setMappingNumberingRulesOpenState(newState);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={mappingSourceFieldsOpenState[index]}
|
aria-expanded={mappingNumberingRulesOpenState[index]}
|
||||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
|
disabled={numberingRulesLoading || numberingRules.length === 0}
|
||||||
>
|
>
|
||||||
{mapping.sourceField
|
{mapping.numberingRuleId
|
||||||
? (() => {
|
? (() => {
|
||||||
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
const rule = numberingRules.find((r) => r.ruleId === mapping.numberingRuleId);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
<div className="flex items-center gap-2 overflow-hidden">
|
||||||
|
<Sparkles className="h-3 w-3 text-purple-500" />
|
||||||
<span className="truncate font-medium">
|
<span className="truncate font-medium">
|
||||||
{field?.label || mapping.sourceField}
|
{rule?.ruleName || mapping.numberingRuleName || mapping.numberingRuleId}
|
||||||
</span>
|
</span>
|
||||||
{field?.label && field.label !== field.name && (
|
|
||||||
<span className="text-muted-foreground font-mono text-xs">
|
|
||||||
{field.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
: "소스 필드 선택"}
|
: "채번 규칙 선택"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -1222,37 +1424,36 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
align="start"
|
align="start"
|
||||||
>
|
>
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
|
<CommandInput placeholder="채번 규칙 검색..." className="text-xs sm:text-sm" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty className="text-xs sm:text-sm">
|
<CommandEmpty className="text-xs sm:text-sm">
|
||||||
필드를 찾을 수 없습니다.
|
채번 규칙을 찾을 수 없습니다.
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{sourceFields.map((field) => (
|
{numberingRules.map((rule) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={field.name}
|
key={rule.ruleId}
|
||||||
value={field.name}
|
value={rule.ruleId}
|
||||||
onSelect={(currentValue) => {
|
onSelect={(currentValue) => {
|
||||||
handleMappingChange(index, "sourceField", currentValue || null);
|
handleMappingChange(index, "numberingRuleId", currentValue);
|
||||||
const newState = [...mappingSourceFieldsOpenState];
|
const newState = [...mappingNumberingRulesOpenState];
|
||||||
newState[index] = false;
|
newState[index] = false;
|
||||||
setMappingSourceFieldsOpenState(newState);
|
setMappingNumberingRulesOpenState(newState);
|
||||||
}}
|
}}
|
||||||
className="text-xs sm:text-sm"
|
className="text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"mr-2 h-4 w-4",
|
||||||
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
|
mapping.numberingRuleId === rule.ruleId ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{field.label || field.name}</span>
|
<span className="font-medium">{rule.ruleName}</span>
|
||||||
{field.label && field.label !== field.name && (
|
<span className="text-muted-foreground font-mono text-[10px]">
|
||||||
<span className="text-muted-foreground font-mono text-[10px]">
|
{rule.ruleId}
|
||||||
{field.name}
|
{rule.tableName && ` - ${rule.tableName}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1261,11 +1462,13 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
{numberingRules.length === 0 && !numberingRulesLoading && (
|
||||||
{hasRestAPISource && (
|
<p className="mt-1 text-xs text-orange-600">
|
||||||
<p className="mt-1 text-xs text-gray-500">API 응답 JSON의 필드명을 입력하세요</p>
|
등록된 채번 규칙이 없습니다. 시스템 관리에서 먼저 채번 규칙을 생성하세요.
|
||||||
)}
|
</p>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-center py-1">
|
<div className="flex items-center justify-center py-1">
|
||||||
<ArrowRight className="h-4 w-4 text-green-600" />
|
<ArrowRight className="h-4 w-4 text-green-600" />
|
||||||
|
|
@ -1400,18 +1603,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 정적 값 */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">정적 값 (선택)</Label>
|
|
||||||
<Input
|
|
||||||
value={mapping.staticValue || ""}
|
|
||||||
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
|
||||||
placeholder="소스 필드 대신 고정 값 사용"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-400">소스 필드가 비어있을 때만 사용됩니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1428,9 +1619,8 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
|
|
||||||
{/* 안내 */}
|
{/* 안내 */}
|
||||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
||||||
✅ 테이블과 필드는 실제 데이터베이스에서 조회됩니다.
|
<p>테이블과 필드는 실제 데이터베이스에서 조회됩니다.</p>
|
||||||
<br />
|
<p className="mt-1">값 생성 방식: 소스 필드(입력값 연결) / 고정값(직접 입력) / 자동생성(채번 규칙)</p>
|
||||||
💡 소스 필드가 없으면 정적 값이 사용됩니다.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -671,9 +671,11 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
console.log("🗑️ 품목 삭제:", deletedItem);
|
console.log("🗑️ 품목 삭제:", deletedItem);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// screenId 전달하여 제어관리 실행 가능하도록 함
|
||||||
const response = await dynamicFormApi.deleteFormDataFromTable(
|
const response = await dynamicFormApi.deleteFormDataFromTable(
|
||||||
deletedItem.id,
|
deletedItem.id,
|
||||||
screenData.screenInfo.tableName,
|
screenData.screenInfo.tableName,
|
||||||
|
modalState.screenId || screenData.screenInfo?.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
|
|
||||||
|
|
@ -1676,7 +1676,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
try {
|
try {
|
||||||
// console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
|
// console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
|
||||||
|
|
||||||
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName);
|
// screenId 전달하여 제어관리 실행 가능하도록 함
|
||||||
|
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName, screenInfo?.id);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert("삭제되었습니다.");
|
alert("삭제되었습니다.");
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -202,14 +202,19 @@ export class DynamicFormApi {
|
||||||
* 실제 테이블에서 폼 데이터 삭제
|
* 실제 테이블에서 폼 데이터 삭제
|
||||||
* @param id 레코드 ID
|
* @param id 레코드 ID
|
||||||
* @param tableName 테이블명
|
* @param tableName 테이블명
|
||||||
|
* @param screenId 화면 ID (제어관리 실행용, 선택사항)
|
||||||
* @returns 삭제 결과
|
* @returns 삭제 결과
|
||||||
*/
|
*/
|
||||||
static async deleteFormDataFromTable(id: string | number, tableName: string): Promise<ApiResponse<void>> {
|
static async deleteFormDataFromTable(
|
||||||
|
id: string | number,
|
||||||
|
tableName: string,
|
||||||
|
screenId?: number
|
||||||
|
): Promise<ApiResponse<void>> {
|
||||||
try {
|
try {
|
||||||
console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName });
|
console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName, screenId });
|
||||||
|
|
||||||
await apiClient.delete(`/dynamic-form/${id}`, {
|
await apiClient.delete(`/dynamic-form/${id}`, {
|
||||||
data: { tableName },
|
data: { tableName, screenId },
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공");
|
console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공");
|
||||||
|
|
@ -556,6 +561,192 @@ export class DynamicFormApi {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 마스터-디테일 엑셀 API
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 관계 정보 조회
|
||||||
|
* @param screenId 화면 ID
|
||||||
|
* @returns 마스터-디테일 관계 정보 (null이면 마스터-디테일 구조 아님)
|
||||||
|
*/
|
||||||
|
static async getMasterDetailRelation(screenId: number): Promise<ApiResponse<MasterDetailRelation | null>> {
|
||||||
|
try {
|
||||||
|
console.log("🔍 마스터-디테일 관계 조회:", screenId);
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/data/master-detail/relation/${screenId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data?.data || null,
|
||||||
|
message: response.data?.message || "조회 완료",
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 마스터-디테일 관계 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
message: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 다운로드 데이터 조회
|
||||||
|
* @param screenId 화면 ID
|
||||||
|
* @param filters 필터 조건
|
||||||
|
* @returns JOIN된 플랫 데이터
|
||||||
|
*/
|
||||||
|
static async getMasterDetailDownloadData(
|
||||||
|
screenId: number,
|
||||||
|
filters?: Record<string, any>
|
||||||
|
): Promise<ApiResponse<MasterDetailDownloadData>> {
|
||||||
|
try {
|
||||||
|
console.log("📥 마스터-디테일 다운로드 데이터 조회:", { screenId, filters });
|
||||||
|
|
||||||
|
const response = await apiClient.post(`/data/master-detail/download`, {
|
||||||
|
screenId,
|
||||||
|
filters,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data?.data,
|
||||||
|
message: "데이터 조회 완료",
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 마스터-디테일 다운로드 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 업로드
|
||||||
|
* @param screenId 화면 ID
|
||||||
|
* @param data 엑셀에서 읽은 플랫 데이터
|
||||||
|
* @returns 업로드 결과
|
||||||
|
*/
|
||||||
|
static async uploadMasterDetailData(
|
||||||
|
screenId: number,
|
||||||
|
data: Record<string, any>[]
|
||||||
|
): Promise<ApiResponse<MasterDetailUploadResult>> {
|
||||||
|
try {
|
||||||
|
console.log("📤 마스터-디테일 업로드:", { screenId, rowCount: data.length });
|
||||||
|
|
||||||
|
const response = await apiClient.post(`/data/master-detail/upload`, {
|
||||||
|
screenId,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: response.data?.success,
|
||||||
|
data: response.data?.data,
|
||||||
|
message: response.data?.message,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 마스터-디테일 업로드 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 간단 모드 엑셀 업로드
|
||||||
|
* - 마스터 정보는 UI에서 선택
|
||||||
|
* - 디테일 정보만 엑셀에서 업로드
|
||||||
|
* - 채번 규칙을 통해 마스터 키 자동 생성
|
||||||
|
* @param screenId 화면 ID
|
||||||
|
* @param detailData 디테일 데이터 배열
|
||||||
|
* @param masterFieldValues UI에서 선택한 마스터 필드 값
|
||||||
|
* @param numberingRuleId 채번 규칙 ID (optional)
|
||||||
|
* @param afterUploadFlowId 업로드 후 실행할 제어 ID (optional, 하위 호환성)
|
||||||
|
* @param afterUploadFlows 업로드 후 실행할 제어 목록 (optional)
|
||||||
|
* @returns 업로드 결과
|
||||||
|
*/
|
||||||
|
static async uploadMasterDetailSimple(
|
||||||
|
screenId: number,
|
||||||
|
detailData: Record<string, any>[],
|
||||||
|
masterFieldValues: Record<string, any>,
|
||||||
|
numberingRuleId?: string,
|
||||||
|
afterUploadFlowId?: string,
|
||||||
|
afterUploadFlows?: Array<{ flowId: string; order: number }>
|
||||||
|
): Promise<ApiResponse<MasterDetailSimpleUploadResult>> {
|
||||||
|
try {
|
||||||
|
console.log("📤 마스터-디테일 간단 모드 업로드:", {
|
||||||
|
screenId,
|
||||||
|
detailRowCount: detailData.length,
|
||||||
|
masterFieldValues,
|
||||||
|
numberingRuleId,
|
||||||
|
afterUploadFlows: afterUploadFlows?.length || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await apiClient.post(`/data/master-detail/upload-simple`, {
|
||||||
|
screenId,
|
||||||
|
detailData,
|
||||||
|
masterFieldValues,
|
||||||
|
numberingRuleId,
|
||||||
|
afterUploadFlowId,
|
||||||
|
afterUploadFlows,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: response.data?.success,
|
||||||
|
data: response.data?.data,
|
||||||
|
message: response.data?.message,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 마스터-디테일 간단 모드 업로드 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터-디테일 관계 타입
|
||||||
|
export interface MasterDetailRelation {
|
||||||
|
masterTable: string;
|
||||||
|
detailTable: string;
|
||||||
|
masterKeyColumn: string;
|
||||||
|
detailFkColumn: string;
|
||||||
|
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||||
|
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터-디테일 다운로드 데이터 타입
|
||||||
|
export interface MasterDetailDownloadData {
|
||||||
|
headers: string[];
|
||||||
|
columns: string[];
|
||||||
|
data: Record<string, any>[];
|
||||||
|
masterColumns: string[];
|
||||||
|
detailColumns: string[];
|
||||||
|
joinKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터-디테일 업로드 결과 타입
|
||||||
|
export interface MasterDetailUploadResult {
|
||||||
|
success: boolean;
|
||||||
|
masterInserted: number;
|
||||||
|
masterUpdated: number;
|
||||||
|
detailInserted: number;
|
||||||
|
detailDeleted: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 마스터-디테일 간단 모드 업로드 결과 타입
|
||||||
|
export interface MasterDetailSimpleUploadResult {
|
||||||
|
success: boolean;
|
||||||
|
masterInserted: number;
|
||||||
|
detailInserted: number;
|
||||||
|
generatedKey: string; // 생성된 마스터 키
|
||||||
|
errors?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 편의를 위한 기본 export
|
// 편의를 위한 기본 export
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
if (item[underscoreKey] !== undefined) {
|
if (item[underscoreKey] !== undefined) {
|
||||||
return item[underscoreKey];
|
return item[underscoreKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6️⃣ 🆕 모든 키에서 _fieldName으로 끝나는 키 찾기
|
||||||
|
// 예: partner_id_customer_name (프론트엔드가 customer_id로 추론했지만 실제는 partner_id인 경우)
|
||||||
|
const matchingKey = Object.keys(item).find((key) => key.endsWith(`_${fieldName}`));
|
||||||
|
if (matchingKey && item[matchingKey] !== undefined) {
|
||||||
|
return item[matchingKey];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -3795,3 +3795,4 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -967,11 +967,11 @@ export class ButtonActionExecutor {
|
||||||
deletedItemIds,
|
deletedItemIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 삭제 API 호출
|
// 삭제 API 호출 - screenId 전달하여 제어관리 실행 가능하도록 함
|
||||||
for (const itemId of deletedItemIds) {
|
for (const itemId of deletedItemIds) {
|
||||||
try {
|
try {
|
||||||
console.log(`🗑️ [handleSave] 항목 삭제 중: ${itemId} from ${targetTable}`);
|
console.log(`🗑️ [handleSave] 항목 삭제 중: ${itemId} from ${targetTable}`);
|
||||||
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(itemId, targetTable);
|
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(itemId, targetTable, context.screenId);
|
||||||
if (deleteResult.success) {
|
if (deleteResult.success) {
|
||||||
console.log(`✅ [handleSave] 항목 삭제 완료: ${itemId}`);
|
console.log(`✅ [handleSave] 항목 삭제 완료: ${itemId}`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1967,7 +1967,8 @@ export class ButtonActionExecutor {
|
||||||
for (const deletedItem of deletedItems) {
|
for (const deletedItem of deletedItems) {
|
||||||
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`);
|
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`);
|
||||||
|
|
||||||
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deletedItem.id, saveTableName);
|
// screenId 전달하여 제어관리 실행 가능하도록 함
|
||||||
|
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deletedItem.id, saveTableName, context.screenId);
|
||||||
|
|
||||||
if (!deleteResult.success) {
|
if (!deleteResult.success) {
|
||||||
throw new Error(deleteResult.message || "품목 삭제 실패");
|
throw new Error(deleteResult.message || "품목 삭제 실패");
|
||||||
|
|
@ -2434,7 +2435,8 @@ export class ButtonActionExecutor {
|
||||||
if (deleteId) {
|
if (deleteId) {
|
||||||
console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId });
|
console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId });
|
||||||
|
|
||||||
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName);
|
// screenId 전달하여 제어관리 실행 가능하도록 함
|
||||||
|
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName, screenId);
|
||||||
if (!deleteResult.success) {
|
if (!deleteResult.success) {
|
||||||
throw new Error(`ID ${deleteId} 삭제 실패: ${deleteResult.message}`);
|
throw new Error(`ID ${deleteId} 삭제 실패: ${deleteResult.message}`);
|
||||||
}
|
}
|
||||||
|
|
@ -2469,8 +2471,8 @@ export class ButtonActionExecutor {
|
||||||
if (tableName && screenId && formData.id) {
|
if (tableName && screenId && formData.id) {
|
||||||
console.log("단일 데이터 삭제:", { tableName, screenId, id: formData.id });
|
console.log("단일 데이터 삭제:", { tableName, screenId, id: formData.id });
|
||||||
|
|
||||||
// 실제 삭제 API 호출
|
// 실제 삭제 API 호출 - screenId 전달하여 제어관리 실행 가능하도록 함
|
||||||
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(formData.id, tableName);
|
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(formData.id, tableName, screenId);
|
||||||
|
|
||||||
if (!deleteResult.success) {
|
if (!deleteResult.success) {
|
||||||
throw new Error(deleteResult.message || "삭제에 실패했습니다.");
|
throw new Error(deleteResult.message || "삭제에 실패했습니다.");
|
||||||
|
|
@ -4251,7 +4253,8 @@ export class ButtonActionExecutor {
|
||||||
throw new Error("삭제할 항목의 ID를 찾을 수 없습니다.");
|
throw new Error("삭제할 항목의 ID를 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await DynamicFormApi.deleteFormDataFromTable(deleteId, context.tableName);
|
// screenId 전달하여 제어관리 실행 가능하도록 함
|
||||||
|
const result = await DynamicFormApi.deleteFormDataFromTable(deleteId, context.tableName, context.screenId);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log("✅ 삭제 성공:", result);
|
console.log("✅ 삭제 성공:", result);
|
||||||
|
|
@ -4481,8 +4484,67 @@ export class ButtonActionExecutor {
|
||||||
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
||||||
|
|
||||||
let dataToExport: any[] = [];
|
let dataToExport: any[] = [];
|
||||||
|
let visibleColumns: string[] | undefined = undefined;
|
||||||
|
let columnLabels: Record<string, string> | undefined = undefined;
|
||||||
|
|
||||||
// ✅ 항상 API 호출로 필터링된 전체 데이터 가져오기
|
// 🆕 마스터-디테일 구조 확인 및 처리
|
||||||
|
if (context.screenId) {
|
||||||
|
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||||
|
const relationResponse = await DynamicFormApi.getMasterDetailRelation(context.screenId);
|
||||||
|
|
||||||
|
if (relationResponse.success && relationResponse.data) {
|
||||||
|
// 마스터-디테일 구조인 경우 전용 API 사용
|
||||||
|
console.log("📊 마스터-디테일 엑셀 다운로드:", relationResponse.data);
|
||||||
|
|
||||||
|
const downloadResponse = await DynamicFormApi.getMasterDetailDownloadData(
|
||||||
|
context.screenId,
|
||||||
|
context.filterConditions
|
||||||
|
);
|
||||||
|
|
||||||
|
if (downloadResponse.success && downloadResponse.data) {
|
||||||
|
dataToExport = downloadResponse.data.data;
|
||||||
|
visibleColumns = downloadResponse.data.columns;
|
||||||
|
|
||||||
|
// 헤더와 컬럼 매핑
|
||||||
|
columnLabels = {};
|
||||||
|
downloadResponse.data.columns.forEach((col: string, index: number) => {
|
||||||
|
columnLabels![col] = downloadResponse.data.headers[index] || col;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${dataToExport.length}행`);
|
||||||
|
} else {
|
||||||
|
toast.error("마스터-디테일 데이터 조회에 실패했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터-디테일 데이터 변환 및 다운로드
|
||||||
|
if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) {
|
||||||
|
dataToExport = dataToExport.map((row: any) => {
|
||||||
|
const filteredRow: Record<string, any> = {};
|
||||||
|
visibleColumns!.forEach((columnName: string) => {
|
||||||
|
const label = columnLabels?.[columnName] || columnName;
|
||||||
|
filteredRow[label] = row[columnName];
|
||||||
|
});
|
||||||
|
return filteredRow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일명 생성
|
||||||
|
let defaultFileName = relationResponse.data.masterTable || "데이터";
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const menuName = localStorage.getItem("currentMenuName");
|
||||||
|
if (menuName) defaultFileName = menuName;
|
||||||
|
}
|
||||||
|
const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
||||||
|
const sheetName = config.excelSheetName || "Sheet1";
|
||||||
|
|
||||||
|
await exportToExcel(dataToExport, fileName, sheetName, true);
|
||||||
|
toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 기존 로직: 단일 테이블 처리
|
||||||
if (context.tableName) {
|
if (context.tableName) {
|
||||||
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
|
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
|
||||||
const storedData = tableDisplayStore.getTableData(context.tableName);
|
const storedData = tableDisplayStore.getTableData(context.tableName);
|
||||||
|
|
@ -4574,8 +4636,7 @@ export class ButtonActionExecutor {
|
||||||
const includeHeaders = config.excelIncludeHeaders !== false;
|
const includeHeaders = config.excelIncludeHeaders !== false;
|
||||||
|
|
||||||
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
|
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
|
||||||
let visibleColumns: string[] | undefined = undefined;
|
// visibleColumns, columnLabels는 함수 상단에서 이미 선언됨
|
||||||
let columnLabels: Record<string, string> | undefined = undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
|
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
|
||||||
|
|
@ -4776,8 +4837,53 @@ export class ButtonActionExecutor {
|
||||||
context,
|
context,
|
||||||
userId: context.userId,
|
userId: context.userId,
|
||||||
tableName: context.tableName,
|
tableName: context.tableName,
|
||||||
|
screenId: context.screenId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🆕 마스터-디테일 구조 확인 (화면에 분할 패널이 있으면 자동 감지)
|
||||||
|
let isMasterDetail = false;
|
||||||
|
let masterDetailRelation: any = null;
|
||||||
|
let masterDetailExcelConfig: any = undefined;
|
||||||
|
|
||||||
|
// 화면 레이아웃에서 분할 패널 자동 감지
|
||||||
|
if (context.screenId) {
|
||||||
|
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||||
|
const relationResponse = await DynamicFormApi.getMasterDetailRelation(context.screenId);
|
||||||
|
|
||||||
|
if (relationResponse.success && relationResponse.data) {
|
||||||
|
isMasterDetail = true;
|
||||||
|
masterDetailRelation = relationResponse.data;
|
||||||
|
|
||||||
|
// 버튼 설정에서 채번 규칙 등 추가 설정 가져오기
|
||||||
|
if (config.masterDetailExcel) {
|
||||||
|
masterDetailExcelConfig = {
|
||||||
|
...config.masterDetailExcel,
|
||||||
|
// 분할 패널에서 감지한 테이블 정보로 덮어쓰기
|
||||||
|
masterTable: relationResponse.data.masterTable,
|
||||||
|
detailTable: relationResponse.data.detailTable,
|
||||||
|
masterKeyColumn: relationResponse.data.masterKeyColumn,
|
||||||
|
detailFkColumn: relationResponse.data.detailFkColumn,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 버튼 설정이 없으면 분할 패널 정보만 사용
|
||||||
|
masterDetailExcelConfig = {
|
||||||
|
masterTable: relationResponse.data.masterTable,
|
||||||
|
detailTable: relationResponse.data.detailTable,
|
||||||
|
masterKeyColumn: relationResponse.data.masterKeyColumn,
|
||||||
|
detailFkColumn: relationResponse.data.detailFkColumn,
|
||||||
|
simpleMode: true, // 기본값으로 간단 모드 사용
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📊 마스터-디테일 구조 자동 감지:", {
|
||||||
|
masterTable: relationResponse.data.masterTable,
|
||||||
|
detailTable: relationResponse.data.detailTable,
|
||||||
|
masterKeyColumn: relationResponse.data.masterKeyColumn,
|
||||||
|
numberingRuleId: masterDetailExcelConfig?.numberingRuleId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 동적 import로 모달 컴포넌트 로드
|
// 동적 import로 모달 컴포넌트 로드
|
||||||
const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal");
|
const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal");
|
||||||
const { createRoot } = await import("react-dom/client");
|
const { createRoot } = await import("react-dom/client");
|
||||||
|
|
@ -4820,6 +4926,11 @@ export class ButtonActionExecutor {
|
||||||
uploadMode: config.excelUploadMode || "insert",
|
uploadMode: config.excelUploadMode || "insert",
|
||||||
keyColumn: config.excelKeyColumn,
|
keyColumn: config.excelKeyColumn,
|
||||||
userId: context.userId,
|
userId: context.userId,
|
||||||
|
// 🆕 마스터-디테일 관련 props
|
||||||
|
screenId: context.screenId,
|
||||||
|
isMasterDetail,
|
||||||
|
masterDetailRelation,
|
||||||
|
masterDetailExcelConfig,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨
|
// 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨
|
||||||
context.onRefresh?.();
|
context.onRefresh?.();
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,11 @@ export interface InsertActionNodeData {
|
||||||
targetField: string;
|
targetField: string;
|
||||||
targetFieldLabel?: string;
|
targetFieldLabel?: string;
|
||||||
staticValue?: any;
|
staticValue?: any;
|
||||||
|
// 🔥 값 생성 유형 추가
|
||||||
|
valueType?: "source" | "static" | "autoGenerate"; // 소스 필드 / 고정값 / 자동 생성
|
||||||
|
// 자동 생성 옵션 (valueType === "autoGenerate" 일 때)
|
||||||
|
numberingRuleId?: string; // 채번 규칙 ID
|
||||||
|
numberingRuleName?: string; // 채번 규칙명 (표시용)
|
||||||
}>;
|
}>;
|
||||||
options: {
|
options: {
|
||||||
batchSize?: number;
|
batchSize?: number;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue