Files
vexplor_dev/frontend/components/report/designer/modals/VisualDataSourceBuilder.tsx
2026-03-12 18:47:42 +09:00

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>
);
}