697 lines
24 KiB
TypeScript
697 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Database,
|
|
Plus,
|
|
Trash2,
|
|
Link2,
|
|
Loader2,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Table2,
|
|
} from "lucide-react";
|
|
import { reportApi } from "@/lib/api/reportApi";
|
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|
import type { ReportQuery } from "@/contexts/report-designer/types";
|
|
import type {
|
|
ComponentConfig,
|
|
VisualDataSource,
|
|
VisualDetailSource,
|
|
VisualColumn,
|
|
JoinKeyPair,
|
|
} from "@/types/report";
|
|
|
|
interface SchemaTable {
|
|
table_name: string;
|
|
table_type: string;
|
|
}
|
|
|
|
interface SchemaColumn {
|
|
column_name: string;
|
|
data_type: string;
|
|
is_nullable: string;
|
|
}
|
|
|
|
interface ForeignKey {
|
|
constraint_name: string;
|
|
columns: string[];
|
|
foreign_table: string;
|
|
foreign_columns: string[];
|
|
}
|
|
|
|
interface Props {
|
|
component: ComponentConfig;
|
|
embedded?: boolean;
|
|
}
|
|
|
|
const EMPTY_DATA_SOURCE: VisualDataSource = {
|
|
master: { tableName: "", columns: [] },
|
|
details: [],
|
|
};
|
|
|
|
function buildSqlForMaster(ds: VisualDataSource): string {
|
|
if (!ds.master.tableName || ds.master.columns.length === 0) return "";
|
|
const cols = ds.master.columns.map((c) => `"${c.name}"`).join(", ");
|
|
return `SELECT ${cols} FROM "${ds.master.tableName}" WHERE 1=1`;
|
|
}
|
|
|
|
function buildSqlForDetail(ds: VisualDataSource, detail: VisualDetailSource): string {
|
|
if (!detail.tableName || detail.columns.length === 0) return "";
|
|
const cols = detail.columns.map((c) => `"${c.name}"`).join(", ");
|
|
const conditions = detail.joinKeys
|
|
.filter((jk) => jk.detailColumn)
|
|
.map((jk, idx) => `"${jk.detailColumn}" = $${idx + 1}`)
|
|
.join(" AND ");
|
|
const where = conditions ? ` WHERE ${conditions}` : "";
|
|
return `SELECT ${cols} FROM "${detail.tableName}"${where}`;
|
|
}
|
|
|
|
function buildQueriesFromDataSource(ds: VisualDataSource): ReportQuery[] {
|
|
const queries: ReportQuery[] = [];
|
|
|
|
const masterSql = buildSqlForMaster(ds);
|
|
if (masterSql) {
|
|
queries.push({
|
|
id: `vds_master_${ds.master.tableName}`,
|
|
name: `${ds.master.tableName} (마스터)`,
|
|
type: "MASTER",
|
|
sqlQuery: masterSql,
|
|
parameters: [],
|
|
visualDataSource: ds,
|
|
});
|
|
}
|
|
|
|
for (const detail of ds.details) {
|
|
const detailSql = buildSqlForDetail(ds, detail);
|
|
if (detailSql) {
|
|
const params = detail.joinKeys
|
|
.filter((jk) => jk.detailColumn)
|
|
.map((_, idx) => `$${idx + 1}`);
|
|
queries.push({
|
|
id: `vds_detail_${detail.id}`,
|
|
name: `${detail.tableName} (디테일)`,
|
|
type: "DETAIL",
|
|
sqlQuery: detailSql,
|
|
parameters: params,
|
|
visualDataSource: ds,
|
|
});
|
|
}
|
|
}
|
|
|
|
return queries;
|
|
}
|
|
|
|
export function VisualDataSourceBuilder({ component, embedded = false }: Props) {
|
|
const { updateComponent, queries, setQueries } = useReportDesigner();
|
|
|
|
const ds: VisualDataSource = component.visualDataSource ?? EMPTY_DATA_SOURCE;
|
|
const prevDsRef = useRef<string>("");
|
|
|
|
// 데이터 소스 변경 시 queries 배열 자동 동기화
|
|
useEffect(() => {
|
|
const dsJson = JSON.stringify(ds);
|
|
if (dsJson === prevDsRef.current) return;
|
|
prevDsRef.current = dsJson;
|
|
|
|
if (!ds.master.tableName) return;
|
|
|
|
const vdsQueries = buildQueriesFromDataSource(ds);
|
|
if (vdsQueries.length === 0) return;
|
|
|
|
const nonVdsQueries = queries.filter((q) => !q.id.startsWith("vds_"));
|
|
setQueries([...nonVdsQueries, ...vdsQueries]);
|
|
}, [ds, queries, setQueries]);
|
|
|
|
const [tables, setTables] = useState<SchemaTable[]>([]);
|
|
const [tablesLoading, setTablesLoading] = useState(false);
|
|
const [masterColumns, setMasterColumns] = useState<SchemaColumn[]>([]);
|
|
const [masterColumnsLoading, setMasterColumnsLoading] = useState(false);
|
|
const [detailColumnsMap, setDetailColumnsMap] = useState<Record<string, SchemaColumn[]>>({});
|
|
const [detailColumnsLoading, setDetailColumnsLoading] = useState<Record<string, boolean>>({});
|
|
const [detailFkMap, setDetailFkMap] = useState<Record<string, ForeignKey[]>>({});
|
|
const [expandedDetails, setExpandedDetails] = useState<Record<string, boolean>>({});
|
|
|
|
// 복원 시 디테일 테이블의 컬럼 목록 자동 로드
|
|
useEffect(() => {
|
|
for (const detail of ds.details) {
|
|
if (detail.tableName && !detailColumnsMap[detail.id]) {
|
|
setDetailColumnsLoading((prev) => ({ ...prev, [detail.id]: true }));
|
|
reportApi
|
|
.getSchemaTableColumns(detail.tableName)
|
|
.then((res) => {
|
|
if (res.success) {
|
|
setDetailColumnsMap((prev) => ({ ...prev, [detail.id]: res.data }));
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
setDetailColumnsLoading((prev) => ({ ...prev, [detail.id]: false }));
|
|
});
|
|
}
|
|
}
|
|
}, [ds.details, detailColumnsMap]);
|
|
|
|
const updateDS = useCallback(
|
|
(patch: Partial<VisualDataSource>) => {
|
|
updateComponent(component.id, {
|
|
visualDataSource: { ...ds, ...patch },
|
|
});
|
|
},
|
|
[component.id, ds, updateComponent],
|
|
);
|
|
|
|
// 테이블 목록 로드
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setTablesLoading(true);
|
|
reportApi
|
|
.getSchemaTableList()
|
|
.then((res) => {
|
|
if (!cancelled && res.success) setTables(res.data);
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
if (!cancelled) setTablesLoading(false);
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
// 마스터 테이블 컬럼 로드
|
|
useEffect(() => {
|
|
if (!ds.master.tableName) {
|
|
setMasterColumns([]);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setMasterColumnsLoading(true);
|
|
reportApi
|
|
.getSchemaTableColumns(ds.master.tableName)
|
|
.then((res) => {
|
|
if (!cancelled && res.success) setMasterColumns(res.data);
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
if (!cancelled) setMasterColumnsLoading(false);
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [ds.master.tableName]);
|
|
|
|
const handleMasterTableChange = useCallback(
|
|
(tableName: string) => {
|
|
const newTableName = tableName === "none" ? "" : tableName;
|
|
updateDS({
|
|
master: { tableName: newTableName, columns: [] },
|
|
details: [],
|
|
});
|
|
setDetailColumnsMap({});
|
|
setDetailFkMap({});
|
|
},
|
|
[updateDS],
|
|
);
|
|
|
|
const toggleMasterColumn = useCallback(
|
|
(colName: string, dataType: string) => {
|
|
const existing = ds.master.columns.find((c) => c.name === colName);
|
|
const newColumns = existing
|
|
? ds.master.columns.filter((c) => c.name !== colName)
|
|
: [...ds.master.columns, { name: colName, label: colName, dataType, selected: true }];
|
|
updateDS({ master: { ...ds.master, columns: newColumns } });
|
|
},
|
|
[ds, updateDS],
|
|
);
|
|
|
|
const updateMasterColumnLabel = useCallback(
|
|
(colName: string, label: string) => {
|
|
const newColumns = ds.master.columns.map((c) =>
|
|
c.name === colName ? { ...c, label } : c,
|
|
);
|
|
updateDS({ master: { ...ds.master, columns: newColumns } });
|
|
},
|
|
[ds, updateDS],
|
|
);
|
|
|
|
// 디테일 추가
|
|
const addDetail = useCallback(() => {
|
|
const newDetail: VisualDetailSource = {
|
|
id: `detail_${Date.now()}`,
|
|
tableName: "",
|
|
columns: [],
|
|
joinKeys: [],
|
|
};
|
|
updateDS({ details: [...ds.details, newDetail] });
|
|
setExpandedDetails((prev) => ({ ...prev, [newDetail.id]: true }));
|
|
}, [ds, updateDS]);
|
|
|
|
const removeDetail = useCallback(
|
|
(detailId: string) => {
|
|
updateDS({ details: ds.details.filter((d) => d.id !== detailId) });
|
|
},
|
|
[ds, updateDS],
|
|
);
|
|
|
|
const updateDetail = useCallback(
|
|
(detailId: string, patch: Partial<VisualDetailSource>) => {
|
|
updateDS({
|
|
details: ds.details.map((d) => (d.id === detailId ? { ...d, ...patch } : d)),
|
|
});
|
|
},
|
|
[ds, updateDS],
|
|
);
|
|
|
|
// 디테일 테이블 변경 시 컬럼 + FK 로드
|
|
const handleDetailTableChange = useCallback(
|
|
async (detailId: string, tableName: string) => {
|
|
const newTableName = tableName === "none" ? "" : tableName;
|
|
updateDetail(detailId, { tableName: newTableName, columns: [], joinKeys: [] });
|
|
|
|
if (!newTableName) return;
|
|
|
|
setDetailColumnsLoading((prev) => ({ ...prev, [detailId]: true }));
|
|
try {
|
|
const [colRes, fkRes] = await Promise.all([
|
|
reportApi.getSchemaTableColumns(newTableName),
|
|
reportApi.getSchemaTableForeignKeys(newTableName),
|
|
]);
|
|
|
|
if (colRes.success) {
|
|
setDetailColumnsMap((prev) => ({ ...prev, [detailId]: colRes.data }));
|
|
}
|
|
|
|
if (fkRes.success) {
|
|
setDetailFkMap((prev) => ({ ...prev, [detailId]: fkRes.data }));
|
|
|
|
// FK 자동 감지: 마스터 테이블을 참조하는 FK 찾기
|
|
const matchingFk = fkRes.data.find(
|
|
(fk: ForeignKey) => fk.foreign_table === ds.master.tableName,
|
|
);
|
|
if (matchingFk) {
|
|
const autoJoinKeys: JoinKeyPair[] = matchingFk.columns.map(
|
|
(col: string, idx: number) => ({
|
|
masterColumn: matchingFk.foreign_columns[idx] || "",
|
|
detailColumn: col,
|
|
autoDetected: true,
|
|
}),
|
|
);
|
|
updateDetail(detailId, {
|
|
tableName: newTableName,
|
|
columns: [],
|
|
joinKeys: autoJoinKeys,
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
// 무시
|
|
} finally {
|
|
setDetailColumnsLoading((prev) => ({ ...prev, [detailId]: false }));
|
|
}
|
|
},
|
|
[ds.master.tableName, updateDetail],
|
|
);
|
|
|
|
const toggleDetailColumn = useCallback(
|
|
(detailId: string, colName: string, dataType: string) => {
|
|
const detail = ds.details.find((d) => d.id === detailId);
|
|
if (!detail) return;
|
|
const existing = detail.columns.find((c) => c.name === colName);
|
|
const newColumns = existing
|
|
? detail.columns.filter((c) => c.name !== colName)
|
|
: [...detail.columns, { name: colName, label: colName, dataType, selected: true }];
|
|
updateDetail(detailId, { columns: newColumns });
|
|
},
|
|
[ds.details, updateDetail],
|
|
);
|
|
|
|
const updateDetailColumnLabel = useCallback(
|
|
(detailId: string, colName: string, label: string) => {
|
|
const detail = ds.details.find((d) => d.id === detailId);
|
|
if (!detail) return;
|
|
const newColumns = detail.columns.map((c) =>
|
|
c.name === colName ? { ...c, label } : c,
|
|
);
|
|
updateDetail(detailId, { columns: newColumns });
|
|
},
|
|
[ds.details, updateDetail],
|
|
);
|
|
|
|
// 연결 키 관리
|
|
const addJoinKey = useCallback(
|
|
(detailId: string) => {
|
|
const detail = ds.details.find((d) => d.id === detailId);
|
|
if (!detail) return;
|
|
updateDetail(detailId, {
|
|
joinKeys: [
|
|
...detail.joinKeys,
|
|
{ masterColumn: "", detailColumn: "", autoDetected: false },
|
|
],
|
|
});
|
|
},
|
|
[ds.details, updateDetail],
|
|
);
|
|
|
|
const updateJoinKey = useCallback(
|
|
(detailId: string, keyIdx: number, patch: Partial<JoinKeyPair>) => {
|
|
const detail = ds.details.find((d) => d.id === detailId);
|
|
if (!detail) return;
|
|
const newKeys = detail.joinKeys.map((k, i) =>
|
|
i === keyIdx ? { ...k, ...patch, autoDetected: false } : k,
|
|
);
|
|
updateDetail(detailId, { joinKeys: newKeys });
|
|
},
|
|
[ds.details, updateDetail],
|
|
);
|
|
|
|
const removeJoinKey = useCallback(
|
|
(detailId: string, keyIdx: number) => {
|
|
const detail = ds.details.find((d) => d.id === detailId);
|
|
if (!detail) return;
|
|
updateDetail(detailId, {
|
|
joinKeys: detail.joinKeys.filter((_, i) => i !== keyIdx),
|
|
});
|
|
},
|
|
[ds.details, updateDetail],
|
|
);
|
|
|
|
const toggleDetailExpanded = useCallback((detailId: string) => {
|
|
setExpandedDetails((prev) => ({ ...prev, [detailId]: !prev[detailId] }));
|
|
}, []);
|
|
|
|
// 컬럼 체크박스 렌더링 헬퍼
|
|
const renderColumnCheckboxes = (
|
|
schemaColumns: SchemaColumn[],
|
|
selectedColumns: VisualColumn[],
|
|
onToggle: (colName: string, dataType: string) => void,
|
|
onLabelChange: (colName: string, label: string) => void,
|
|
loading: boolean,
|
|
) => {
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-4">
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
<span className="ml-2 text-xs text-muted-foreground">컬럼 로딩 중...</span>
|
|
</div>
|
|
);
|
|
}
|
|
if (schemaColumns.length === 0) {
|
|
return <p className="py-2 text-xs text-muted-foreground">테이블을 선택해주세요.</p>;
|
|
}
|
|
return (
|
|
<div className="max-h-48 space-y-1 overflow-y-auto">
|
|
{schemaColumns.map((col) => {
|
|
const selected = selectedColumns.find((c) => c.name === col.column_name);
|
|
return (
|
|
<div key={col.column_name} className="flex items-center gap-2 rounded px-1 py-0.5 hover:bg-muted/50">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!selected}
|
|
onChange={() => onToggle(col.column_name, col.data_type)}
|
|
className="h-3.5 w-3.5 rounded border-gray-300"
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate text-xs">{col.column_name}</span>
|
|
<span className="shrink-0 text-[9px] text-gray-400">{col.data_type}</span>
|
|
{selected && (
|
|
<Input
|
|
value={selected.label}
|
|
onChange={(e) => onLabelChange(col.column_name, e.target.value)}
|
|
placeholder="라벨"
|
|
className="h-6 w-24 text-[10px]"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const content = (
|
|
<div className="space-y-3">
|
|
{/* 마스터 데이터 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">마스터 테이블</Label>
|
|
<Select
|
|
value={ds.master.tableName || "none"}
|
|
onValueChange={handleMasterTableChange}
|
|
disabled={tablesLoading}
|
|
>
|
|
<SelectTrigger className="h-9 text-sm">
|
|
<SelectValue placeholder={tablesLoading ? "로딩 중..." : "테이블 선택"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택 안함</SelectItem>
|
|
{tables.map((t) => (
|
|
<SelectItem key={t.table_name} value={t.table_name}>
|
|
{t.table_name}
|
|
{t.table_type === "VIEW" && (
|
|
<span className="ml-1 text-[10px] text-muted-foreground">(뷰)</span>
|
|
)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{ds.master.tableName && (
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium">
|
|
마스터 컬럼
|
|
{ds.master.columns.length > 0 && (
|
|
<Badge variant="secondary" className="ml-2 text-[10px]">
|
|
{ds.master.columns.length}개
|
|
</Badge>
|
|
)}
|
|
</Label>
|
|
{renderColumnCheckboxes(
|
|
masterColumns,
|
|
ds.master.columns,
|
|
toggleMasterColumn,
|
|
updateMasterColumnLabel,
|
|
masterColumnsLoading,
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 디테일 데이터 */}
|
|
{ds.details.map((detail, idx) => {
|
|
const detailCols = detailColumnsMap[detail.id] || [];
|
|
const isLoading = detailColumnsLoading[detail.id] || false;
|
|
const isExpanded = expandedDetails[detail.id] !== false;
|
|
|
|
return (
|
|
<div key={detail.id} className="rounded-lg border border-gray-200 bg-white p-3">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleDetailExpanded(detail.id)}
|
|
className="flex items-center gap-1.5"
|
|
>
|
|
<Table2 className="h-3.5 w-3.5 text-gray-500" />
|
|
<span className="text-xs font-medium text-foreground">
|
|
디테일 #{idx + 1}
|
|
{detail.tableName && (
|
|
<span className="ml-1 font-normal text-muted-foreground">
|
|
({detail.tableName})
|
|
</span>
|
|
)}
|
|
</span>
|
|
{isExpanded ? (
|
|
<ChevronUp className="h-3 w-3 text-gray-400" />
|
|
) : (
|
|
<ChevronDown className="h-3 w-3 text-gray-400" />
|
|
)}
|
|
</button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 text-red-400 hover:bg-red-50 hover:text-red-600"
|
|
onClick={() => removeDetail(detail.id)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="space-y-2">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium">테이블</Label>
|
|
<Select
|
|
value={detail.tableName || "none"}
|
|
onValueChange={(v) => handleDetailTableChange(detail.id, v)}
|
|
disabled={tablesLoading}
|
|
>
|
|
<SelectTrigger className="h-9 text-sm">
|
|
<SelectValue placeholder="테이블 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택 안함</SelectItem>
|
|
{tables.map((t) => (
|
|
<SelectItem key={t.table_name} value={t.table_name}>
|
|
{t.table_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{detail.tableName && (
|
|
<div className="rounded-lg border border-blue-200 bg-blue-50/50 p-2.5">
|
|
<div className="mb-1.5 flex items-center justify-between">
|
|
<div className="flex items-center gap-1.5">
|
|
<Link2 className="h-3 w-3 text-blue-600" />
|
|
<span className="text-[11px] font-medium text-blue-700">연결 키</span>
|
|
{detail.joinKeys.some((k) => k.autoDetected) && (
|
|
<Badge variant="outline" className="h-4 border-blue-300 px-1 text-[9px] text-blue-600">
|
|
자동 감지
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-1.5 text-[10px]"
|
|
onClick={() => addJoinKey(detail.id)}
|
|
>
|
|
<Plus className="mr-0.5 h-2.5 w-2.5" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
{detail.joinKeys.length === 0 && (
|
|
<p className="text-[10px] text-blue-600">
|
|
FK가 감지되지 않았습니다. 수동으로 추가해주세요.
|
|
</p>
|
|
)}
|
|
|
|
<div className="space-y-1">
|
|
{detail.joinKeys.map((jk, ki) => (
|
|
<div key={ki} className="flex items-center gap-1">
|
|
<Select
|
|
value={jk.masterColumn || "none"}
|
|
onValueChange={(v) =>
|
|
updateJoinKey(detail.id, ki, {
|
|
masterColumn: v === "none" ? "" : v,
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]">
|
|
<SelectValue placeholder="마스터 컬럼" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택</SelectItem>
|
|
{ds.master.columns.length > 0
|
|
? ds.master.columns.map((c) => (
|
|
<SelectItem key={c.name} value={c.name}>
|
|
{c.label !== c.name ? `${c.label} (${c.name})` : c.name}
|
|
</SelectItem>
|
|
))
|
|
: masterColumns.map((c) => (
|
|
<SelectItem key={c.column_name} value={c.column_name}>
|
|
{c.column_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="text-[10px] text-gray-400">↔</span>
|
|
<Select
|
|
value={jk.detailColumn || "none"}
|
|
onValueChange={(v) =>
|
|
updateJoinKey(detail.id, ki, {
|
|
detailColumn: v === "none" ? "" : v,
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]">
|
|
<SelectValue placeholder="디테일 컬럼" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택</SelectItem>
|
|
{detailCols.map((c) => (
|
|
<SelectItem key={c.column_name} value={c.column_name}>
|
|
{c.column_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-5 w-5 shrink-0 text-red-400 hover:text-red-600"
|
|
onClick={() => removeJoinKey(detail.id, ki)}
|
|
>
|
|
<Trash2 className="h-2.5 w-2.5" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{detail.tableName && (
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium">
|
|
디테일 컬럼
|
|
{detail.columns.length > 0 && (
|
|
<Badge variant="secondary" className="ml-2 text-[10px]">
|
|
{detail.columns.length}개
|
|
</Badge>
|
|
)}
|
|
</Label>
|
|
{renderColumnCheckboxes(
|
|
detailCols,
|
|
detail.columns,
|
|
(colName, dataType) => toggleDetailColumn(detail.id, colName, dataType),
|
|
(colName, label) => updateDetailColumnLabel(detail.id, colName, label),
|
|
isLoading,
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{ds.master.tableName && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full border-dashed text-xs"
|
|
onClick={addDetail}
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
디테일 데이터 추가
|
|
</Button>
|
|
)}
|
|
|
|
{!ds.master.tableName && (
|
|
<div className="rounded-lg border border-dashed border-gray-200 p-4 text-center">
|
|
<Database className="mx-auto mb-1.5 h-6 w-6 text-gray-300" />
|
|
<p className="text-[11px] text-muted-foreground">
|
|
마스터 테이블을 선택하면 데이터 소스를 구성할 수 있습니다.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
if (embedded) return content;
|
|
|
|
return (
|
|
<ScrollArea className="h-full">
|
|
<div className="p-4">{content}</div>
|
|
</ScrollArea>
|
|
);
|
|
}
|