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:
kjs
2026-03-04 21:08:45 +09:00
parent f97edad1ea
commit ac2da7a1d7
10 changed files with 813 additions and 31 deletions
+115 -9
View File
@@ -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>
);