feat: Enhance category column handling and data mapping
- Updated the `getCategoryColumnsByCompany` and `getCategoryColumnsByMenu` functions to exclude reference columns from category column queries, improving data integrity. - Modified the `TableManagementService` to include `category_ref` in the column management logic, ensuring proper handling of category references during data operations. - Enhanced the frontend components to support category reference mapping, allowing for better data representation and user interaction. - Implemented category label conversion in various components to improve the display of category data, ensuring a seamless user experience.
This commit is contained in:
parent
863ec614f4
commit
eb27f01616
|
|
@ -1769,6 +1769,7 @@ export async function getCategoryColumnsByCompany(
|
||||||
let columnsResult;
|
let columnsResult;
|
||||||
|
|
||||||
// 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회
|
// 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회
|
||||||
|
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
|
||||||
if (companyCode === "*") {
|
if (companyCode === "*") {
|
||||||
const columnsQuery = `
|
const columnsQuery = `
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
|
|
@ -1788,15 +1789,15 @@ export async function getCategoryColumnsByCompany(
|
||||||
ON ttc.table_name = tl.table_name
|
ON ttc.table_name = tl.table_name
|
||||||
WHERE ttc.input_type = 'category'
|
WHERE ttc.input_type = 'category'
|
||||||
AND ttc.company_code = '*'
|
AND ttc.company_code = '*'
|
||||||
|
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||||
ORDER BY ttc.table_name, ttc.column_name
|
ORDER BY ttc.table_name, ttc.column_name
|
||||||
`;
|
`;
|
||||||
|
|
||||||
columnsResult = await pool.query(columnsQuery);
|
columnsResult = await pool.query(columnsQuery);
|
||||||
logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", {
|
logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||||
rowCount: columnsResult.rows.length
|
rowCount: columnsResult.rows.length
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
|
|
||||||
const columnsQuery = `
|
const columnsQuery = `
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
ttc.table_name AS "tableName",
|
ttc.table_name AS "tableName",
|
||||||
|
|
@ -1815,11 +1816,12 @@ export async function getCategoryColumnsByCompany(
|
||||||
ON ttc.table_name = tl.table_name
|
ON ttc.table_name = tl.table_name
|
||||||
WHERE ttc.input_type = 'category'
|
WHERE ttc.input_type = 'category'
|
||||||
AND ttc.company_code = $1
|
AND ttc.company_code = $1
|
||||||
|
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||||
ORDER BY ttc.table_name, ttc.column_name
|
ORDER BY ttc.table_name, ttc.column_name
|
||||||
`;
|
`;
|
||||||
|
|
||||||
columnsResult = await pool.query(columnsQuery, [companyCode]);
|
columnsResult = await pool.query(columnsQuery, [companyCode]);
|
||||||
logger.info("✅ 회사별 카테고리 컬럼 조회 완료", {
|
logger.info("회사별 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||||
companyCode,
|
companyCode,
|
||||||
rowCount: columnsResult.rows.length
|
rowCount: columnsResult.rows.length
|
||||||
});
|
});
|
||||||
|
|
@ -1880,13 +1882,10 @@ export async function getCategoryColumnsByMenu(
|
||||||
const { getPool } = await import("../database/db");
|
const { getPool } = await import("../database/db");
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
// 🆕 table_type_columns에서 직접 input_type = 'category'인 컬럼들을 조회
|
// table_type_columns에서 input_type = 'category' 컬럼 조회
|
||||||
// category_column_mapping 대신 table_type_columns 기준으로 조회
|
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
|
||||||
logger.info("🔍 table_type_columns 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
|
|
||||||
|
|
||||||
let columnsResult;
|
let columnsResult;
|
||||||
|
|
||||||
// 최고 관리자인 경우 모든 회사의 카테고리 컬럼 조회
|
|
||||||
if (companyCode === "*") {
|
if (companyCode === "*") {
|
||||||
const columnsQuery = `
|
const columnsQuery = `
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
|
|
@ -1906,15 +1905,15 @@ export async function getCategoryColumnsByMenu(
|
||||||
ON ttc.table_name = tl.table_name
|
ON ttc.table_name = tl.table_name
|
||||||
WHERE ttc.input_type = 'category'
|
WHERE ttc.input_type = 'category'
|
||||||
AND ttc.company_code = '*'
|
AND ttc.company_code = '*'
|
||||||
|
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||||
ORDER BY ttc.table_name, ttc.column_name
|
ORDER BY ttc.table_name, ttc.column_name
|
||||||
`;
|
`;
|
||||||
|
|
||||||
columnsResult = await pool.query(columnsQuery);
|
columnsResult = await pool.query(columnsQuery);
|
||||||
logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", {
|
logger.info("최고 관리자: 메뉴별 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||||
rowCount: columnsResult.rows.length
|
rowCount: columnsResult.rows.length
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
|
|
||||||
const columnsQuery = `
|
const columnsQuery = `
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
ttc.table_name AS "tableName",
|
ttc.table_name AS "tableName",
|
||||||
|
|
@ -1933,11 +1932,12 @@ export async function getCategoryColumnsByMenu(
|
||||||
ON ttc.table_name = tl.table_name
|
ON ttc.table_name = tl.table_name
|
||||||
WHERE ttc.input_type = 'category'
|
WHERE ttc.input_type = 'category'
|
||||||
AND ttc.company_code = $1
|
AND ttc.company_code = $1
|
||||||
|
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||||
ORDER BY ttc.table_name, ttc.column_name
|
ORDER BY ttc.table_name, ttc.column_name
|
||||||
`;
|
`;
|
||||||
|
|
||||||
columnsResult = await pool.query(columnsQuery, [companyCode]);
|
columnsResult = await pool.query(columnsQuery, [companyCode]);
|
||||||
logger.info("✅ 회사별 카테고리 컬럼 조회 완료", {
|
logger.info("회사별 메뉴 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||||
companyCode,
|
companyCode,
|
||||||
rowCount: columnsResult.rows.length
|
rowCount: columnsResult.rows.length
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -518,8 +518,8 @@ export class TableManagementService {
|
||||||
table_name, column_name, column_label, input_type, detail_settings,
|
table_name, column_name, column_label, input_type, detail_settings,
|
||||||
code_category, code_value, reference_table, reference_column,
|
code_category, code_value, reference_table, reference_column,
|
||||||
display_column, display_order, is_visible, is_nullable,
|
display_column, display_order, is_visible, is_nullable,
|
||||||
company_code, created_date, updated_date
|
company_code, category_ref, created_date, updated_date
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, NOW(), NOW())
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, $14, NOW(), NOW())
|
||||||
ON CONFLICT (table_name, column_name, company_code)
|
ON CONFLICT (table_name, column_name, company_code)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
|
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
|
||||||
|
|
@ -532,6 +532,7 @@ export class TableManagementService {
|
||||||
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
|
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
|
||||||
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
|
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
|
||||||
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
|
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
|
||||||
|
category_ref = EXCLUDED.category_ref,
|
||||||
updated_date = NOW()`,
|
updated_date = NOW()`,
|
||||||
[
|
[
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -547,6 +548,7 @@ export class TableManagementService {
|
||||||
settings.displayOrder || 0,
|
settings.displayOrder || 0,
|
||||||
settings.isVisible !== undefined ? settings.isVisible : true,
|
settings.isVisible !== undefined ? settings.isVisible : true,
|
||||||
companyCode,
|
companyCode,
|
||||||
|
settings.categoryRef || null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -4553,7 +4555,8 @@ export class TableManagementService {
|
||||||
END as "detailSettings",
|
END as "detailSettings",
|
||||||
ttc.is_nullable as "isNullable",
|
ttc.is_nullable as "isNullable",
|
||||||
ic.data_type as "dataType",
|
ic.data_type as "dataType",
|
||||||
ttc.company_code as "companyCode"
|
ttc.company_code as "companyCode",
|
||||||
|
ttc.category_ref as "categoryRef"
|
||||||
FROM table_type_columns ttc
|
FROM table_type_columns ttc
|
||||||
LEFT JOIN information_schema.columns ic
|
LEFT JOIN information_schema.columns ic
|
||||||
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
|
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
|
||||||
|
|
@ -4630,20 +4633,24 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
|
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
|
||||||
const baseInfo = {
|
const baseInfo: any = {
|
||||||
tableName: tableName,
|
tableName: tableName,
|
||||||
columnName: col.columnName,
|
columnName: col.columnName,
|
||||||
displayName: col.displayName,
|
displayName: col.displayName,
|
||||||
dataType: col.dataType || "varchar",
|
dataType: col.dataType || "varchar",
|
||||||
inputType: col.inputType,
|
inputType: col.inputType,
|
||||||
detailSettings: col.detailSettings,
|
detailSettings: col.detailSettings,
|
||||||
description: "", // 필수 필드 추가
|
description: "",
|
||||||
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
|
isNullable: col.isNullable === "Y" ? "Y" : "N",
|
||||||
isPrimaryKey: false,
|
isPrimaryKey: false,
|
||||||
displayOrder: 0,
|
displayOrder: 0,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (col.categoryRef) {
|
||||||
|
baseInfo.categoryRef = col.categoryRef;
|
||||||
|
}
|
||||||
|
|
||||||
// 카테고리 타입인 경우 categoryMenus 추가
|
// 카테고리 타입인 경우 categoryMenus 추가
|
||||||
if (
|
if (
|
||||||
col.inputType === "category" &&
|
col.inputType === "category" &&
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,10 @@ interface ColumnTypeInfo {
|
||||||
referenceTable?: string;
|
referenceTable?: string;
|
||||||
referenceColumn?: string;
|
referenceColumn?: string;
|
||||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||||
categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열
|
categoryMenus?: number[];
|
||||||
hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할
|
hierarchyRole?: "large" | "medium" | "small";
|
||||||
numberingRuleId?: string; // 🆕 Numbering 타입: 채번규칙 ID
|
numberingRuleId?: string;
|
||||||
|
categoryRef?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SecondLevelMenu {
|
interface SecondLevelMenu {
|
||||||
|
|
@ -388,6 +389,7 @@ export default function TableManagementPage() {
|
||||||
numberingRuleId,
|
numberingRuleId,
|
||||||
categoryMenus: col.categoryMenus || [],
|
categoryMenus: col.categoryMenus || [],
|
||||||
hierarchyRole,
|
hierarchyRole,
|
||||||
|
categoryRef: col.categoryRef || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -670,15 +672,16 @@ export default function TableManagementPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnSetting = {
|
const columnSetting = {
|
||||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
columnName: column.columnName,
|
||||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
columnLabel: column.displayName,
|
||||||
inputType: column.inputType || "text",
|
inputType: column.inputType || "text",
|
||||||
detailSettings: finalDetailSettings,
|
detailSettings: finalDetailSettings,
|
||||||
codeCategory: column.codeCategory || "",
|
codeCategory: column.codeCategory || "",
|
||||||
codeValue: column.codeValue || "",
|
codeValue: column.codeValue || "",
|
||||||
referenceTable: column.referenceTable || "",
|
referenceTable: column.referenceTable || "",
|
||||||
referenceColumn: column.referenceColumn || "",
|
referenceColumn: column.referenceColumn || "",
|
||||||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
displayColumn: column.displayColumn || "",
|
||||||
|
categoryRef: column.categoryRef || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// console.log("저장할 컬럼 설정:", columnSetting);
|
// console.log("저장할 컬럼 설정:", columnSetting);
|
||||||
|
|
@ -705,9 +708,9 @@ export default function TableManagementPage() {
|
||||||
length: column.categoryMenus?.length || 0,
|
length: column.categoryMenus?.length || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (column.inputType === "category") {
|
if (column.inputType === "category" && !column.categoryRef) {
|
||||||
// 1. 먼저 기존 매핑 모두 삭제
|
// 참조가 아닌 자체 카테고리만 메뉴 매핑 처리
|
||||||
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", {
|
console.log("기존 카테고리 메뉴 매핑 삭제 시작:", {
|
||||||
tableName: selectedTable,
|
tableName: selectedTable,
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
});
|
});
|
||||||
|
|
@ -866,8 +869,8 @@ export default function TableManagementPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
columnName: column.columnName,
|
||||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
columnLabel: column.displayName,
|
||||||
inputType: column.inputType || "text",
|
inputType: column.inputType || "text",
|
||||||
detailSettings: finalDetailSettings,
|
detailSettings: finalDetailSettings,
|
||||||
description: column.description || "",
|
description: column.description || "",
|
||||||
|
|
@ -875,7 +878,8 @@ export default function TableManagementPage() {
|
||||||
codeValue: column.codeValue || "",
|
codeValue: column.codeValue || "",
|
||||||
referenceTable: column.referenceTable || "",
|
referenceTable: column.referenceTable || "",
|
||||||
referenceColumn: column.referenceColumn || "",
|
referenceColumn: column.referenceColumn || "",
|
||||||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
displayColumn: column.displayColumn || "",
|
||||||
|
categoryRef: column.categoryRef || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -888,8 +892,8 @@ export default function TableManagementPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
// 🆕 Category 타입 컬럼들의 메뉴 매핑 처리
|
// 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외)
|
||||||
const categoryColumns = columns.filter((col) => col.inputType === "category");
|
const categoryColumns = columns.filter((col) => col.inputType === "category" && !col.categoryRef);
|
||||||
|
|
||||||
console.log("📥 전체 저장: 카테고리 컬럼 확인", {
|
console.log("📥 전체 저장: 카테고리 컬럼 확인", {
|
||||||
totalColumns: columns.length,
|
totalColumns: columns.length,
|
||||||
|
|
@ -1691,7 +1695,30 @@ export default function TableManagementPage() {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */}
|
{/* 카테고리 타입: 참조 설정 */}
|
||||||
|
{column.inputType === "category" && (
|
||||||
|
<div className="w-56">
|
||||||
|
<label className="text-muted-foreground mb-1 block text-xs">카테고리 참조 (선택)</label>
|
||||||
|
<Input
|
||||||
|
value={column.categoryRef || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value || null;
|
||||||
|
setColumns((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.columnName === column.columnName
|
||||||
|
? { ...c, categoryRef: val }
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
placeholder="테이블명.컬럼명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||||
|
다른 테이블의 카테고리 값 참조 시 입력
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||||
{column.inputType === "entity" && (
|
{column.inputType === "entity" && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -1155,19 +1155,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
const masterRecordId = response.data?.id || formData.id;
|
const masterRecordId = response.data?.id || formData.id;
|
||||||
|
|
||||||
// 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("repeaterSave", {
|
|
||||||
detail: {
|
|
||||||
parentId: masterRecordId,
|
|
||||||
masterRecordId,
|
|
||||||
mainFormData: formData,
|
|
||||||
tableName: screenData.screenInfo.tableName,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
console.log("📋 [EditModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName: screenData.screenInfo.tableName });
|
|
||||||
|
|
||||||
toast.success("데이터가 생성되었습니다.");
|
toast.success("데이터가 생성되었습니다.");
|
||||||
|
|
||||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||||
|
|
@ -1215,6 +1202,40 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
|
||||||
|
try {
|
||||||
|
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||||
|
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||||
|
const handler = () => {
|
||||||
|
clearTimeout(fallbackTimeout);
|
||||||
|
window.removeEventListener("repeaterSaveComplete", handler);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
window.addEventListener("repeaterSaveComplete", handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", {
|
||||||
|
parentId: masterRecordId,
|
||||||
|
tableName: screenData.screenInfo.tableName,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("repeaterSave", {
|
||||||
|
detail: {
|
||||||
|
parentId: masterRecordId,
|
||||||
|
tableName: screenData.screenInfo.tableName,
|
||||||
|
mainFormData: formData,
|
||||||
|
masterRecordId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await repeaterSavePromise;
|
||||||
|
console.log("✅ [EditModal] INSERT 후 repeaterSave 완료");
|
||||||
|
} catch (repeaterError) {
|
||||||
|
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
|
||||||
|
}
|
||||||
|
|
||||||
handleClose();
|
handleClose();
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.message || "생성에 실패했습니다.");
|
throw new Error(response.message || "생성에 실패했습니다.");
|
||||||
|
|
@ -1320,6 +1341,40 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
|
||||||
|
try {
|
||||||
|
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||||
|
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||||
|
const handler = () => {
|
||||||
|
clearTimeout(fallbackTimeout);
|
||||||
|
window.removeEventListener("repeaterSaveComplete", handler);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
window.addEventListener("repeaterSaveComplete", handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", {
|
||||||
|
parentId: recordId,
|
||||||
|
tableName: screenData.screenInfo.tableName,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("repeaterSave", {
|
||||||
|
detail: {
|
||||||
|
parentId: recordId,
|
||||||
|
tableName: screenData.screenInfo.tableName,
|
||||||
|
mainFormData: formData,
|
||||||
|
masterRecordId: recordId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await repeaterSavePromise;
|
||||||
|
console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료");
|
||||||
|
} catch (repeaterError) {
|
||||||
|
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
|
||||||
|
}
|
||||||
|
|
||||||
handleClose();
|
handleClose();
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.message || "수정에 실패했습니다.");
|
throw new Error(response.message || "수정에 실패했습니다.");
|
||||||
|
|
|
||||||
|
|
@ -571,8 +571,38 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 리피터가 화면과 동일 테이블을 사용하는지 감지 (useCustomTable 미설정 = 동일 테이블)
|
||||||
|
const hasRepeaterOnSameTable = allComponents.some((c: any) => {
|
||||||
|
const compType = c.componentType || c.overrides?.type;
|
||||||
|
if (compType !== "v2-repeater") return false;
|
||||||
|
const compConfig = c.componentConfig || c.overrides || {};
|
||||||
|
return !compConfig.useCustomTable;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasRepeaterOnSameTable) {
|
||||||
|
// 동일 테이블 리피터: 마스터 저장 스킵, 리피터만 저장
|
||||||
|
// 리피터가 mainFormData를 각 행에 병합하여 N건 INSERT 처리
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("repeaterSave", {
|
||||||
|
detail: {
|
||||||
|
parentId: null,
|
||||||
|
masterRecordId: null,
|
||||||
|
mainFormData: formData,
|
||||||
|
tableName: screenInfo.tableName,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success("데이터가 성공적으로 저장되었습니다.");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
|
// 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
|
||||||
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
|
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
|
||||||
const masterFormData: Record<string, any> = {};
|
const masterFormData: Record<string, any> = {};
|
||||||
|
|
||||||
|
|
@ -591,11 +621,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
|
|
||||||
Object.entries(formData).forEach(([key, value]) => {
|
Object.entries(formData).forEach(([key, value]) => {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
// 배열이 아닌 값은 그대로 저장
|
|
||||||
masterFormData[key] = value;
|
masterFormData[key] = value;
|
||||||
} else if (mediaColumnNames.has(key)) {
|
} else if (mediaColumnNames.has(key)) {
|
||||||
// v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응)
|
|
||||||
// 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용
|
|
||||||
masterFormData[key] = value.length > 0 ? value[0] : null;
|
masterFormData[key] = value.length > 0 ? value[0] : null;
|
||||||
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
|
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -608,7 +635,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
data: masterFormData,
|
data: masterFormData,
|
||||||
};
|
};
|
||||||
|
|
||||||
// console.log("💾 저장 액션 실행:", saveData);
|
|
||||||
const response = await dynamicFormApi.saveData(saveData);
|
const response = await dynamicFormApi.saveData(saveData);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
|
@ -619,7 +645,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
new CustomEvent("repeaterSave", {
|
new CustomEvent("repeaterSave", {
|
||||||
detail: {
|
detail: {
|
||||||
parentId: masterRecordId,
|
parentId: masterRecordId,
|
||||||
masterRecordId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
|
masterRecordId,
|
||||||
mainFormData: formData,
|
mainFormData: formData,
|
||||||
tableName: screenInfo.tableName,
|
tableName: screenInfo.tableName,
|
||||||
},
|
},
|
||||||
|
|
@ -631,7 +657,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
toast.error(response.message || "저장에 실패했습니다.");
|
toast.error(response.message || "저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("저장 오류:", error);
|
|
||||||
toast.error("저장 중 오류가 발생했습니다.");
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect, useMemo, useCallback } from "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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
@ -92,13 +92,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
|
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
|
||||||
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
|
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
|
||||||
|
|
||||||
// 🆕 데이터 전달 필드 매핑용 상태
|
// 🆕 데이터 전달 필드 매핑용 상태 (멀티 테이블 매핑 지원)
|
||||||
const [mappingSourceColumns, setMappingSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
|
||||||
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||||
const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<string, boolean>>({});
|
||||||
const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<string, boolean>>({});
|
||||||
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<number, string>>({});
|
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<string, string>>({});
|
||||||
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<number, string>>({});
|
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<string, string>>({});
|
||||||
|
const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0);
|
||||||
|
|
||||||
// 🆕 openModalWithData 전용 필드 매핑 상태
|
// 🆕 openModalWithData 전용 필드 매핑 상태
|
||||||
const [modalSourceColumns, setModalSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
const [modalSourceColumns, setModalSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||||
|
|
@ -295,57 +296,57 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드
|
// 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드
|
||||||
useEffect(() => {
|
const loadMappingColumns = useCallback(async (tableName: string): Promise<Array<{ name: string; label: string }>> => {
|
||||||
const sourceTable = config.action?.dataTransfer?.sourceTable;
|
try {
|
||||||
const targetTable = config.action?.dataTransfer?.targetTable;
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||||
|
if (response.data.success) {
|
||||||
|
let columnData = response.data.data;
|
||||||
|
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||||
|
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||||
|
|
||||||
const loadColumns = async () => {
|
if (Array.isArray(columnData)) {
|
||||||
if (sourceTable) {
|
return columnData.map((col: any) => ({
|
||||||
try {
|
name: col.name || col.columnName,
|
||||||
const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`);
|
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||||
if (response.data.success) {
|
}));
|
||||||
let columnData = response.data.data;
|
|
||||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
|
||||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
|
||||||
|
|
||||||
if (Array.isArray(columnData)) {
|
|
||||||
const columns = columnData.map((col: any) => ({
|
|
||||||
name: col.name || col.columnName,
|
|
||||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
|
||||||
}));
|
|
||||||
setMappingSourceColumns(columns);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("소스 테이블 컬럼 로드 실패:", error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (targetTable) {
|
useEffect(() => {
|
||||||
try {
|
const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || [];
|
||||||
const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`);
|
const legacySourceTable = config.action?.dataTransfer?.sourceTable;
|
||||||
if (response.data.success) {
|
const targetTable = config.action?.dataTransfer?.targetTable;
|
||||||
let columnData = response.data.data;
|
|
||||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
|
||||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
|
||||||
|
|
||||||
if (Array.isArray(columnData)) {
|
const loadAll = async () => {
|
||||||
const columns = columnData.map((col: any) => ({
|
const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean);
|
||||||
name: col.name || col.columnName,
|
if (legacySourceTable && !sourceTableNames.includes(legacySourceTable)) {
|
||||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
sourceTableNames.push(legacySourceTable);
|
||||||
}));
|
}
|
||||||
setMappingTargetColumns(columns);
|
|
||||||
}
|
const newMap: Record<string, Array<{ name: string; label: string }>> = {};
|
||||||
}
|
for (const tbl of sourceTableNames) {
|
||||||
} catch (error) {
|
if (!mappingSourceColumnsMap[tbl]) {
|
||||||
console.error("타겟 테이블 컬럼 로드 실패:", error);
|
newMap[tbl] = await loadMappingColumns(tbl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (Object.keys(newMap).length > 0) {
|
||||||
|
setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetTable && mappingTargetColumns.length === 0) {
|
||||||
|
const cols = await loadMappingColumns(targetTable);
|
||||||
|
setMappingTargetColumns(cols);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadColumns();
|
loadAll();
|
||||||
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
|
}, [config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable, loadMappingColumns]);
|
||||||
|
|
||||||
// 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드
|
// 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -3364,278 +3365,342 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필드 매핑 규칙 */}
|
{/* 멀티 테이블 필드 매핑 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>필드 매핑 설정</Label>
|
<Label>필드 매핑 설정</Label>
|
||||||
|
|
||||||
{/* 소스/타겟 테이블 선택 */}
|
{/* 타겟 테이블 (공통) */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-1">
|
||||||
<div className="space-y-1">
|
<Label className="text-xs">타겟 테이블</Label>
|
||||||
<Label className="text-xs">소스 테이블</Label>
|
<Popover>
|
||||||
<Popover>
|
<PopoverTrigger asChild>
|
||||||
<PopoverTrigger asChild>
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
{config.action?.dataTransfer?.targetTable
|
||||||
{config.action?.dataTransfer?.sourceTable
|
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
|
||||||
? availableTables.find((t) => t.name === config.action?.dataTransfer?.sourceTable)?.label ||
|
config.action?.dataTransfer?.targetTable
|
||||||
config.action?.dataTransfer?.sourceTable
|
: "타겟 테이블 선택"}
|
||||||
: "테이블 선택"}
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
</Button>
|
||||||
</Button>
|
</PopoverTrigger>
|
||||||
</PopoverTrigger>
|
<PopoverContent className="w-[250px] p-0" align="start">
|
||||||
<PopoverContent className="w-[250px] p-0" align="start">
|
<Command>
|
||||||
<Command>
|
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
<CommandList>
|
||||||
<CommandList>
|
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
<CommandGroup>
|
||||||
<CommandGroup>
|
{availableTables.map((table) => (
|
||||||
{availableTables.map((table) => (
|
<CommandItem
|
||||||
<CommandItem
|
key={table.name}
|
||||||
key={table.name}
|
value={`${table.label} ${table.name}`}
|
||||||
value={`${table.label} ${table.name}`}
|
onSelect={() => {
|
||||||
onSelect={() => {
|
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.sourceTable", table.name);
|
}}
|
||||||
}}
|
className="text-xs"
|
||||||
className="text-xs"
|
>
|
||||||
>
|
<Check
|
||||||
<Check
|
className={cn(
|
||||||
className={cn(
|
"mr-2 h-3 w-3",
|
||||||
"mr-2 h-3 w-3",
|
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
|
||||||
config.action?.dataTransfer?.sourceTable === table.name ? "opacity-100" : "opacity-0",
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
<span className="font-medium">{table.label}</span>
|
||||||
<span className="font-medium">{table.label}</span>
|
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||||||
<span className="text-muted-foreground ml-1">({table.name})</span>
|
</CommandItem>
|
||||||
</CommandItem>
|
))}
|
||||||
))}
|
</CommandGroup>
|
||||||
</CommandGroup>
|
</CommandList>
|
||||||
</CommandList>
|
</Command>
|
||||||
</Command>
|
</PopoverContent>
|
||||||
</PopoverContent>
|
</Popover>
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">타겟 테이블</Label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
|
||||||
{config.action?.dataTransfer?.targetTable
|
|
||||||
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
|
|
||||||
config.action?.dataTransfer?.targetTable
|
|
||||||
: "테이블 선택"}
|
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[250px] p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{availableTables.map((table) => (
|
|
||||||
<CommandItem
|
|
||||||
key={table.name}
|
|
||||||
value={`${table.label} ${table.name}`}
|
|
||||||
onSelect={() => {
|
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
|
|
||||||
}}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-3 w-3",
|
|
||||||
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="font-medium">{table.label}</span>
|
|
||||||
<span className="text-muted-foreground ml-1">({table.name})</span>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필드 매핑 규칙 */}
|
{/* 소스 테이블 매핑 그룹 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">필드 매핑 규칙</Label>
|
<Label className="text-xs">소스 테이블별 매핑</Label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 text-[10px]"
|
className="h-6 text-[10px]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentRules = config.action?.dataTransfer?.mappingRules || [];
|
const currentMappings = config.action?.dataTransfer?.multiTableMappings || [];
|
||||||
const newRule = { sourceField: "", targetField: "", transform: "" };
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", [
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", [...currentRules, newRule]);
|
...currentMappings,
|
||||||
|
{ sourceTable: "", mappingRules: [] },
|
||||||
|
]);
|
||||||
|
setActiveMappingGroupIndex(currentMappings.length);
|
||||||
}}
|
}}
|
||||||
disabled={!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable}
|
disabled={!config.action?.dataTransfer?.targetTable}
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
매핑 추가
|
소스 테이블 추가
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-[10px]">
|
<p className="text-muted-foreground text-[10px]">
|
||||||
소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다.
|
여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable ? (
|
{!config.action?.dataTransfer?.targetTable ? (
|
||||||
<div className="rounded-md border border-dashed p-3 text-center">
|
<div className="rounded-md border border-dashed p-3 text-center">
|
||||||
<p className="text-muted-foreground text-xs">먼저 소스 테이블과 타겟 테이블을 선택하세요.</p>
|
<p className="text-muted-foreground text-xs">먼저 타겟 테이블을 선택하세요.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? (
|
) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? (
|
||||||
<div className="rounded-md border border-dashed p-3 text-center">
|
<div className="rounded-md border border-dashed p-3 text-center">
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다.
|
매핑 그룹이 없습니다. 소스 테이블을 추가하세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => (
|
{/* 소스 테이블 탭 */}
|
||||||
<div key={index} className="bg-background flex items-center gap-2 rounded-md border p-2">
|
<div className="flex flex-wrap gap-1">
|
||||||
{/* 소스 필드 선택 (Combobox) */}
|
{(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => (
|
||||||
<div className="flex-1">
|
<div key={gIdx} className="flex items-center gap-0.5">
|
||||||
<Popover
|
<Button
|
||||||
open={mappingSourcePopoverOpen[index] || false}
|
type="button"
|
||||||
onOpenChange={(open) => setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-[10px]"
|
||||||
|
onClick={() => setActiveMappingGroupIndex(gIdx)}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
{group.sourceTable
|
||||||
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable
|
||||||
{rule.sourceField
|
: `그룹 ${gIdx + 1}`}
|
||||||
? mappingSourceColumns.find((c) => c.name === rule.sourceField)?.label ||
|
{group.mappingRules?.length > 0 && (
|
||||||
rule.sourceField
|
<span className="bg-primary/20 ml-1 rounded-full px-1 text-[9px]">
|
||||||
: "소스 필드"}
|
{group.mappingRules.length}
|
||||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
</span>
|
||||||
</Button>
|
)}
|
||||||
</PopoverTrigger>
|
</Button>
|
||||||
<PopoverContent className="w-[200px] p-0" align="start">
|
<Button
|
||||||
<Command>
|
type="button"
|
||||||
<CommandInput
|
variant="ghost"
|
||||||
placeholder="컬럼 검색..."
|
size="icon"
|
||||||
className="h-8 text-xs"
|
className="text-destructive hover:bg-destructive/10 h-5 w-5"
|
||||||
value={mappingSourceSearch[index] || ""}
|
onClick={() => {
|
||||||
onValueChange={(value) =>
|
const mappings = [...(config.action?.dataTransfer?.multiTableMappings || [])];
|
||||||
setMappingSourceSearch((prev) => ({ ...prev, [index]: value }))
|
mappings.splice(gIdx, 1);
|
||||||
}
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
|
||||||
/>
|
if (activeMappingGroupIndex >= mappings.length) {
|
||||||
<CommandList>
|
setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
|
||||||
<CommandEmpty className="py-2 text-center text-xs">
|
}
|
||||||
컬럼을 찾을 수 없습니다
|
}}
|
||||||
</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{mappingSourceColumns.map((col) => (
|
|
||||||
<CommandItem
|
|
||||||
key={col.name}
|
|
||||||
value={`${col.label} ${col.name}`}
|
|
||||||
onSelect={() => {
|
|
||||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
|
||||||
rules[index] = { ...rules[index], sourceField: col.name };
|
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
|
||||||
setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false }));
|
|
||||||
}}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-3 w-3",
|
|
||||||
rule.sourceField === col.name ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>{col.label}</span>
|
|
||||||
{col.label !== col.name && (
|
|
||||||
<span className="text-muted-foreground ml-1">({col.name})</span>
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="text-muted-foreground text-xs">→</span>
|
|
||||||
|
|
||||||
{/* 타겟 필드 선택 (Combobox) */}
|
|
||||||
<div className="flex-1">
|
|
||||||
<Popover
|
|
||||||
open={mappingTargetPopoverOpen[index] || false}
|
|
||||||
onOpenChange={(open) => setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<X className="h-3 w-3" />
|
||||||
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
</Button>
|
||||||
{rule.targetField
|
|
||||||
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label ||
|
|
||||||
rule.targetField
|
|
||||||
: "타겟 필드"}
|
|
||||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[200px] p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="컬럼 검색..."
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={mappingTargetSearch[index] || ""}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setMappingTargetSearch((prev) => ({ ...prev, [index]: value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty className="py-2 text-center text-xs">
|
|
||||||
컬럼을 찾을 수 없습니다
|
|
||||||
</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{mappingTargetColumns.map((col) => (
|
|
||||||
<CommandItem
|
|
||||||
key={col.name}
|
|
||||||
value={`${col.label} ${col.name}`}
|
|
||||||
onSelect={() => {
|
|
||||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
|
||||||
rules[index] = { ...rules[index], targetField: col.name };
|
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
|
||||||
setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false }));
|
|
||||||
}}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-3 w-3",
|
|
||||||
rule.targetField === col.name ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>{col.label}</span>
|
|
||||||
{col.label !== col.name && (
|
|
||||||
<span className="text-muted-foreground ml-1">({col.name})</span>
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
{/* 활성 그룹 편집 영역 */}
|
||||||
type="button"
|
{(() => {
|
||||||
variant="ghost"
|
const multiMappings = config.action?.dataTransfer?.multiTableMappings || [];
|
||||||
size="icon"
|
const activeGroup = multiMappings[activeMappingGroupIndex];
|
||||||
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
if (!activeGroup) return null;
|
||||||
onClick={() => {
|
|
||||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
const activeSourceTable = activeGroup.sourceTable || "";
|
||||||
rules.splice(index, 1);
|
const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || [];
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
const activeRules: any[] = activeGroup.mappingRules || [];
|
||||||
}}
|
|
||||||
>
|
const updateGroupField = (field: string, value: any) => {
|
||||||
<X className="h-3 w-3" />
|
const mappings = [...multiMappings];
|
||||||
</Button>
|
mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value };
|
||||||
</div>
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
|
||||||
))}
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
|
{/* 소스 테이블 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">소스 테이블</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||||
|
{activeSourceTable
|
||||||
|
? availableTables.find((t) => t.name === activeSourceTable)?.label || activeSourceTable
|
||||||
|
: "소스 테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[250px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.name}
|
||||||
|
value={`${table.label} ${table.name}`}
|
||||||
|
onSelect={async () => {
|
||||||
|
updateGroupField("sourceTable", table.name);
|
||||||
|
if (!mappingSourceColumnsMap[table.name]) {
|
||||||
|
const cols = await loadMappingColumns(table.name);
|
||||||
|
setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
activeSourceTable === table.name ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{table.label}</span>
|
||||||
|
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 매핑 규칙 목록 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-[10px]">매핑 규칙</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 text-[10px]"
|
||||||
|
onClick={() => {
|
||||||
|
updateGroupField("mappingRules", [...activeRules, { sourceField: "", targetField: "" }]);
|
||||||
|
}}
|
||||||
|
disabled={!activeSourceTable}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!activeSourceTable ? (
|
||||||
|
<p className="text-muted-foreground text-[10px]">소스 테이블을 먼저 선택하세요.</p>
|
||||||
|
) : activeRules.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-[10px]">매핑 없음 (동일 필드명 자동 매핑)</p>
|
||||||
|
) : (
|
||||||
|
activeRules.map((rule: any, rIdx: number) => {
|
||||||
|
const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`;
|
||||||
|
const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`;
|
||||||
|
return (
|
||||||
|
<div key={rIdx} className="bg-background flex items-center gap-2 rounded-md border p-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Popover
|
||||||
|
open={mappingSourcePopoverOpen[popoverKeyS] || false}
|
||||||
|
onOpenChange={(open) =>
|
||||||
|
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
||||||
|
{rule.sourceField
|
||||||
|
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label || rule.sourceField
|
||||||
|
: "소스 필드"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">컬럼 없음</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{activeSourceColumns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.name}
|
||||||
|
value={`${col.label} ${col.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
const newRules = [...activeRules];
|
||||||
|
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
|
||||||
|
updateGroupField("mappingRules", newRules);
|
||||||
|
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: false }));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", rule.sourceField === col.name ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span>{col.label}</span>
|
||||||
|
{col.label !== col.name && <span className="text-muted-foreground ml-1">({col.name})</span>}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-muted-foreground text-xs">→</span>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<Popover
|
||||||
|
open={mappingTargetPopoverOpen[popoverKeyT] || false}
|
||||||
|
onOpenChange={(open) =>
|
||||||
|
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
||||||
|
{rule.targetField
|
||||||
|
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label || rule.targetField
|
||||||
|
: "타겟 필드"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">컬럼 없음</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{mappingTargetColumns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.name}
|
||||||
|
value={`${col.label} ${col.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
const newRules = [...activeRules];
|
||||||
|
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
|
||||||
|
updateGroupField("mappingRules", newRules);
|
||||||
|
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: false }));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", rule.targetField === col.name ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span>{col.label}</span>
|
||||||
|
{col.label !== col.name && <span className="text-muted-foreground ml-1">({col.name})</span>}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
||||||
|
onClick={() => {
|
||||||
|
const newRules = [...activeRules];
|
||||||
|
newRules.splice(rIdx, 1);
|
||||||
|
updateGroupField("mappingRules", newRules);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3647,9 +3712,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<br />
|
<br />
|
||||||
1. 소스 컴포넌트에서 데이터를 선택합니다
|
1. 소스 컴포넌트에서 데이터를 선택합니다
|
||||||
<br />
|
<br />
|
||||||
2. 필드 매핑 규칙을 설정합니다 (예: 품번 → 품목코드)
|
2. 소스 테이블별로 필드 매핑 규칙을 설정합니다
|
||||||
<br />
|
<br />
|
||||||
3. 이 버튼을 클릭하면 매핑된 데이터가 타겟으로 전달됩니다
|
3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,10 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
allColumns = response.data;
|
allColumns = response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// category 타입 컬럼만 필터링
|
// category 타입 중 자체 카테고리만 필터링 (참조 컬럼 제외)
|
||||||
const categoryColumns = allColumns.filter(
|
const categoryColumns = allColumns.filter(
|
||||||
(col: any) => col.inputType === "category" || col.input_type === "category"
|
(col: any) => (col.inputType === "category" || col.input_type === "category")
|
||||||
|
&& !col.categoryRef && !col.category_ref
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("✅ 카테고리 컬럼 필터링 완료:", {
|
console.log("✅ 카테고리 컬럼 필터링 완료:", {
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,15 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// 저장 이벤트 핸들러에서 항상 최신 data를 참조하기 위한 ref
|
||||||
|
const dataRef = useRef<any[]>(data);
|
||||||
|
useEffect(() => {
|
||||||
|
dataRef.current = data;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// 수정 모드에서 로드된 원본 ID 목록 (삭제 추적용)
|
||||||
|
const loadedIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
|
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
|
||||||
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
|
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
|
||||||
|
|
||||||
|
|
@ -91,17 +100,67 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 정규화: {0: {...}} 형태 처리
|
// 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거
|
||||||
|
const metaFieldsToStrip = new Set([
|
||||||
|
"id",
|
||||||
|
"created_date",
|
||||||
|
"updated_date",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
"company_code",
|
||||||
|
]);
|
||||||
const normalizedData = incomingData.map((item: any) => {
|
const normalizedData = incomingData.map((item: any) => {
|
||||||
|
let raw = item;
|
||||||
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
|
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
|
||||||
const { 0: originalData, ...additionalFields } = item;
|
const { 0: originalData, ...additionalFields } = item;
|
||||||
return { ...originalData, ...additionalFields };
|
raw = { ...originalData, ...additionalFields };
|
||||||
}
|
}
|
||||||
return item;
|
const cleaned: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (!metaFieldsToStrip.has(key)) {
|
||||||
|
cleaned[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
});
|
});
|
||||||
|
|
||||||
const mode = configOrMode?.mode || configOrMode || "append";
|
const mode = configOrMode?.mode || configOrMode || "append";
|
||||||
|
|
||||||
|
// 카테고리 코드 → 라벨 변환
|
||||||
|
// allCategoryColumns 또는 fromMainForm 컬럼의 값을 라벨로 변환
|
||||||
|
const codesToResolve = new Set<string>();
|
||||||
|
for (const item of normalizedData) {
|
||||||
|
for (const [key, val] of Object.entries(item)) {
|
||||||
|
if (key.startsWith("_")) continue;
|
||||||
|
if (typeof val === "string" && val && !categoryLabelMapRef.current[val]) {
|
||||||
|
codesToResolve.add(val as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codesToResolve.size > 0) {
|
||||||
|
try {
|
||||||
|
const resp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: Array.from(codesToResolve),
|
||||||
|
});
|
||||||
|
if (resp.data?.success && resp.data.data) {
|
||||||
|
const labelData = resp.data.data as Record<string, string>;
|
||||||
|
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||||
|
for (const item of normalizedData) {
|
||||||
|
for (const key of Object.keys(item)) {
|
||||||
|
if (key.startsWith("_")) continue;
|
||||||
|
const val = item[key];
|
||||||
|
if (typeof val === "string" && labelData[val]) {
|
||||||
|
item[key] = labelData[val];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 변환 실패 시 코드 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setData((prev) => {
|
setData((prev) => {
|
||||||
const next = mode === "replace" ? normalizedData : [...prev, ...normalizedData];
|
const next = mode === "replace" ? normalizedData : [...prev, ...normalizedData];
|
||||||
onDataChangeRef.current?.(next);
|
onDataChangeRef.current?.(next);
|
||||||
|
|
@ -137,6 +196,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
|
|
||||||
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
|
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
|
||||||
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
||||||
|
const categoryLabelMapRef = useRef<Record<string, string>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
categoryLabelMapRef.current = categoryLabelMap;
|
||||||
|
}, [categoryLabelMap]);
|
||||||
|
|
||||||
// 현재 테이블 컬럼 정보 (inputType 매핑용)
|
// 현재 테이블 컬럼 정보 (inputType 매핑용)
|
||||||
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
|
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
|
||||||
|
|
@ -170,35 +233,54 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
};
|
};
|
||||||
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
|
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
|
||||||
|
|
||||||
// 저장 이벤트 리스너
|
// 저장 이벤트 리스너 (dataRef/categoryLabelMapRef를 사용하여 항상 최신 상태 참조)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSaveEvent = async (event: CustomEvent) => {
|
const handleSaveEvent = async (event: CustomEvent) => {
|
||||||
// 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용
|
const currentData = dataRef.current;
|
||||||
const tableName =
|
const currentCategoryMap = categoryLabelMapRef.current;
|
||||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
||||||
const eventParentId = event.detail?.parentId;
|
|
||||||
const mainFormData = event.detail?.mainFormData;
|
|
||||||
|
|
||||||
// 🆕 마스터 테이블에서 생성된 ID (FK 연결용)
|
const configTableName =
|
||||||
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||||
|
const tableName = configTableName || event.detail?.tableName;
|
||||||
|
const mainFormData = event.detail?.mainFormData;
|
||||||
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
|
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
|
||||||
|
|
||||||
if (!tableName || data.length === 0) {
|
console.log("🔵 [V2Repeater] repeaterSave 이벤트 수신:", {
|
||||||
|
configTableName,
|
||||||
|
tableName,
|
||||||
|
masterRecordId,
|
||||||
|
dataLength: currentData.length,
|
||||||
|
foreignKeyColumn: config.foreignKeyColumn,
|
||||||
|
foreignKeySourceColumn: config.foreignKeySourceColumn,
|
||||||
|
dataSnapshot: currentData.map((r: any) => ({ id: r.id, item_name: r.item_name })),
|
||||||
|
});
|
||||||
|
toast.info(`[디버그] V2Repeater 이벤트 수신: ${currentData.length}건, table=${tableName}`);
|
||||||
|
|
||||||
|
if (!tableName || currentData.length === 0) {
|
||||||
|
console.warn("🔴 [V2Repeater] 저장 스킵:", { tableName, dataLength: currentData.length });
|
||||||
|
toast.warning(`[디버그] V2Repeater 저장 스킵: data=${currentData.length}, table=${tableName}`);
|
||||||
|
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// V2Repeater 저장 시작
|
if (config.foreignKeyColumn) {
|
||||||
const saveInfo = {
|
const sourceCol = config.foreignKeySourceColumn;
|
||||||
|
const hasFkSource = sourceCol && mainFormData && mainFormData[sourceCol] !== undefined;
|
||||||
|
if (!hasFkSource && !masterRecordId) {
|
||||||
|
console.warn("🔴 [V2Repeater] FK 소스 값/masterRecordId 모두 없어 저장 스킵");
|
||||||
|
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("V2Repeater 저장 시작", {
|
||||||
tableName,
|
tableName,
|
||||||
useCustomTable: config.useCustomTable,
|
|
||||||
mainTableName: config.mainTableName,
|
|
||||||
foreignKeyColumn: config.foreignKeyColumn,
|
foreignKeyColumn: config.foreignKeyColumn,
|
||||||
masterRecordId,
|
masterRecordId,
|
||||||
dataLength: data.length,
|
dataLength: currentData.length,
|
||||||
};
|
});
|
||||||
console.log("V2Repeater 저장 시작", saveInfo);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 테이블 유효 컬럼 조회
|
|
||||||
let validColumns: Set<string> = new Set();
|
let validColumns: Set<string> = new Set();
|
||||||
try {
|
try {
|
||||||
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||||
|
|
@ -209,13 +291,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
console.warn("테이블 컬럼 정보 조회 실패");
|
console.warn("테이블 컬럼 정보 조회 실패");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
for (let i = 0; i < currentData.length; i++) {
|
||||||
const row = data[i];
|
const row = currentData[i];
|
||||||
|
|
||||||
// 내부 필드 제거
|
|
||||||
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
|
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
|
||||||
|
|
||||||
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
|
|
||||||
let mergedData: Record<string, any>;
|
let mergedData: Record<string, any>;
|
||||||
if (config.useCustomTable && config.mainTableName) {
|
if (config.useCustomTable && config.mainTableName) {
|
||||||
mergedData = { ...cleanRow };
|
mergedData = { ...cleanRow };
|
||||||
|
|
@ -242,59 +321,83 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 유효하지 않은 컬럼 제거
|
|
||||||
const filteredData: Record<string, any> = {};
|
const filteredData: Record<string, any> = {};
|
||||||
for (const [key, value] of Object.entries(mergedData)) {
|
for (const [key, value] of Object.entries(mergedData)) {
|
||||||
if (validColumns.size === 0 || validColumns.has(key)) {
|
if (validColumns.size === 0 || validColumns.has(key)) {
|
||||||
filteredData[key] = value;
|
if (typeof value === "string" && currentCategoryMap[value]) {
|
||||||
|
filteredData[key] = currentCategoryMap[value];
|
||||||
|
} else {
|
||||||
|
filteredData[key] = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기존 행(id 존재)은 UPDATE, 새 행은 INSERT
|
|
||||||
const rowId = row.id;
|
const rowId = row.id;
|
||||||
|
console.log(`🔧 [V2Repeater] 행 ${i} 저장:`, {
|
||||||
|
rowId,
|
||||||
|
isUpdate: rowId && typeof rowId === "string" && rowId.includes("-"),
|
||||||
|
filteredDataKeys: Object.keys(filteredData),
|
||||||
|
});
|
||||||
if (rowId && typeof rowId === "string" && rowId.includes("-")) {
|
if (rowId && typeof rowId === "string" && rowId.includes("-")) {
|
||||||
// UUID 형태의 id가 있으면 기존 데이터 → UPDATE
|
|
||||||
const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData;
|
const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData;
|
||||||
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
|
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
|
||||||
originalData: { id: rowId },
|
originalData: { id: rowId },
|
||||||
updatedData: updateFields,
|
updatedData: updateFields,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 새 행 → INSERT
|
|
||||||
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
|
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 삭제된 행 처리: 원본에는 있었지만 현재 data에 없는 ID를 DELETE
|
||||||
|
const currentIds = new Set(currentData.map((r) => r.id).filter(Boolean));
|
||||||
|
const deletedIds = Array.from(loadedIdsRef.current).filter((id) => !currentIds.has(id));
|
||||||
|
if (deletedIds.length > 0) {
|
||||||
|
console.log("🗑️ [V2Repeater] 삭제할 행:", deletedIds);
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/table-management/tables/${tableName}/delete`, {
|
||||||
|
data: deletedIds.map((id) => ({ id })),
|
||||||
|
});
|
||||||
|
console.log(`✅ [V2Repeater] ${deletedIds.length}건 삭제 완료`);
|
||||||
|
} catch (deleteError) {
|
||||||
|
console.error("❌ [V2Repeater] 삭제 실패:", deleteError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 저장 완료 후 loadedIdsRef 갱신
|
||||||
|
loadedIdsRef.current = new Set(currentData.map((r) => r.id).filter(Boolean));
|
||||||
|
|
||||||
|
toast.success(`V2Repeater ${currentData.length}건 저장 완료`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ V2Repeater 저장 실패:", error);
|
console.error("❌ V2Repeater 저장 실패:", error);
|
||||||
throw error;
|
toast.error(`V2Repeater 저장 실패: ${error}`);
|
||||||
|
} finally {
|
||||||
|
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// V2 EventBus 구독
|
|
||||||
const unsubscribe = v2EventBus.subscribe(
|
const unsubscribe = v2EventBus.subscribe(
|
||||||
V2_EVENTS.REPEATER_SAVE,
|
V2_EVENTS.REPEATER_SAVE,
|
||||||
async (payload) => {
|
async (payload) => {
|
||||||
const tableName =
|
const configTableName =
|
||||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||||
if (payload.tableName === tableName) {
|
if (!configTableName || payload.tableName === configTableName) {
|
||||||
await handleSaveEvent({ detail: payload } as CustomEvent);
|
await handleSaveEvent({ detail: payload } as CustomEvent);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
|
{ componentId: `v2-repeater-${config.dataSource?.tableName || "same-table"}` },
|
||||||
);
|
);
|
||||||
|
|
||||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
|
||||||
window.addEventListener("repeaterSave" as any, handleSaveEvent);
|
window.addEventListener("repeaterSave" as any, handleSaveEvent);
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
|
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
data,
|
|
||||||
config.dataSource?.tableName,
|
config.dataSource?.tableName,
|
||||||
config.useCustomTable,
|
config.useCustomTable,
|
||||||
config.mainTableName,
|
config.mainTableName,
|
||||||
config.foreignKeyColumn,
|
config.foreignKeyColumn,
|
||||||
|
config.foreignKeySourceColumn,
|
||||||
parentId,
|
parentId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -362,7 +465,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
// 각 행에 소스 테이블의 표시 데이터 병합
|
// 각 행에 소스 테이블의 표시 데이터 병합
|
||||||
// RepeaterTable은 isSourceDisplay 컬럼을 `_display_${col.key}` 필드로 렌더링함
|
|
||||||
rows.forEach((row: any) => {
|
rows.forEach((row: any) => {
|
||||||
const sourceRecord = sourceMap.get(String(row[fkColumn]));
|
const sourceRecord = sourceMap.get(String(row[fkColumn]));
|
||||||
if (sourceRecord) {
|
if (sourceRecord) {
|
||||||
|
|
@ -380,12 +482,50 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환
|
||||||
|
const codesToResolve = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const val of Object.values(row)) {
|
||||||
|
if (typeof val === "string" && val.startsWith("CATEGORY_")) {
|
||||||
|
codesToResolve.add(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codesToResolve.size > 0) {
|
||||||
|
try {
|
||||||
|
const labelResp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: Array.from(codesToResolve),
|
||||||
|
});
|
||||||
|
if (labelResp.data?.success && labelResp.data.data) {
|
||||||
|
const labelData = labelResp.data.data as Record<string, string>;
|
||||||
|
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const key of Object.keys(row)) {
|
||||||
|
if (key.startsWith("_")) continue;
|
||||||
|
const val = row[key];
|
||||||
|
if (typeof val === "string" && labelData[val]) {
|
||||||
|
row[key] = labelData[val];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 라벨 변환 실패 시 코드 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원본 ID 목록 기록 (삭제 추적용)
|
||||||
|
const ids = rows.map((r: any) => r.id).filter(Boolean);
|
||||||
|
loadedIdsRef.current = new Set(ids);
|
||||||
|
console.log("📋 [V2Repeater] 원본 ID 기록:", ids);
|
||||||
|
|
||||||
setData(rows);
|
setData(rows);
|
||||||
dataLoadedRef.current = true;
|
dataLoadedRef.current = true;
|
||||||
if (onDataChange) onDataChange(rows);
|
if (onDataChange) onDataChange(rows);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ [V2Repeater] 기존 데이터 로드 실패:", error);
|
console.error("[V2Repeater] 기존 데이터 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -407,16 +547,28 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
if (!tableName) return;
|
if (!tableName) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
const [colResponse, typeResponse] = await Promise.all([
|
||||||
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
apiClient.get(`/table-management/tables/${tableName}/columns`),
|
||||||
|
apiClient.get(`/table-management/tables/${tableName}/web-types`),
|
||||||
|
]);
|
||||||
|
const columns = colResponse.data?.data?.columns || colResponse.data?.columns || colResponse.data || [];
|
||||||
|
const inputTypes = typeResponse.data?.data || [];
|
||||||
|
|
||||||
|
// inputType/categoryRef 매핑 생성
|
||||||
|
const typeMap: Record<string, any> = {};
|
||||||
|
inputTypes.forEach((t: any) => {
|
||||||
|
typeMap[t.columnName] = t;
|
||||||
|
});
|
||||||
|
|
||||||
const columnMap: Record<string, any> = {};
|
const columnMap: Record<string, any> = {};
|
||||||
columns.forEach((col: any) => {
|
columns.forEach((col: any) => {
|
||||||
const name = col.columnName || col.column_name || col.name;
|
const name = col.columnName || col.column_name || col.name;
|
||||||
|
const typeInfo = typeMap[name];
|
||||||
columnMap[name] = {
|
columnMap[name] = {
|
||||||
inputType: col.inputType || col.input_type || col.webType || "text",
|
inputType: typeInfo?.inputType || col.inputType || col.input_type || col.webType || "text",
|
||||||
displayName: col.displayName || col.display_name || col.label || name,
|
displayName: col.displayName || col.display_name || col.label || name,
|
||||||
detailSettings: col.detailSettings || col.detail_settings,
|
detailSettings: col.detailSettings || col.detail_settings,
|
||||||
|
categoryRef: typeInfo?.categoryRef || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setCurrentTableColumnInfo(columnMap);
|
setCurrentTableColumnInfo(columnMap);
|
||||||
|
|
@ -548,14 +700,18 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
else if (inputType === "code") type = "select";
|
else if (inputType === "code") type = "select";
|
||||||
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
|
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
|
||||||
|
|
||||||
// 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식)
|
// 카테고리 참조 ID 결정
|
||||||
// category 타입인 경우 현재 테이블명과 컬럼명을 조합
|
// DB의 category_ref 설정 우선, 없으면 자기 테이블.컬럼명 사용
|
||||||
let categoryRef: string | undefined;
|
let categoryRef: string | undefined;
|
||||||
if (inputType === "category") {
|
if (inputType === "category") {
|
||||||
// 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용
|
const dbCategoryRef = colInfo?.detailSettings?.categoryRef || colInfo?.categoryRef;
|
||||||
const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
|
if (dbCategoryRef) {
|
||||||
if (tableName) {
|
categoryRef = dbCategoryRef;
|
||||||
categoryRef = `${tableName}.${col.key}`;
|
} else {
|
||||||
|
const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
|
||||||
|
if (tableName) {
|
||||||
|
categoryRef = `${tableName}.${col.key}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -574,63 +730,78 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
|
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
|
||||||
|
|
||||||
// 리피터 컬럼 설정에서 카테고리 타입 컬럼 자동 감지
|
// 리피터 컬럼 설정에서 카테고리 타입 컬럼 자동 감지
|
||||||
|
// repeaterColumns의 resolved type 사용 (config + DB 메타데이터 모두 반영)
|
||||||
const allCategoryColumns = useMemo(() => {
|
const allCategoryColumns = useMemo(() => {
|
||||||
const fromConfig = config.columns
|
const fromRepeater = repeaterColumns
|
||||||
.filter((col) => col.inputType === "category")
|
.filter((col) => col.type === "category")
|
||||||
.map((col) => col.key);
|
.map((col) => col.field.replace(/^_display_/, ""));
|
||||||
const merged = new Set([...sourceCategoryColumns, ...fromConfig]);
|
const merged = new Set([...sourceCategoryColumns, ...fromRepeater]);
|
||||||
return Array.from(merged);
|
return Array.from(merged);
|
||||||
}, [sourceCategoryColumns, config.columns]);
|
}, [sourceCategoryColumns, repeaterColumns]);
|
||||||
|
|
||||||
// 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
|
// CATEGORY_ 코드 배열을 받아 라벨을 일괄 조회하는 함수
|
||||||
|
const fetchCategoryLabels = useCallback(async (codes: string[]) => {
|
||||||
|
if (codes.length === 0) return;
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: codes,
|
||||||
|
});
|
||||||
|
if (response.data?.success && response.data.data) {
|
||||||
|
setCategoryLabelMap((prev) => ({ ...prev, ...response.data.data }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 라벨 조회 실패:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// parentFormData(마스터 행)에서 카테고리 코드를 미리 로드
|
||||||
|
// fromMainForm autoFill에서 참조할 마스터 필드의 라벨을 사전에 확보
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCategoryLabels = async () => {
|
if (!parentFormData) return;
|
||||||
if (allCategoryColumns.length === 0 || data.length === 0) {
|
const codes: string[] = [];
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 데이터에서 카테고리 컬럼의 모든 고유 코드 수집
|
// fromMainForm autoFill의 sourceField 값 중 카테고리 컬럼에 해당하는 것만 수집
|
||||||
const allCodes = new Set<string>();
|
for (const col of config.columns) {
|
||||||
for (const row of data) {
|
if (col.autoFill?.type === "fromMainForm" && col.autoFill.sourceField) {
|
||||||
for (const col of allCategoryColumns) {
|
const val = parentFormData[col.autoFill.sourceField];
|
||||||
// _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인
|
if (typeof val === "string" && val && !categoryLabelMap[val]) {
|
||||||
const val = row[`_display_${col}`] || row[col];
|
codes.push(val);
|
||||||
if (val && typeof val === "string") {
|
|
||||||
const codes = val
|
|
||||||
.split(",")
|
|
||||||
.map((c: string) => c.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
for (const code of codes) {
|
|
||||||
if (!categoryLabelMap[code] && code.length > 0) {
|
|
||||||
allCodes.add(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// receiveFromParent 패턴
|
||||||
if (allCodes.size === 0) {
|
if ((col as any).receiveFromParent) {
|
||||||
return;
|
const parentField = (col as any).parentFieldName || col.key;
|
||||||
}
|
const val = parentFormData[parentField];
|
||||||
|
if (typeof val === "string" && val && !categoryLabelMap[val]) {
|
||||||
try {
|
codes.push(val);
|
||||||
const response = await apiClient.post("/table-categories/labels-by-codes", {
|
|
||||||
valueCodes: Array.from(allCodes),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data?.success && response.data.data) {
|
|
||||||
setCategoryLabelMap((prev) => ({
|
|
||||||
...prev,
|
|
||||||
...response.data.data,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error("카테고리 라벨 조회 실패:", error);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
loadCategoryLabels();
|
if (codes.length > 0) {
|
||||||
}, [data, allCategoryColumns]);
|
fetchCategoryLabels(codes);
|
||||||
|
}
|
||||||
|
}, [parentFormData, config.columns, fetchCategoryLabels]);
|
||||||
|
|
||||||
|
// 데이터 변경 시 카테고리 라벨 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.length === 0) return;
|
||||||
|
|
||||||
|
const allCodes = new Set<string>();
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
for (const col of allCategoryColumns) {
|
||||||
|
const val = row[`_display_${col}`] || row[col];
|
||||||
|
if (val && typeof val === "string") {
|
||||||
|
val.split(",").map((c: string) => c.trim()).filter(Boolean).forEach((code: string) => {
|
||||||
|
if (!categoryLabelMap[code]) allCodes.add(code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCategoryLabels(Array.from(allCodes));
|
||||||
|
}, [data, allCategoryColumns, fetchCategoryLabels]);
|
||||||
|
|
||||||
// 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능)
|
// 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능)
|
||||||
const applyCalculationRules = useCallback(
|
const applyCalculationRules = useCallback(
|
||||||
|
|
@ -747,7 +918,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
|
|
||||||
case "fromMainForm":
|
case "fromMainForm":
|
||||||
if (col.autoFill.sourceField && mainFormData) {
|
if (col.autoFill.sourceField && mainFormData) {
|
||||||
return mainFormData[col.autoFill.sourceField];
|
const rawValue = mainFormData[col.autoFill.sourceField];
|
||||||
|
// categoryLabelMap에 매핑이 있으면 라벨로 변환 (접두사 무관)
|
||||||
|
if (typeof rawValue === "string" && categoryLabelMap[rawValue]) {
|
||||||
|
return categoryLabelMap[rawValue];
|
||||||
|
}
|
||||||
|
return rawValue;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
|
|
||||||
|
|
@ -767,7 +943,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[categoryLabelMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 채번 API 호출 (비동기)
|
// 🆕 채번 API 호출 (비동기)
|
||||||
|
|
@ -801,7 +977,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
const row: any = { _id: `grouped_${Date.now()}_${index}` };
|
const row: any = { _id: `grouped_${Date.now()}_${index}` };
|
||||||
|
|
||||||
for (const col of config.columns) {
|
for (const col of config.columns) {
|
||||||
const sourceValue = item[(col as any).sourceKey || col.key];
|
let sourceValue = item[(col as any).sourceKey || col.key];
|
||||||
|
|
||||||
|
// 카테고리 코드 → 라벨 변환 (접두사 무관, categoryLabelMap 기반)
|
||||||
|
if (typeof sourceValue === "string" && categoryLabelMap[sourceValue]) {
|
||||||
|
sourceValue = categoryLabelMap[sourceValue];
|
||||||
|
}
|
||||||
|
|
||||||
if (col.isSourceDisplay) {
|
if (col.isSourceDisplay) {
|
||||||
row[col.key] = sourceValue ?? "";
|
row[col.key] = sourceValue ?? "";
|
||||||
|
|
@ -822,6 +1003,48 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관)
|
||||||
|
const categoryColSet = new Set(allCategoryColumns);
|
||||||
|
const codesToResolve = new Set<string>();
|
||||||
|
for (const row of newRows) {
|
||||||
|
for (const col of config.columns) {
|
||||||
|
const val = row[col.key] || row[`_display_${col.key}`];
|
||||||
|
if (typeof val === "string" && val && (categoryColSet.has(col.key) || col.autoFill?.type === "fromMainForm")) {
|
||||||
|
if (!categoryLabelMap[val]) {
|
||||||
|
codesToResolve.add(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codesToResolve.size > 0) {
|
||||||
|
apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: Array.from(codesToResolve),
|
||||||
|
}).then((resp) => {
|
||||||
|
if (resp.data?.success && resp.data.data) {
|
||||||
|
const labelData = resp.data.data as Record<string, string>;
|
||||||
|
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||||
|
const convertedRows = newRows.map((row) => {
|
||||||
|
const updated = { ...row };
|
||||||
|
for (const col of config.columns) {
|
||||||
|
const val = updated[col.key];
|
||||||
|
if (typeof val === "string" && labelData[val]) {
|
||||||
|
updated[col.key] = labelData[val];
|
||||||
|
}
|
||||||
|
const dispKey = `_display_${col.key}`;
|
||||||
|
const dispVal = updated[dispKey];
|
||||||
|
if (typeof dispVal === "string" && labelData[dispVal]) {
|
||||||
|
updated[dispKey] = labelData[dispVal];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
setData(convertedRows);
|
||||||
|
onDataChange?.(convertedRows);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
setData(newRows);
|
setData(newRows);
|
||||||
onDataChange?.(newRows);
|
onDataChange?.(newRows);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|
@ -856,7 +1079,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [parentFormData, config.columns, generateAutoFillValueSync]);
|
}, [parentFormData, config.columns, generateAutoFillValueSync]);
|
||||||
|
|
||||||
// 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
|
// 행 추가 (inline 모드 또는 모달 열기)
|
||||||
const handleAddRow = useCallback(async () => {
|
const handleAddRow = useCallback(async () => {
|
||||||
if (isModalMode) {
|
if (isModalMode) {
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
|
|
@ -864,11 +1087,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
const newRow: any = { _id: `new_${Date.now()}` };
|
const newRow: any = { _id: `new_${Date.now()}` };
|
||||||
const currentRowCount = data.length;
|
const currentRowCount = data.length;
|
||||||
|
|
||||||
// 먼저 동기적 자동 입력 값 적용
|
// 동기적 자동 입력 값 적용
|
||||||
for (const col of config.columns) {
|
for (const col of config.columns) {
|
||||||
const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData);
|
const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData);
|
||||||
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
||||||
// 채번 규칙: 즉시 API 호출
|
|
||||||
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
||||||
} else if (autoValue !== undefined) {
|
} else if (autoValue !== undefined) {
|
||||||
newRow[col.key] = autoValue;
|
newRow[col.key] = autoValue;
|
||||||
|
|
@ -877,10 +1099,51 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fromMainForm 등으로 넘어온 카테고리 코드 → 라벨 변환
|
||||||
|
// allCategoryColumns에 해당하는 컬럼이거나 categoryLabelMap에 매핑이 있으면 변환
|
||||||
|
const categoryColSet = new Set(allCategoryColumns);
|
||||||
|
const unresolvedCodes: string[] = [];
|
||||||
|
for (const col of config.columns) {
|
||||||
|
const val = newRow[col.key];
|
||||||
|
if (typeof val !== "string" || !val) continue;
|
||||||
|
|
||||||
|
// 이 컬럼이 카테고리 타입이거나, fromMainForm으로 가져온 값인 경우
|
||||||
|
const isCategoryCol = categoryColSet.has(col.key);
|
||||||
|
const isFromMainForm = col.autoFill?.type === "fromMainForm";
|
||||||
|
|
||||||
|
if (isCategoryCol || isFromMainForm) {
|
||||||
|
if (categoryLabelMap[val]) {
|
||||||
|
newRow[col.key] = categoryLabelMap[val];
|
||||||
|
} else {
|
||||||
|
unresolvedCodes.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unresolvedCodes.length > 0) {
|
||||||
|
try {
|
||||||
|
const resp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: unresolvedCodes,
|
||||||
|
});
|
||||||
|
if (resp.data?.success && resp.data.data) {
|
||||||
|
const labelData = resp.data.data as Record<string, string>;
|
||||||
|
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||||
|
for (const col of config.columns) {
|
||||||
|
const val = newRow[col.key];
|
||||||
|
if (typeof val === "string" && labelData[val]) {
|
||||||
|
newRow[col.key] = labelData[val];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 변환 실패 시 코드 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newData = [...data, newRow];
|
const newData = [...data, newRow];
|
||||||
handleDataChange(newData);
|
handleDataChange(newData);
|
||||||
}
|
}
|
||||||
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData]);
|
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData, categoryLabelMap, allCategoryColumns]);
|
||||||
|
|
||||||
// 모달에서 항목 선택 - 비동기로 변경
|
// 모달에서 항목 선택 - 비동기로 변경
|
||||||
const handleSelectItems = useCallback(
|
const handleSelectItems = useCallback(
|
||||||
|
|
@ -905,8 +1168,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
// 모든 컬럼 처리 (순서대로)
|
// 모든 컬럼 처리 (순서대로)
|
||||||
for (const col of config.columns) {
|
for (const col of config.columns) {
|
||||||
if (col.isSourceDisplay) {
|
if (col.isSourceDisplay) {
|
||||||
// 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용)
|
let displayVal = item[col.key] || "";
|
||||||
row[`_display_${col.key}`] = item[col.key] || "";
|
// 카테고리 컬럼이면 코드→라벨 변환 (접두사 무관)
|
||||||
|
if (typeof displayVal === "string" && categoryLabelMap[displayVal]) {
|
||||||
|
displayVal = categoryLabelMap[displayVal];
|
||||||
|
}
|
||||||
|
row[`_display_${col.key}`] = displayVal;
|
||||||
} else {
|
} else {
|
||||||
// 자동 입력 값 적용
|
// 자동 입력 값 적용
|
||||||
const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData);
|
const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData);
|
||||||
|
|
@ -926,6 +1193,43 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 카테고리/fromMainForm 컬럼에서 미해결 코드 수집 및 변환
|
||||||
|
const categoryColSet = new Set(allCategoryColumns);
|
||||||
|
const unresolvedCodes = new Set<string>();
|
||||||
|
for (const row of newRows) {
|
||||||
|
for (const col of config.columns) {
|
||||||
|
const val = row[col.key];
|
||||||
|
if (typeof val !== "string" || !val) continue;
|
||||||
|
const isCategoryCol = categoryColSet.has(col.key);
|
||||||
|
const isFromMainForm = col.autoFill?.type === "fromMainForm";
|
||||||
|
if ((isCategoryCol || isFromMainForm) && !categoryLabelMap[val]) {
|
||||||
|
unresolvedCodes.add(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unresolvedCodes.size > 0) {
|
||||||
|
try {
|
||||||
|
const resp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: Array.from(unresolvedCodes),
|
||||||
|
});
|
||||||
|
if (resp.data?.success && resp.data.data) {
|
||||||
|
const labelData = resp.data.data as Record<string, string>;
|
||||||
|
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||||
|
for (const row of newRows) {
|
||||||
|
for (const col of config.columns) {
|
||||||
|
const val = row[col.key];
|
||||||
|
if (typeof val === "string" && labelData[val]) {
|
||||||
|
row[col.key] = labelData[val];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 변환 실패 시 코드 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newData = [...data, ...newRows];
|
const newData = [...data, ...newRows];
|
||||||
handleDataChange(newData);
|
handleDataChange(newData);
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
|
|
@ -939,6 +1243,8 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
generateAutoFillValueSync,
|
generateAutoFillValueSync,
|
||||||
generateNumberingCode,
|
generateNumberingCode,
|
||||||
parentFormData,
|
parentFormData,
|
||||||
|
categoryLabelMap,
|
||||||
|
allCategoryColumns,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -951,9 +1257,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
}, [config.columns]);
|
}, [config.columns]);
|
||||||
|
|
||||||
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
|
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
|
||||||
const dataRef = useRef(data);
|
|
||||||
dataRef.current = data;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeFormSave = async (event: Event) => {
|
const handleBeforeFormSave = async (event: Event) => {
|
||||||
const customEvent = event as CustomEvent;
|
const customEvent = event as CustomEvent;
|
||||||
|
|
|
||||||
|
|
@ -480,15 +480,20 @@ export function RepeaterTable({
|
||||||
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
||||||
const value = row[column.field];
|
const value = row[column.field];
|
||||||
|
|
||||||
// 🆕 카테고리 라벨 변환 함수
|
// 카테고리 라벨 변환 함수
|
||||||
const getCategoryDisplayValue = (val: any): string => {
|
const getCategoryDisplayValue = (val: any): string => {
|
||||||
if (!val || typeof val !== "string") return val || "-";
|
if (!val || typeof val !== "string") return val || "-";
|
||||||
|
|
||||||
// 카테고리 컬럼이 아니면 그대로 반환
|
const fieldName = column.field.replace(/^_display_/, "");
|
||||||
const fieldName = column.field.replace(/^_display_/, ""); // _display_ 접두사 제거
|
const isCategoryColumn = categoryColumns.includes(fieldName);
|
||||||
if (!categoryColumns.includes(fieldName)) return val;
|
|
||||||
|
|
||||||
// 쉼표로 구분된 다중 값 처리
|
// categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관)
|
||||||
|
if (categoryLabelMap[val]) return categoryLabelMap[val];
|
||||||
|
|
||||||
|
// 카테고리 컬럼이 아니면 원래 값 반환
|
||||||
|
if (!isCategoryColumn) return val;
|
||||||
|
|
||||||
|
// 콤마 구분된 다중 값 처리
|
||||||
const codes = val
|
const codes = val
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((c: string) => c.trim())
|
.map((c: string) => c.trim())
|
||||||
|
|
|
||||||
|
|
@ -781,6 +781,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const dataProvider: DataProvidable = {
|
const dataProvider: DataProvidable = {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
componentType: "table-list",
|
componentType: "table-list",
|
||||||
|
tableName: tableConfig.selectedTable,
|
||||||
|
|
||||||
getSelectedData: () => {
|
getSelectedData: () => {
|
||||||
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
|
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
|
||||||
|
|
|
||||||
|
|
@ -554,6 +554,69 @@ export function TableSectionRenderer({
|
||||||
loadCategoryOptions();
|
loadCategoryOptions();
|
||||||
}, [tableConfig.source.tableName, tableConfig.columns]);
|
}, [tableConfig.source.tableName, tableConfig.columns]);
|
||||||
|
|
||||||
|
// receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!formData || Object.keys(formData).length === 0) return;
|
||||||
|
if (!tableConfig.columns) return;
|
||||||
|
|
||||||
|
const codesToResolve: string[] = [];
|
||||||
|
for (const col of tableConfig.columns) {
|
||||||
|
// receiveFromParent 컬럼
|
||||||
|
if ((col as any).receiveFromParent) {
|
||||||
|
const parentField = (col as any).parentFieldName || col.field;
|
||||||
|
const val = formData[parentField];
|
||||||
|
if (typeof val === "string" && val) {
|
||||||
|
codesToResolve.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// internal 매핑 컬럼
|
||||||
|
const mapping = (col as any).valueMapping;
|
||||||
|
if (mapping?.type === "internal" && mapping.internalField) {
|
||||||
|
const val = formData[mapping.internalField];
|
||||||
|
if (typeof val === "string" && val) {
|
||||||
|
codesToResolve.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codesToResolve.length === 0) return;
|
||||||
|
|
||||||
|
const loadParentLabels = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: codesToResolve,
|
||||||
|
});
|
||||||
|
if (resp.data?.success && resp.data.data) {
|
||||||
|
const labelData = resp.data.data as Record<string, string>;
|
||||||
|
// categoryOptionsMap에 추가 (receiveFromParent 컬럼별로)
|
||||||
|
const newOptionsMap: Record<string, { value: string; label: string }[]> = {};
|
||||||
|
for (const col of tableConfig.columns) {
|
||||||
|
let val: string | undefined;
|
||||||
|
if ((col as any).receiveFromParent) {
|
||||||
|
const parentField = (col as any).parentFieldName || col.field;
|
||||||
|
val = formData[parentField] as string;
|
||||||
|
}
|
||||||
|
const mapping = (col as any).valueMapping;
|
||||||
|
if (mapping?.type === "internal" && mapping.internalField) {
|
||||||
|
val = formData[mapping.internalField] as string;
|
||||||
|
}
|
||||||
|
if (val && typeof val === "string" && labelData[val]) {
|
||||||
|
newOptionsMap[col.field] = [{ value: val, label: labelData[val] }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(newOptionsMap).length > 0) {
|
||||||
|
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 라벨 조회 실패 시 무시
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadParentLabels();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [formData, tableConfig.columns]);
|
||||||
|
|
||||||
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
|
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isConditionalMode) return;
|
if (!isConditionalMode) return;
|
||||||
|
|
@ -1005,6 +1068,23 @@ export function TableSectionRenderer({
|
||||||
});
|
});
|
||||||
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
|
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
|
||||||
|
|
||||||
|
// categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생
|
||||||
|
const tableCategoryColumns = useMemo(() => {
|
||||||
|
return Object.keys(categoryOptionsMap);
|
||||||
|
}, [categoryOptionsMap]);
|
||||||
|
|
||||||
|
const tableCategoryLabelMap = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const options of Object.values(categoryOptionsMap)) {
|
||||||
|
for (const opt of options) {
|
||||||
|
if (opt.value && opt.label) {
|
||||||
|
map[opt.value] = opt.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [categoryOptionsMap]);
|
||||||
|
|
||||||
// 원본 계산 규칙 (조건부 계산 포함)
|
// 원본 계산 규칙 (조건부 계산 포함)
|
||||||
const originalCalculationRules: TableCalculationRule[] = useMemo(
|
const originalCalculationRules: TableCalculationRule[] = useMemo(
|
||||||
() => tableConfig.calculations || [],
|
() => tableConfig.calculations || [],
|
||||||
|
|
@ -1312,6 +1392,67 @@ export function TableSectionRenderer({
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용)
|
||||||
|
const categoryFields = (tableConfig.columns || [])
|
||||||
|
.filter((col) => col.type === "category" || col.type === "select")
|
||||||
|
.reduce<Record<string, Record<string, string>>>((acc, col) => {
|
||||||
|
const options = categoryOptionsMap[col.field];
|
||||||
|
if (options && options.length > 0) {
|
||||||
|
acc[col.field] = {};
|
||||||
|
for (const opt of options) {
|
||||||
|
acc[col.field][opt.value] = opt.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// receiveFromParent / internal 매핑으로 넘어온 값도 포함하여 변환
|
||||||
|
if (Object.keys(categoryFields).length > 0) {
|
||||||
|
for (const item of mappedItems) {
|
||||||
|
for (const [field, codeToLabel] of Object.entries(categoryFields)) {
|
||||||
|
const val = item[field];
|
||||||
|
if (typeof val === "string" && codeToLabel[val]) {
|
||||||
|
item[field] = codeToLabel[val];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryOptionsMap에 없는 경우 API fallback
|
||||||
|
const unresolvedCodes = new Set<string>();
|
||||||
|
const categoryColFields = new Set(
|
||||||
|
(tableConfig.columns || []).filter((col) => col.type === "category").map((col) => col.field),
|
||||||
|
);
|
||||||
|
for (const item of mappedItems) {
|
||||||
|
for (const field of categoryColFields) {
|
||||||
|
const val = item[field];
|
||||||
|
if (typeof val === "string" && val && !categoryFields[field]?.[val] && val !== item[field]) {
|
||||||
|
unresolvedCodes.add(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unresolvedCodes.size > 0) {
|
||||||
|
try {
|
||||||
|
const labelResp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: Array.from(unresolvedCodes),
|
||||||
|
});
|
||||||
|
if (labelResp.data?.success && labelResp.data.data) {
|
||||||
|
const labelData = labelResp.data.data as Record<string, string>;
|
||||||
|
for (const item of mappedItems) {
|
||||||
|
for (const field of categoryColFields) {
|
||||||
|
const val = item[field];
|
||||||
|
if (typeof val === "string" && labelData[val]) {
|
||||||
|
item[field] = labelData[val];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 변환 실패 시 코드 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 계산 필드 업데이트
|
// 계산 필드 업데이트
|
||||||
const calculatedItems = calculateAll(mappedItems);
|
const calculatedItems = calculateAll(mappedItems);
|
||||||
|
|
||||||
|
|
@ -1319,7 +1460,7 @@ export function TableSectionRenderer({
|
||||||
const newData = [...tableData, ...calculatedItems];
|
const newData = [...tableData, ...calculatedItems];
|
||||||
handleDataChange(newData);
|
handleDataChange(newData);
|
||||||
},
|
},
|
||||||
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources],
|
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources, categoryOptionsMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 컬럼 모드/조회 옵션 변경 핸들러
|
// 컬럼 모드/조회 옵션 변경 핸들러
|
||||||
|
|
@ -1667,6 +1808,31 @@ export function TableSectionRenderer({
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용)
|
||||||
|
const categoryFields = (tableConfig.columns || [])
|
||||||
|
.filter((col) => col.type === "category" || col.type === "select")
|
||||||
|
.reduce<Record<string, Record<string, string>>>((acc, col) => {
|
||||||
|
const options = categoryOptionsMap[col.field];
|
||||||
|
if (options && options.length > 0) {
|
||||||
|
acc[col.field] = {};
|
||||||
|
for (const opt of options) {
|
||||||
|
acc[col.field][opt.value] = opt.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
if (Object.keys(categoryFields).length > 0) {
|
||||||
|
for (const item of mappedItems) {
|
||||||
|
for (const [field, codeToLabel] of Object.entries(categoryFields)) {
|
||||||
|
const val = item[field];
|
||||||
|
if (typeof val === "string" && codeToLabel[val]) {
|
||||||
|
item[field] = codeToLabel[val];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 현재 조건의 데이터에 추가
|
// 현재 조건의 데이터에 추가
|
||||||
const currentData = conditionalTableData[modalCondition] || [];
|
const currentData = conditionalTableData[modalCondition] || [];
|
||||||
const newData = [...currentData, ...mappedItems];
|
const newData = [...currentData, ...mappedItems];
|
||||||
|
|
@ -1964,6 +2130,8 @@ export function TableSectionRenderer({
|
||||||
[conditionValue]: newSelected,
|
[conditionValue]: newSelected,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
|
categoryColumns={tableCategoryColumns}
|
||||||
|
categoryLabelMap={tableCategoryLabelMap}
|
||||||
equalizeWidthsTrigger={widthTrigger}
|
equalizeWidthsTrigger={widthTrigger}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -2055,6 +2223,8 @@ export function TableSectionRenderer({
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
equalizeWidthsTrigger={widthTrigger}
|
equalizeWidthsTrigger={widthTrigger}
|
||||||
|
categoryColumns={tableCategoryColumns}
|
||||||
|
categoryLabelMap={tableCategoryLabelMap}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
|
|
@ -2185,6 +2355,8 @@ export function TableSectionRenderer({
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
onSelectionChange={setSelectedRows}
|
onSelectionChange={setSelectedRows}
|
||||||
equalizeWidthsTrigger={widthTrigger}
|
equalizeWidthsTrigger={widthTrigger}
|
||||||
|
categoryColumns={tableCategoryColumns}
|
||||||
|
categoryLabelMap={tableCategoryLabelMap}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 항목 선택 모달 */}
|
{/* 항목 선택 모달 */}
|
||||||
|
|
|
||||||
|
|
@ -393,7 +393,7 @@ export interface TableModalFilter {
|
||||||
export interface TableColumnConfig {
|
export interface TableColumnConfig {
|
||||||
field: string; // 필드명 (저장할 컬럼명)
|
field: string; // 필드명 (저장할 컬럼명)
|
||||||
label: string; // 컬럼 헤더 라벨
|
label: string; // 컬럼 헤더 라벨
|
||||||
type: "text" | "number" | "date" | "select"; // 입력 타입
|
type: "text" | "number" | "date" | "select" | "category"; // 입력 타입
|
||||||
|
|
||||||
// 소스 필드 매핑 (검색 모달에서 가져올 컬럼명)
|
// 소스 필드 매핑 (검색 모달에서 가져올 컬럼명)
|
||||||
sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일)
|
sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일)
|
||||||
|
|
|
||||||
|
|
@ -897,11 +897,30 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 매핑 규칙 적용 + 추가 데이터 병합
|
// 4. 매핑 규칙 결정: 멀티 테이블 매핑 또는 레거시 단일 매핑
|
||||||
const mappedData = sourceData.map((row) => {
|
let effectiveMappingRules: any[] = dataTransferConfig.mappingRules || [];
|
||||||
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
|
|
||||||
|
const sourceTableName = sourceProvider?.tableName;
|
||||||
|
const multiTableMappings: Array<{ sourceTable: string; mappingRules: any[] }> =
|
||||||
|
dataTransferConfig.multiTableMappings || [];
|
||||||
|
|
||||||
|
if (multiTableMappings.length > 0 && sourceTableName) {
|
||||||
|
const matchedGroup = multiTableMappings.find((g) => g.sourceTable === sourceTableName);
|
||||||
|
if (matchedGroup) {
|
||||||
|
effectiveMappingRules = matchedGroup.mappingRules || [];
|
||||||
|
console.log(`✅ [ButtonPrimary] 멀티 테이블 매핑 적용: ${sourceTableName}`, effectiveMappingRules);
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ [ButtonPrimary] 소스 테이블 ${sourceTableName}에 대한 매핑 없음, 동일 필드명 자동 매핑`);
|
||||||
|
effectiveMappingRules = [];
|
||||||
|
}
|
||||||
|
} else if (multiTableMappings.length > 0 && !sourceTableName) {
|
||||||
|
console.log("⚠️ [ButtonPrimary] 소스 테이블 미감지, 첫 번째 매핑 그룹 사용");
|
||||||
|
effectiveMappingRules = multiTableMappings[0]?.mappingRules || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedData = sourceData.map((row) => {
|
||||||
|
const mappedRow = applyMappingRules(row, effectiveMappingRules);
|
||||||
|
|
||||||
// 추가 데이터를 모든 행에 포함
|
|
||||||
return {
|
return {
|
||||||
...mappedRow,
|
...mappedRow,
|
||||||
...additionalData,
|
...additionalData,
|
||||||
|
|
|
||||||
|
|
@ -654,7 +654,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
|
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
|
||||||
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
|
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
|
||||||
const [columnMeta, setColumnMeta] = useState<
|
const [columnMeta, setColumnMeta] = useState<
|
||||||
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
|
Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }>
|
||||||
>({});
|
>({});
|
||||||
// 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType)
|
// 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType)
|
||||||
const [joinedColumnMeta, setJoinedColumnMeta] = useState<
|
const [joinedColumnMeta, setJoinedColumnMeta] = useState<
|
||||||
|
|
@ -865,6 +865,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const dataProvider: DataProvidable = {
|
const dataProvider: DataProvidable = {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
componentType: "table-list",
|
componentType: "table-list",
|
||||||
|
tableName: tableConfig.selectedTable,
|
||||||
|
|
||||||
getSelectedData: () => {
|
getSelectedData: () => {
|
||||||
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
|
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
|
||||||
|
|
@ -1233,13 +1234,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const cached = tableColumnCache.get(cacheKey);
|
const cached = tableColumnCache.get(cacheKey);
|
||||||
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
|
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
|
||||||
const labels: Record<string, string> = {};
|
const labels: Record<string, string> = {};
|
||||||
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }> = {};
|
||||||
|
|
||||||
// 캐시된 inputTypes 맵 생성
|
|
||||||
const inputTypeMap: Record<string, string> = {};
|
const inputTypeMap: Record<string, string> = {};
|
||||||
|
const categoryRefMap: Record<string, string> = {};
|
||||||
if (cached.inputTypes) {
|
if (cached.inputTypes) {
|
||||||
cached.inputTypes.forEach((col: any) => {
|
cached.inputTypes.forEach((col: any) => {
|
||||||
inputTypeMap[col.columnName] = col.inputType;
|
inputTypeMap[col.columnName] = col.inputType;
|
||||||
|
if (col.categoryRef) {
|
||||||
|
categoryRefMap[col.columnName] = col.categoryRef;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1248,7 +1252,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
meta[col.columnName] = {
|
meta[col.columnName] = {
|
||||||
webType: col.webType,
|
webType: col.webType,
|
||||||
codeCategory: col.codeCategory,
|
codeCategory: col.codeCategory,
|
||||||
inputType: inputTypeMap[col.columnName], // 캐시된 inputType 사용!
|
inputType: inputTypeMap[col.columnName],
|
||||||
|
categoryRef: categoryRefMap[col.columnName],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1259,11 +1264,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
|
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
|
||||||
|
|
||||||
// 컬럼 입력 타입 정보 가져오기
|
|
||||||
const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable);
|
const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable);
|
||||||
const inputTypeMap: Record<string, string> = {};
|
const inputTypeMap: Record<string, string> = {};
|
||||||
|
const categoryRefMap: Record<string, string> = {};
|
||||||
inputTypes.forEach((col: any) => {
|
inputTypes.forEach((col: any) => {
|
||||||
inputTypeMap[col.columnName] = col.inputType;
|
inputTypeMap[col.columnName] = col.inputType;
|
||||||
|
if (col.categoryRef) {
|
||||||
|
categoryRefMap[col.columnName] = col.categoryRef;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tableColumnCache.set(cacheKey, {
|
tableColumnCache.set(cacheKey, {
|
||||||
|
|
@ -1273,7 +1281,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
const labels: Record<string, string> = {};
|
const labels: Record<string, string> = {};
|
||||||
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }> = {};
|
||||||
|
|
||||||
columns.forEach((col: any) => {
|
columns.forEach((col: any) => {
|
||||||
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
||||||
|
|
@ -1281,6 +1289,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
webType: col.webType,
|
webType: col.webType,
|
||||||
codeCategory: col.codeCategory,
|
codeCategory: col.codeCategory,
|
||||||
inputType: inputTypeMap[col.columnName],
|
inputType: inputTypeMap[col.columnName],
|
||||||
|
categoryRef: categoryRefMap[col.columnName],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1355,14 +1364,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
for (const columnName of categoryColumns) {
|
for (const columnName of categoryColumns) {
|
||||||
try {
|
try {
|
||||||
// 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인
|
|
||||||
let targetTable = tableConfig.selectedTable;
|
let targetTable = tableConfig.selectedTable;
|
||||||
let targetColumn = columnName;
|
let targetColumn = columnName;
|
||||||
|
|
||||||
if (columnName.includes(".")) {
|
// category_ref가 있으면 참조 테이블.컬럼 기준으로 조회
|
||||||
|
const meta = columnMeta[columnName];
|
||||||
|
if (meta?.categoryRef) {
|
||||||
|
const refParts = meta.categoryRef.split(".");
|
||||||
|
if (refParts.length === 2) {
|
||||||
|
targetTable = refParts[0];
|
||||||
|
targetColumn = refParts[1];
|
||||||
|
}
|
||||||
|
} else if (columnName.includes(".")) {
|
||||||
|
// 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태
|
||||||
const parts = columnName.split(".");
|
const parts = columnName.split(".");
|
||||||
targetTable = parts[0]; // 조인된 테이블명 (예: item_info)
|
targetTable = parts[0];
|
||||||
targetColumn = parts[1]; // 실제 컬럼명 (예: material)
|
targetColumn = parts[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
|
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
|
||||||
|
|
@ -1563,7 +1580,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
categoryColumns.length,
|
categoryColumns.length,
|
||||||
JSON.stringify(categoryColumns),
|
JSON.stringify(categoryColumns),
|
||||||
JSON.stringify(tableConfig.columns),
|
JSON.stringify(tableConfig.columns),
|
||||||
]); // 더 명확한 의존성
|
columnMeta,
|
||||||
|
]);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 데이터 가져오기
|
// 데이터 가져오기
|
||||||
|
|
|
||||||
|
|
@ -559,6 +559,7 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||||||
|
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
|
||||||
if (onSave) {
|
if (onSave) {
|
||||||
try {
|
try {
|
||||||
await onSave();
|
await onSave();
|
||||||
|
|
@ -626,6 +627,7 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||||||
// 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리)
|
// 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리)
|
||||||
|
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
|
||||||
if (onSave && !hasTableSectionData) {
|
if (onSave && !hasTableSectionData) {
|
||||||
try {
|
try {
|
||||||
await onSave();
|
await onSave();
|
||||||
|
|
@ -1494,13 +1496,24 @@ export class ButtonActionExecutor {
|
||||||
// @ts-ignore - window에 동적 속성 사용
|
// @ts-ignore - window에 동적 속성 사용
|
||||||
const v2RepeaterTables = Array.from(window.__v2RepeaterInstances || []);
|
const v2RepeaterTables = Array.from(window.__v2RepeaterInstances || []);
|
||||||
|
|
||||||
|
// V2Repeater가 동일 테이블에 존재하는지 allComponents로 감지
|
||||||
|
// (useCustomTable 미설정 = 화면 테이블에 직접 저장하는 리피터)
|
||||||
|
const hasRepeaterOnSameTable = context.allComponents?.some((c: any) => {
|
||||||
|
const compType = c.componentType || c.overrides?.type;
|
||||||
|
if (compType !== "v2-repeater") return false;
|
||||||
|
const compConfig = c.componentConfig || c.overrides || {};
|
||||||
|
return !compConfig.useCustomTable;
|
||||||
|
}) || false;
|
||||||
|
|
||||||
// 메인 저장 건너뛰기 조건:
|
// 메인 저장 건너뛰기 조건:
|
||||||
// 1. RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리
|
// 1. RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리
|
||||||
// 2. V2Repeater가 같은 테이블에 존재 (리피터 데이터에 메인 폼 데이터 병합되어 저장됨)
|
// 2. V2Repeater가 같은 테이블에 존재 (리피터 데이터에 메인 폼 데이터 병합되어 저장됨)
|
||||||
|
// 3. allComponents에서 useCustomTable 미설정 V2Repeater 감지 (글로벌 등록 없는 경우)
|
||||||
const shouldSkipMainSave =
|
const shouldSkipMainSave =
|
||||||
repeatScreenModalTables.includes(tableName) ||
|
repeatScreenModalTables.includes(tableName) ||
|
||||||
repeaterFieldGroupTables.includes(tableName) ||
|
repeaterFieldGroupTables.includes(tableName) ||
|
||||||
v2RepeaterTables.includes(tableName);
|
v2RepeaterTables.includes(tableName) ||
|
||||||
|
hasRepeaterOnSameTable;
|
||||||
|
|
||||||
if (shouldSkipMainSave) {
|
if (shouldSkipMainSave) {
|
||||||
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal/V2Repeater에서 처리" };
|
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal/V2Repeater에서 처리" };
|
||||||
|
|
@ -1779,16 +1792,7 @@ export class ButtonActionExecutor {
|
||||||
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
|
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
|
// V2Repeater 저장 이벤트 발생 (모달 닫기 전에 실행해야 V2Repeater가 이벤트를 수신할 수 있음)
|
||||||
context.onRefresh?.();
|
|
||||||
context.onFlowRefresh?.();
|
|
||||||
|
|
||||||
// 저장 성공 후 이벤트 발생
|
|
||||||
window.dispatchEvent(new CustomEvent("closeEditModal")); // EditModal 닫기
|
|
||||||
window.dispatchEvent(new CustomEvent("saveSuccessInModal")); // ScreenModal 연속 등록 모드 처리
|
|
||||||
|
|
||||||
// V2Repeater 저장 이벤트 발생 (메인 폼 데이터 + 리피터 데이터 병합 저장)
|
|
||||||
// 🔧 formData를 리피터에 전달하여 각 행에 병합 저장
|
|
||||||
const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id;
|
const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id;
|
||||||
|
|
||||||
// _deferSave 데이터 처리 (마스터-디테일 순차 저장: 레벨별 저장 + temp→real ID 매핑)
|
// _deferSave 데이터 처리 (마스터-디테일 순차 저장: 레벨별 저장 + temp→real ID 매핑)
|
||||||
|
|
@ -1866,17 +1870,45 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("🟢 [buttonActions] repeaterSave 이벤트 발행:", {
|
||||||
|
parentId: savedId,
|
||||||
|
tableName: context.tableName,
|
||||||
|
masterRecordId: savedId,
|
||||||
|
mainFormDataKeys: Object.keys(mainFormData),
|
||||||
|
});
|
||||||
|
|
||||||
|
// V2Repeater 저장 완료를 기다리기 위한 Promise
|
||||||
|
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||||
|
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||||
|
const handler = () => {
|
||||||
|
clearTimeout(fallbackTimeout);
|
||||||
|
window.removeEventListener("repeaterSaveComplete", handler);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
window.addEventListener("repeaterSaveComplete", handler);
|
||||||
|
});
|
||||||
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("repeaterSave", {
|
new CustomEvent("repeaterSave", {
|
||||||
detail: {
|
detail: {
|
||||||
parentId: savedId,
|
parentId: savedId,
|
||||||
tableName: context.tableName,
|
tableName: context.tableName,
|
||||||
mainFormData, // 🆕 메인 폼 데이터 전달
|
mainFormData,
|
||||||
masterRecordId: savedId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
|
masterRecordId: savedId,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await repeaterSavePromise;
|
||||||
|
|
||||||
|
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
|
||||||
|
context.onRefresh?.();
|
||||||
|
context.onFlowRefresh?.();
|
||||||
|
|
||||||
|
// 저장 성공 후 모달 닫기 이벤트 발생
|
||||||
|
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||||
|
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("저장 오류:", error);
|
console.error("저장 오류:", error);
|
||||||
|
|
@ -1884,6 +1916,50 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2Repeater 디테일 데이터 저장 이벤트 발행 (onSave 콜백 경로에서도 사용)
|
||||||
|
*/
|
||||||
|
private static async dispatchRepeaterSave(context: ButtonActionContext): Promise<void> {
|
||||||
|
const formData = context.formData || {};
|
||||||
|
const savedId = formData.id;
|
||||||
|
|
||||||
|
if (!savedId) {
|
||||||
|
console.log("⚠️ [dispatchRepeaterSave] savedId(formData.id) 없음 - 스킵");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🟢 [dispatchRepeaterSave] repeaterSave 이벤트 발행:", {
|
||||||
|
parentId: savedId,
|
||||||
|
tableName: context.tableName,
|
||||||
|
masterRecordId: savedId,
|
||||||
|
formDataKeys: Object.keys(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||||
|
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||||
|
const handler = () => {
|
||||||
|
clearTimeout(fallbackTimeout);
|
||||||
|
window.removeEventListener("repeaterSaveComplete", handler);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
window.addEventListener("repeaterSaveComplete", handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("repeaterSave", {
|
||||||
|
detail: {
|
||||||
|
parentId: savedId,
|
||||||
|
tableName: context.tableName,
|
||||||
|
mainFormData: formData,
|
||||||
|
masterRecordId: savedId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await repeaterSavePromise;
|
||||||
|
console.log("✅ [dispatchRepeaterSave] repeaterSave 완료");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DB에서 조회한 실제 기본키로 formData에서 값 추출
|
* DB에서 조회한 실제 기본키로 formData에서 값 추출
|
||||||
* @param formData 폼 데이터
|
* @param formData 폼 데이터
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,15 @@ export interface MappingRule {
|
||||||
required?: boolean; // 필수 여부
|
required?: boolean; // 필수 여부
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 멀티 테이블 매핑 그룹
|
||||||
|
* 소스 테이블별로 별도의 매핑 규칙을 정의
|
||||||
|
*/
|
||||||
|
export interface MultiTableMappingGroup {
|
||||||
|
sourceTable: string;
|
||||||
|
mappingRules: MappingRule[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 수신자 설정
|
* 데이터 수신자 설정
|
||||||
* 데이터를 받을 타겟 컴포넌트의 설정
|
* 데이터를 받을 타겟 컴포넌트의 설정
|
||||||
|
|
@ -155,6 +164,7 @@ export interface DataReceivable {
|
||||||
export interface DataProvidable {
|
export interface DataProvidable {
|
||||||
componentId: string;
|
componentId: string;
|
||||||
componentType: string;
|
componentType: string;
|
||||||
|
tableName?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 선택된 데이터를 가져오는 메서드
|
* 선택된 데이터를 가져오는 메서드
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue