feat: Implement entity join functionality in V2Repeater and configuration panel
- Added support for entity joins in the V2Repeater component, allowing for automatic resolution of foreign key references to display data from related tables. - Introduced a new `resolveEntityJoins` function to handle the fetching and mapping of reference data based on configured entity joins. - Enhanced the V2RepeaterConfigPanel to manage entity join configurations, including loading available columns and toggling join settings. - Updated the data handling logic to incorporate mapping rules for incoming data, ensuring that only necessary fields are retained during processing. - Improved user experience by providing clear logging and feedback during entity join resolution and data mapping operations.
This commit is contained in:
@@ -89,6 +89,67 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
const onDataChangeRef = useRef(onDataChange);
|
||||
onDataChangeRef.current = onDataChange;
|
||||
|
||||
// Entity 조인 설정을 ref로 보관 (이벤트 핸들러 closure에서 항상 최신값 참조)
|
||||
const entityJoinsRef = useRef(config.entityJoins);
|
||||
useEffect(() => {
|
||||
entityJoinsRef.current = config.entityJoins;
|
||||
}, [config.entityJoins]);
|
||||
|
||||
// Entity 조인 해석: FK 값을 기반으로 참조 테이블에서 표시 데이터를 가져와 행에 채움
|
||||
const resolveEntityJoins = useCallback(async (rows: any[]): Promise<any[]> => {
|
||||
const entityJoins = entityJoinsRef.current;
|
||||
console.log("🔍 [V2Repeater] resolveEntityJoins 시작:", {
|
||||
entityJoins,
|
||||
rowCount: rows.length,
|
||||
sampleRow: rows[0],
|
||||
});
|
||||
|
||||
if (!entityJoins || entityJoins.length === 0) {
|
||||
console.warn("⚠️ [V2Repeater] entityJoins 설정 없음 - 해석 스킵");
|
||||
return rows;
|
||||
}
|
||||
|
||||
const resolvedRows = rows.map((r) => ({ ...r }));
|
||||
|
||||
for (const join of entityJoins) {
|
||||
const fkValues = [...new Set(resolvedRows.map((r) => r[join.sourceColumn]).filter(Boolean))];
|
||||
console.log(`🔍 [V2Repeater] FK 값 추출: ${join.sourceColumn} → [${fkValues.join(", ")}]`);
|
||||
if (fkValues.length === 0) continue;
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/table-management/tables/${join.referenceTable}/data`, {
|
||||
page: 1,
|
||||
size: fkValues.length + 10,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "id", operator: "in", value: fkValues }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
console.log(`🔍 [V2Repeater] API 응답:`, response.data);
|
||||
const refData = response.data?.data?.data || response.data?.data?.rows || [];
|
||||
const lookupMap = new Map(refData.map((r: any) => [String(r.id), r]));
|
||||
|
||||
resolvedRows.forEach((row) => {
|
||||
const fkVal = String(row[join.sourceColumn] || "");
|
||||
const refRecord = lookupMap.get(fkVal);
|
||||
if (refRecord) {
|
||||
join.columns.forEach((col) => {
|
||||
row[col.displayField] = refRecord[col.referenceField];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ [V2Repeater] Entity 조인 해석 완료: ${join.referenceTable} (${fkValues.length}건, 조회결과: ${refData.length}건)`);
|
||||
} catch (error) {
|
||||
console.error(`❌ [V2Repeater] Entity 조인 해석 실패: ${join.referenceTable}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedRows;
|
||||
}, []);
|
||||
|
||||
const handleReceiveData = useCallback(
|
||||
async (incomingData: any[], configOrMode?: any) => {
|
||||
console.log("📥 [V2Repeater] 데이터 수신:", { count: incomingData?.length, configOrMode });
|
||||
@@ -98,6 +159,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// mappingRules 처리: configOrMode에 mappingRules가 있으면 적용
|
||||
const mappingRules = configOrMode?.mappingRules;
|
||||
|
||||
// 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거
|
||||
const metaFieldsToStrip = new Set([
|
||||
"id",
|
||||
@@ -107,12 +171,33 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
"updated_by",
|
||||
"company_code",
|
||||
]);
|
||||
const normalizedData = incomingData.map((item: any) => {
|
||||
let normalizedData = incomingData.map((item: any, index: number) => {
|
||||
let raw = item;
|
||||
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
|
||||
const { 0: originalData, ...additionalFields } = item;
|
||||
raw = { ...originalData, ...additionalFields };
|
||||
}
|
||||
|
||||
// mappingRules가 있으면 규칙에 따라 매핑 (필요한 필드만 추출)
|
||||
if (mappingRules && mappingRules.length > 0) {
|
||||
const mapped: Record<string, any> = { _id: `receive_${Date.now()}_${index}` };
|
||||
for (const rule of mappingRules) {
|
||||
mapped[rule.targetField] = raw[rule.sourceField];
|
||||
}
|
||||
// additionalSources에서 추가된 필드도 유지 (mappingRules에 없는 필드 중 메타가 아닌 것)
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!metaFieldsToStrip.has(key) && !(key in mapped) && !key.startsWith("_")) {
|
||||
// 소스 테이블의 컬럼이 아닌 추가 데이터만 유지 (additionalSources 등)
|
||||
const isMappingSource = mappingRules.some((r: any) => r.sourceField === key);
|
||||
if (!isMappingSource) {
|
||||
mapped[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
// mappingRules 없으면 기존 로직: 메타 필드만 제거
|
||||
const cleaned: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!metaFieldsToStrip.has(key)) {
|
||||
@@ -122,10 +207,16 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
return cleaned;
|
||||
});
|
||||
|
||||
console.log("📥 [V2Repeater] 매핑 후 데이터:", normalizedData);
|
||||
|
||||
// Entity 조인 해석 (FK → 참조 테이블 데이터)
|
||||
normalizedData = await resolveEntityJoins(normalizedData);
|
||||
|
||||
console.log("📥 [V2Repeater] Entity 조인 후 데이터:", normalizedData);
|
||||
|
||||
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)) {
|
||||
@@ -167,7 +258,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
|
||||
toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`);
|
||||
},
|
||||
[],
|
||||
[resolveEntityJoins],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1412,32 +1503,31 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
}
|
||||
|
||||
// 데이터 매핑 처리
|
||||
const mappedData = transferData.map((item: any, index: number) => {
|
||||
let mappedData = transferData.map((item: any, index: number) => {
|
||||
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
|
||||
|
||||
if (mappingRules && mappingRules.length > 0) {
|
||||
// 매핑 규칙이 있으면 적용
|
||||
mappingRules.forEach((rule: any) => {
|
||||
newRow[rule.targetField] = item[rule.sourceField];
|
||||
});
|
||||
} else {
|
||||
// 매핑 규칙 없으면 그대로 복사
|
||||
Object.assign(newRow, item);
|
||||
}
|
||||
|
||||
return newRow;
|
||||
});
|
||||
|
||||
// Entity 조인 해석 (FK → 참조 테이블 데이터)
|
||||
mappedData = await resolveEntityJoins(mappedData);
|
||||
|
||||
// mode에 따라 데이터 처리
|
||||
if (mode === "replace") {
|
||||
handleDataChange(mappedData);
|
||||
} else if (mode === "merge") {
|
||||
// 중복 제거 후 병합 (id 기준)
|
||||
const existingIds = new Set(data.map((row) => row.id || row._id));
|
||||
const newItems = mappedData.filter((row: any) => !existingIds.has(row.id || row._id));
|
||||
handleDataChange([...data, ...newItems]);
|
||||
} else {
|
||||
// 기본: append
|
||||
handleDataChange([...data, ...mappedData]);
|
||||
}
|
||||
};
|
||||
@@ -1447,12 +1537,21 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
const customEvent = event as CustomEvent;
|
||||
const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {};
|
||||
|
||||
console.log("📨 [V2Repeater] splitPanelDataTransfer 수신:", {
|
||||
dataCount: transferData?.length,
|
||||
mappingRules,
|
||||
mode,
|
||||
sourcePosition,
|
||||
sampleSourceData: transferData?.[0],
|
||||
entityJoinsConfig: entityJoinsRef.current,
|
||||
});
|
||||
|
||||
if (!transferData || transferData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 매핑 처리
|
||||
const mappedData = transferData.map((item: any, index: number) => {
|
||||
let mappedData = transferData.map((item: any, index: number) => {
|
||||
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
|
||||
|
||||
if (mappingRules && mappingRules.length > 0) {
|
||||
@@ -1466,6 +1565,13 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
return newRow;
|
||||
});
|
||||
|
||||
console.log("📨 [V2Repeater] 매핑 후 데이터:", mappedData);
|
||||
|
||||
// Entity 조인 해석 (FK → 참조 테이블 데이터)
|
||||
mappedData = await resolveEntityJoins(mappedData);
|
||||
|
||||
console.log("📨 [V2Repeater] Entity 조인 후 데이터:", mappedData);
|
||||
|
||||
// mode에 따라 데이터 처리
|
||||
if (mode === "replace") {
|
||||
handleDataChange(mappedData);
|
||||
|
||||
@@ -48,12 +48,14 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
V2RepeaterConfig,
|
||||
RepeaterColumnConfig,
|
||||
RepeaterEntityJoin,
|
||||
DEFAULT_REPEATER_CONFIG,
|
||||
RENDER_MODE_OPTIONS,
|
||||
MODAL_SIZE_OPTIONS,
|
||||
@@ -151,6 +153,28 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||
const [loadingRelations, setLoadingRelations] = useState(false);
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false); // 테이블 Combobox 열림 상태
|
||||
|
||||
// Entity 조인 관련 상태
|
||||
const [entityJoinData, setEntityJoinData] = useState<{
|
||||
joinTables: Array<{
|
||||
tableName: string;
|
||||
currentDisplayColumn: string;
|
||||
joinConfig?: { sourceColumn?: string };
|
||||
availableColumns: Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
inputType?: string;
|
||||
}>;
|
||||
}>;
|
||||
availableColumns: Array<{
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
joinAlias: string;
|
||||
}>;
|
||||
}>({ joinTables: [], availableColumns: [] });
|
||||
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
||||
|
||||
// 🆕 확장된 컬럼 (상세 설정 표시용)
|
||||
const [expandedColumn, setExpandedColumn] = useState<string | null>(null);
|
||||
|
||||
@@ -316,6 +340,89 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||
loadRelatedTables();
|
||||
}, [currentTableName, config.mainTableName]);
|
||||
|
||||
// Entity 조인 컬럼 정보 로드 (저장 테이블 기준)
|
||||
const entityJoinTargetTable = config.useCustomTable && config.mainTableName
|
||||
? config.mainTableName
|
||||
: currentTableName;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEntityJoinColumns = async () => {
|
||||
if (!entityJoinTargetTable) return;
|
||||
setLoadingEntityJoins(true);
|
||||
try {
|
||||
const result = await entityJoinApi.getEntityJoinColumns(entityJoinTargetTable);
|
||||
setEntityJoinData({
|
||||
joinTables: result.joinTables || [],
|
||||
availableColumns: result.availableColumns || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Entity 조인 컬럼 조회 오류:", error);
|
||||
setEntityJoinData({ joinTables: [], availableColumns: [] });
|
||||
} finally {
|
||||
setLoadingEntityJoins(false);
|
||||
}
|
||||
};
|
||||
fetchEntityJoinColumns();
|
||||
}, [entityJoinTargetTable]);
|
||||
|
||||
// Entity 조인 컬럼 토글 (추가/제거)
|
||||
const toggleEntityJoinColumn = useCallback(
|
||||
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => {
|
||||
const currentJoins = config.entityJoins || [];
|
||||
const existingJoinIdx = currentJoins.findIndex(
|
||||
(j) => j.sourceColumn === sourceColumn && j.referenceTable === joinTableName,
|
||||
);
|
||||
|
||||
if (existingJoinIdx >= 0) {
|
||||
const existingJoin = currentJoins[existingJoinIdx];
|
||||
const existingColIdx = existingJoin.columns.findIndex((c) => c.referenceField === refColumnName);
|
||||
|
||||
if (existingColIdx >= 0) {
|
||||
const updatedColumns = existingJoin.columns.filter((_, i) => i !== existingColIdx);
|
||||
if (updatedColumns.length === 0) {
|
||||
updateConfig({ entityJoins: currentJoins.filter((_, i) => i !== existingJoinIdx) });
|
||||
} else {
|
||||
const updated = [...currentJoins];
|
||||
updated[existingJoinIdx] = { ...existingJoin, columns: updatedColumns };
|
||||
updateConfig({ entityJoins: updated });
|
||||
}
|
||||
} else {
|
||||
const updated = [...currentJoins];
|
||||
updated[existingJoinIdx] = {
|
||||
...existingJoin,
|
||||
columns: [...existingJoin.columns, { referenceField: refColumnName, displayField }],
|
||||
};
|
||||
updateConfig({ entityJoins: updated });
|
||||
}
|
||||
} else {
|
||||
updateConfig({
|
||||
entityJoins: [
|
||||
...currentJoins,
|
||||
{
|
||||
sourceColumn,
|
||||
referenceTable: joinTableName,
|
||||
columns: [{ referenceField: refColumnName, displayField }],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
[config.entityJoins, updateConfig],
|
||||
);
|
||||
|
||||
// Entity 조인에 특정 컬럼이 설정되어 있는지 확인
|
||||
const isEntityJoinColumnActive = useCallback(
|
||||
(joinTableName: string, sourceColumn: string, refColumnName: string) => {
|
||||
return (config.entityJoins || []).some(
|
||||
(j) =>
|
||||
j.sourceColumn === sourceColumn &&
|
||||
j.referenceTable === joinTableName &&
|
||||
j.columns.some((c) => c.referenceField === refColumnName),
|
||||
);
|
||||
},
|
||||
[config.entityJoins],
|
||||
);
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<V2RepeaterConfig>) => {
|
||||
@@ -654,9 +761,10 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="basic" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="basic" className="text-xs">기본</TabsTrigger>
|
||||
<TabsTrigger value="columns" className="text-xs">컬럼</TabsTrigger>
|
||||
<TabsTrigger value="entityJoin" className="text-xs">Entity 조인</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 기본 설정 탭 */}
|
||||
@@ -1704,6 +1812,120 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Entity 조인 설정 탭 */}
|
||||
<TabsContent value="entityJoin" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Entity 조인 연결</h3>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
FK 컬럼을 기반으로 참조 테이블의 데이터를 자동으로 조회하여 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
{loadingEntityJoins ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">로딩 중...</p>
|
||||
) : entityJoinData.joinTables.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{entityJoinTargetTable
|
||||
? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다`
|
||||
: "저장 테이블을 먼저 설정해주세요"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entityJoinData.joinTables.map((joinTable, tableIndex) => {
|
||||
const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || "";
|
||||
|
||||
return (
|
||||
<div key={tableIndex} className="space-y-1">
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-blue-600">
|
||||
<Link2 className="h-3 w-3" />
|
||||
<span>{joinTable.tableName}</span>
|
||||
<span className="text-muted-foreground">({sourceColumn})</span>
|
||||
</div>
|
||||
<div className="max-h-40 space-y-0.5 overflow-y-auto rounded-md border border-blue-200 bg-blue-50/30 p-2">
|
||||
{joinTable.availableColumns.map((column, colIndex) => {
|
||||
const isActive = isEntityJoinColumnActive(
|
||||
joinTable.tableName,
|
||||
sourceColumn,
|
||||
column.columnName,
|
||||
);
|
||||
const matchingCol = config.columns.find((c) => c.key === column.columnName);
|
||||
const displayField = matchingCol?.key || column.columnName;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={colIndex}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-blue-100/50",
|
||||
isActive && "bg-blue-100",
|
||||
)}
|
||||
onClick={() =>
|
||||
toggleEntityJoinColumn(
|
||||
joinTable.tableName,
|
||||
sourceColumn,
|
||||
column.columnName,
|
||||
column.columnLabel,
|
||||
displayField,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
className="pointer-events-none h-3.5 w-3.5"
|
||||
/>
|
||||
<Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
|
||||
<span className="truncate text-xs">{column.columnLabel}</span>
|
||||
<span className="ml-auto text-[10px] text-blue-400">
|
||||
{column.inputType || column.dataType}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 현재 설정된 Entity 조인 목록 */}
|
||||
{config.entityJoins && config.entityJoins.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium">설정된 조인</h4>
|
||||
<div className="space-y-1">
|
||||
{config.entityJoins.map((join, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1 rounded border bg-muted/30 px-2 py-1 text-[10px]">
|
||||
<Database className="h-3 w-3 text-blue-500" />
|
||||
<span className="font-medium">{join.sourceColumn}</span>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{join.referenceTable}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({join.columns.map((c) => c.referenceField).join(", ")})
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateConfig({
|
||||
entityJoins: config.entityJoins!.filter((_, i) => i !== idx),
|
||||
});
|
||||
}}
|
||||
className="ml-auto h-4 w-4 p-0 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user