feat: Enhance V2RepeaterConfigPanel with entity join column management

- Updated the toggleEntityJoinColumn function to include an optional columnType parameter for better flexibility in handling join columns.
- Improved the logic for managing entity joins and columns, ensuring that columns are correctly added or removed based on user interactions.
- Introduced a new section in the UI to display entity join columns in a read-only format, providing users with clear visibility of the join configurations.
- Added loading states and messages to enhance user experience during data retrieval for entity joins.

These changes aim to improve the functionality and usability of the V2RepeaterConfigPanel in managing entity relationships.
This commit is contained in:
kmh 2026-03-11 23:38:42 +09:00
parent 09d16e6672
commit 238a7d1db4
1 changed files with 134 additions and 144 deletions

View File

@ -375,12 +375,15 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
// Entity 조인 컬럼 토글 (추가/제거)
const toggleEntityJoinColumn = useCallback(
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => {
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string, columnType?: string) => {
const currentJoins = config.entityJoins || [];
const existingJoinIdx = currentJoins.findIndex(
(j) => j.sourceColumn === sourceColumn && j.referenceTable === joinTableName,
);
let newEntityJoins = [...currentJoins];
let newColumns = [...config.columns];
if (existingJoinIdx >= 0) {
const existingJoin = currentJoins[existingJoinIdx];
const existingColIdx = existingJoin.columns.findIndex((c) => c.referenceField === refColumnName);
@ -388,34 +391,49 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
if (existingColIdx >= 0) {
const updatedColumns = existingJoin.columns.filter((_, i) => i !== existingColIdx);
if (updatedColumns.length === 0) {
updateConfig({ entityJoins: currentJoins.filter((_, i) => i !== existingJoinIdx) });
newEntityJoins = newEntityJoins.filter((_, i) => i !== existingJoinIdx);
} else {
const updated = [...currentJoins];
updated[existingJoinIdx] = { ...existingJoin, columns: updatedColumns };
updateConfig({ entityJoins: updated });
newEntityJoins[existingJoinIdx] = { ...existingJoin, columns: updatedColumns };
}
// config.columns에서도 제거
newColumns = newColumns.filter(c => !(c.key === displayField && c.isJoinColumn));
} else {
const updated = [...currentJoins];
updated[existingJoinIdx] = {
newEntityJoins[existingJoinIdx] = {
...existingJoin,
columns: [...existingJoin.columns, { referenceField: refColumnName, displayField }],
};
updateConfig({ entityJoins: updated });
// config.columns에 추가
newColumns.push({
key: displayField,
title: refColumnLabel,
width: "auto",
visible: true,
editable: false,
isJoinColumn: true,
inputType: columnType || "text",
});
}
} else {
updateConfig({
entityJoins: [
...currentJoins,
{
sourceColumn,
referenceTable: joinTableName,
columns: [{ referenceField: refColumnName, displayField }],
},
],
newEntityJoins.push({
sourceColumn,
referenceTable: joinTableName,
columns: [{ referenceField: refColumnName, displayField }],
});
// config.columns에 추가
newColumns.push({
key: displayField,
title: refColumnLabel,
width: "auto",
visible: true,
editable: false,
isJoinColumn: true,
inputType: columnType || "text",
});
}
updateConfig({ entityJoins: newEntityJoins, columns: newColumns });
},
[config.entityJoins, updateConfig],
[config.entityJoins, config.columns, updateConfig],
);
// Entity 조인에 특정 컬럼이 설정되어 있는지 확인
@ -604,9 +622,9 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
// 컬럼 토글 (현재 테이블 컬럼 - 입력용)
const toggleInputColumn = (column: ColumnOption) => {
const existingIndex = config.columns.findIndex((c) => c.key === column.columnName);
const existingIndex = config.columns.findIndex((c) => c.key === column.columnName && !c.isJoinColumn && !c.isSourceDisplay);
if (existingIndex >= 0) {
const newColumns = config.columns.filter((c) => c.key !== column.columnName);
const newColumns = config.columns.filter((_, i) => i !== existingIndex);
updateConfig({ columns: newColumns });
} else {
// 컬럼의 inputType과 detailSettings 정보 포함
@ -651,7 +669,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
};
const isColumnAdded = (columnName: string) => {
return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay);
return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay && !c.isJoinColumn);
};
const isSourceColumnSelected = (columnName: string) => {
@ -761,10 +779,9 @@ 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-3">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic" className="text-xs"></TabsTrigger>
<TabsTrigger value="columns" className="text-xs"></TabsTrigger>
<TabsTrigger value="entityJoin" className="text-xs">Entity </TabsTrigger>
</TabsList>
{/* 기본 설정 탭 */}
@ -1365,6 +1382,84 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
)}
</div>
{/* ===== 🆕 Entity 조인 컬럼 (표시용) ===== */}
<div className="space-y-2 mt-4">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-primary" />
<Label className="text-xs font-medium text-primary">Entity ()</Label>
</div>
<p className="text-[10px] text-muted-foreground">
FK .
</p>
{loadingEntityJoins ? (
<p className="text-muted-foreground py-2 text-xs"> ...</p>
) : entityJoinData.joinTables.length === 0 ? (
<p className="text-muted-foreground py-2 text-xs">
{entityJoinTargetTable
? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다`
: "저장 테이블을 먼저 설정해주세요"}
</p>
) : (
<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-primary">
<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-primary/20 bg-primary/10/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 && c.isJoinColumn);
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-primary/10/50",
isActive && "bg-primary/10",
)}
onClick={() =>
toggleEntityJoinColumn(
joinTable.tableName,
sourceColumn,
column.columnName,
column.columnLabel,
displayField,
column.inputType || column.dataType
)
}
>
<Checkbox
checked={isActive}
className="pointer-events-none h-3.5 w-3.5"
/>
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate text-xs">{column.columnLabel}</span>
<span className="ml-auto text-[10px] text-primary/80">
{column.inputType || column.dataType}
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
{/* 선택된 컬럼 상세 설정 - 🆕 모든 컬럼 통합, 순서 변경 가능 */}
{config.columns.length > 0 && (
<>
@ -1381,7 +1476,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
<div
className={cn(
"flex items-center gap-2 rounded-md border p-2",
col.isSourceDisplay ? "border-primary/20 bg-primary/10/50" : "border-border bg-muted/30",
(col.isSourceDisplay || col.isJoinColumn) ? "border-primary/20 bg-primary/10/50" : "border-border bg-muted/30",
col.hidden && "opacity-50",
)}
draggable
@ -1403,7 +1498,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
<GripVertical className="text-muted-foreground h-3 w-3 cursor-grab flex-shrink-0" />
{/* 확장/축소 버튼 (입력 컬럼만) */}
{!col.isSourceDisplay && (
{(!col.isSourceDisplay && !col.isJoinColumn) && (
<button
type="button"
onClick={() => setExpandedColumn(expandedColumn === col.key ? null : col.key)}
@ -1419,8 +1514,10 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
{col.isSourceDisplay ? (
<Link2 className="text-primary h-3 w-3 flex-shrink-0" title="소스 표시 (읽기 전용)" />
) : col.isJoinColumn ? (
<Link2 className="text-amber-500 h-3 w-3 flex-shrink-0" title="Entity 조인 (읽기 전용)" />
) : (
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
)}
<Input
@ -1431,7 +1528,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
/>
{/* 히든 토글 (입력 컬럼만) */}
{!col.isSourceDisplay && (
{(!col.isSourceDisplay && !col.isJoinColumn) && (
<button
type="button"
onClick={() => updateColumnProp(col.key, "hidden", !col.hidden)}
@ -1446,12 +1543,12 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
)}
{/* 자동입력 표시 아이콘 */}
{!col.isSourceDisplay && col.autoFill?.type && col.autoFill.type !== "none" && (
{(!col.isSourceDisplay && !col.isJoinColumn) && col.autoFill?.type && col.autoFill.type !== "none" && (
<Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" />
)}
{/* 편집 가능 토글 */}
{!col.isSourceDisplay && (
{(!col.isSourceDisplay && !col.isJoinColumn) && (
<button
type="button"
onClick={() => updateColumnProp(col.key, "editable", !(col.editable ?? true))}
@ -1474,6 +1571,13 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
onClick={() => {
if (col.isSourceDisplay) {
toggleSourceDisplayColumn({ columnName: col.key, displayName: col.title });
} else if (col.isJoinColumn) {
const newColumns = config.columns.filter(c => c.key !== col.key);
const newEntityJoins = config.entityJoins?.map(join => ({
...join,
columns: join.columns.filter(c => c.displayField !== col.key)
})).filter(join => join.columns.length > 0);
updateConfig({ columns: newColumns, entityJoins: newEntityJoins });
} else {
toggleInputColumn({ columnName: col.key, displayName: col.title });
}
@ -1485,7 +1589,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
</div>
{/* 확장된 상세 설정 (입력 컬럼만) */}
{!col.isSourceDisplay && expandedColumn === col.key && (
{(!col.isSourceDisplay && !col.isJoinColumn) && expandedColumn === col.key && (
<div className="ml-6 space-y-2 rounded-md border border-dashed border-input bg-muted p-2">
{/* 자동 입력 설정 */}
<div className="space-y-1">
@ -1812,120 +1916,6 @@ 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-primary">
<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-primary/20 bg-primary/10/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-primary/10/50",
isActive && "bg-primary/10",
)}
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-primary" />
<span className="truncate text-xs">{column.columnLabel}</span>
<span className="ml-auto text-[10px] text-primary/80">
{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-primary" />
<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>
);