엑셀 업로드,다운로드 기능 개선

This commit is contained in:
kjs
2026-01-09 15:32:02 +09:00
parent ee3a648917
commit aa0698556e
9 changed files with 1619 additions and 387 deletions

View File

@@ -187,6 +187,69 @@ router.post(
}
);
/**
* 마스터-디테일 간단 모드 엑셀 업로드
* - 마스터 정보는 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 } = 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);
// 업로드 실행
const result = await masterDetailExcelService.uploadSimple(
parseInt(screenId),
detailData,
masterFieldValues || {},
numberingRuleId,
companyCode,
userId
);
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
// ================================

View File

@@ -283,10 +283,81 @@ class MasterDetailExcelService {
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 masterSelectCols = masterColumns.map(col => `m."${col.name}"`);
const detailSelectCols = detailColumns.map(col => `d."${col.name}"`);
const selectClause = [...masterSelectCols, ...detailSelectCols].join(", ");
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[] = [];
@@ -304,6 +375,8 @@ class MasterDetailExcelService {
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";
@@ -325,6 +398,7 @@ class MasterDetailExcelService {
LEFT JOIN "${detailTable}" d
ON m."${masterKeyColumn}" = d."${detailFkColumn}"
AND m.company_code = d.company_code
${entityJoinClauses}
${whereClause}
ORDER BY m."${masterKeyColumn}", d.id
`;
@@ -353,6 +427,37 @@ class MasterDetailExcelService {
}
}
/**
* 특정 테이블에서 참조 테이블로의 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;
}
}
/**
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
*
@@ -521,6 +626,168 @@ class MasterDetailExcelService {
return result;
}
/**
* 마스터-디테일 간단 모드 업로드
*
* 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함
* 채번 규칙을 통해 마스터 키 자동 생성
*
* @param screenId 화면 ID
* @param detailData 디테일 데이터 배열
* @param masterFieldValues UI에서 선택한 마스터 필드 값
* @param numberingRuleId 채번 규칙 ID (optional)
* @param companyCode 회사 코드
* @param userId 사용자 ID
*/
async uploadSimple(
screenId: number,
detailData: Record<string, any>[],
masterFieldValues: Record<string, any>,
numberingRuleId: string | undefined,
companyCode: string,
userId: string
): Promise<{
success: boolean;
masterInserted: number;
detailInserted: number;
generatedKey: string;
errors: string[];
}> {
const result = {
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,
});
} 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();

View File

@@ -2761,33 +2761,64 @@ export class TableManagementService {
);
for (const additionalColumn of options.additionalJoinColumns) {
// 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기)
const baseJoinConfig = joinConfigs.find(
// 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기
let baseJoinConfig = joinConfigs.find(
(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) {
// joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name)
// sourceColumn을 제거한 나머지 부분이 실제 컬럼명
const sourceColumn = baseJoinConfig.sourceColumn; // dept_code
const joinAlias = additionalColumn.joinAlias; // dept_code_company_name
const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name
// joinAlias에서 실제 컬럼명 추출
const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id)
const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_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(`🔍 조인 컬럼 상세 분석:`, {
sourceColumn,
joinAlias,
frontendSourceColumn,
originalJoinAlias,
correctedJoinAlias,
actualColumnName,
referenceTable: additionalColumn.sourceTable,
referenceTable: (additionalColumn as any).referenceTable,
});
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
const isBasicEntityJoin =
additionalColumn.joinAlias ===
`${baseJoinConfig.sourceColumn}_name`;
correctedJoinAlias === `${sourceColumn}_name`;
if (isBasicEntityJoin) {
logger.info(
`⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀`
`⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀`
);
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
}
@@ -2795,14 +2826,14 @@ export class TableManagementService {
// 추가 조인 컬럼 설정 생성
const additionalJoinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code)
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
referenceTable:
(additionalColumn as any).referenceTable ||
baseJoinConfig.referenceTable, // 참조 테이블 (dept_info)
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code)
displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name)
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
displayColumn: actualColumnName, // 하위 호환성
aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name)
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
separator: " - ", // 기본 구분자
};

View File

@@ -34,6 +34,35 @@ import { cn } from "@/lib/utils";
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
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 {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -53,6 +82,8 @@ export interface ExcelUploadModalProps {
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
};
// 🆕 마스터-디테일 엑셀 업로드 설정
masterDetailExcelConfig?: MasterDetailExcelConfig;
}
interface ColumnMapping {
@@ -71,6 +102,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
screenId,
isMasterDetail = false,
masterDetailRelation,
masterDetailExcelConfig,
}) => {
const [currentStep, setCurrentStep] = useState(1);
@@ -93,6 +125,116 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
// 3단계: 확인
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 selectedFile = e.target.files?.[0];
@@ -198,12 +340,51 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const loadTableSchema = async () => {
try {
console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail });
console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail, isSimpleMasterDetailMode });
let allColumns: TableColumn[] = [];
// 🆕 마스터-디테일 모드: 테이블 컬럼 합치기
if (isMasterDetail && masterDetailRelation) {
// 🆕 마스터-디테일 간단 모드: 디테일 테이블 컬럼만 로드 (마스터 필드는 UI에서 선택)
if (isSimpleMasterDetailMode && masterDetailRelation) {
const { detailTable, detailFkColumn } = masterDetailRelation;
console.log("📊 마스터-디테일 간단 모드 스키마 로드 (디테일만):", { detailTable });
// 디테일 테이블 스키마만 로드 (마스터 정보는 UI에서 선택)
const detailResponse = await getTableSchema(detailTable);
if (detailResponse.success && detailResponse.data) {
// 설정된 detailExcelFields가 있으면 해당 필드만, 없으면 전체
const configuredFields = masterDetailExcelConfig?.detailExcelFields;
const detailCols = detailResponse.data.columns
.filter((col) => {
// 자동 생성 컬럼, FK 컬럼 제외
if (AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())) return false;
if (col.name === detailFkColumn) return false;
// 설정된 필드가 있으면 해당 필드만
if (configuredFields && configuredFields.length > 0) {
return configuredFields.some((f) => f.columnName === col.name);
}
return true;
})
.map((col) => {
// 설정에서 라벨 찾기
const configField = configuredFields?.find((f) => f.columnName === col.name);
return {
...col,
label: configField?.columnLabel || col.label || col.name,
originalName: col.name,
sourceTable: detailTable,
};
});
allColumns = detailCols;
}
console.log("✅ 마스터-디테일 간단 모드 컬럼 로드 완료:", allColumns.length);
}
// 🆕 마스터-디테일 기존 모드: 두 테이블의 컬럼 합치기
else if (isMasterDetail && masterDetailRelation) {
const { masterTable, detailTable, detailFkColumn } = masterDetailRelation;
console.log("📊 마스터-디테일 스키마 로드:", { masterTable, detailTable });
@@ -365,6 +546,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
return;
}
// 🆕 마스터-디테일 간단 모드: 마스터 필드 유효성 검사
if (currentStep === 1 && hasMasterSelectFields && !isMasterFieldsValid()) {
toast.error("마스터 정보를 모두 입력해주세요.");
return;
}
// 1단계 → 2단계 전환 시: 빈 헤더 열 제외
if (currentStep === 1) {
// 빈 헤더가 아닌 열만 필터링
@@ -449,8 +636,39 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}`
);
// 🆕 마스터-디테일 모드 처리
if (isMasterDetail && screenId && masterDetailRelation) {
// 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번)
if (isSimpleMasterDetailMode && screenId && masterDetailRelation) {
console.log("📊 마스터-디테일 간단 모드 업로드:", {
masterDetailRelation,
masterFieldValues,
numberingRuleId: masterDetailExcelConfig?.numberingRuleId,
});
const uploadResult = await DynamicFormApi.uploadMasterDetailSimple(
screenId,
filteredData,
masterFieldValues,
masterDetailExcelConfig?.numberingRuleId || undefined
);
if (uploadResult.success && uploadResult.data) {
const { masterInserted, detailInserted, generatedKey, errors } = uploadResult.data;
toast.success(
`마스터 ${masterInserted}건(${generatedKey || ""}), 디테일 ${detailInserted}건 처리되었습니다.` +
(errors?.length > 0 ? ` (오류: ${errors.length}건)` : "")
);
// 매핑 템플릿 저장
await saveMappingTemplateInternal();
onSuccess?.();
} else {
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
}
}
// 🆕 마스터-디테일 기존 모드 처리
else if (isMasterDetail && screenId && masterDetailRelation) {
console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation);
const uploadResult = await DynamicFormApi.uploadMasterDetailData(
@@ -558,6 +776,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setExcelColumns([]);
setSystemColumns([]);
setColumnMappings([]);
// 🆕 마스터-디테일 모드 초기화
setMasterFieldValues({});
}
}, [open]);
@@ -647,6 +867,87 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
{/* 1단계: 파일 선택 & 미리보기 (통합) */}
{currentStep === 1 && (
<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>
<Label htmlFor="file-upload" className="text-xs sm:text-sm">

File diff suppressed because it is too large Load Diff

View File

@@ -655,6 +655,52 @@ export class DynamicFormApi {
};
}
}
/**
* 마스터-디테일 간단 모드 엑셀 업로드
* - 마스터 정보는 UI에서 선택
* - 디테일 정보만 엑셀에서 업로드
* - 채번 규칙을 통해 마스터 키 자동 생성
* @param screenId 화면 ID
* @param detailData 디테일 데이터 배열
* @param masterFieldValues UI에서 선택한 마스터 필드 값
* @param numberingRuleId 채번 규칙 ID (optional)
* @returns 업로드 결과
*/
static async uploadMasterDetailSimple(
screenId: number,
detailData: Record<string, any>[],
masterFieldValues: Record<string, any>,
numberingRuleId?: string
): Promise<ApiResponse<MasterDetailSimpleUploadResult>> {
try {
console.log("📤 마스터-디테일 간단 모드 업로드:", {
screenId,
detailRowCount: detailData.length,
masterFieldValues,
numberingRuleId,
});
const response = await apiClient.post(`/data/master-detail/upload-simple`, {
screenId,
detailData,
masterFieldValues,
numberingRuleId,
});
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,
};
}
}
}
// 마스터-디테일 관계 타입
@@ -687,5 +733,14 @@ export interface MasterDetailUploadResult {
errors: string[];
}
// 🆕 마스터-디테일 간단 모드 업로드 결과 타입
export interface MasterDetailSimpleUploadResult {
success: boolean;
masterInserted: number;
detailInserted: number;
generatedKey: string; // 생성된 마스터 키
errors?: string[];
}
// 편의를 위한 기본 export
export const dynamicFormApi = DynamicFormApi;

View File

@@ -135,6 +135,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (item[underscoreKey] !== undefined) {
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;

View File

@@ -2886,3 +2886,4 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div>
);
};

View File

@@ -4840,10 +4840,12 @@ export class ButtonActionExecutor {
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);
@@ -4851,7 +4853,34 @@ export class ButtonActionExecutor {
if (relationResponse.success && relationResponse.data) {
isMasterDetail = true;
masterDetailRelation = relationResponse.data;
console.log("📊 마스터-디테일 구조 감지:", masterDetailRelation);
// 버튼 설정에서 채번 규칙 등 추가 설정 가져오기
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,
});
}
}
@@ -4901,6 +4930,7 @@ export class ButtonActionExecutor {
screenId: context.screenId,
isMasterDetail,
masterDetailRelation,
masterDetailExcelConfig,
onSuccess: () => {
// 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨
context.onRefresh?.();