"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(""); // 데이터 소스 변경 시 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([]); const [tablesLoading, setTablesLoading] = useState(false); const [masterColumns, setMasterColumns] = useState([]); const [masterColumnsLoading, setMasterColumnsLoading] = useState(false); const [detailColumnsMap, setDetailColumnsMap] = useState>({}); const [detailColumnsLoading, setDetailColumnsLoading] = useState>({}); const [detailFkMap, setDetailFkMap] = useState>({}); const [expandedDetails, setExpandedDetails] = useState>({}); // 복원 시 디테일 테이블의 컬럼 목록 자동 로드 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) => { 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) => { 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) => { 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 (
컬럼 로딩 중...
); } if (schemaColumns.length === 0) { return

테이블을 선택해주세요.

; } return (
{schemaColumns.map((col) => { const selected = selectedColumns.find((c) => c.name === col.column_name); return (
onToggle(col.column_name, col.data_type)} className="h-3.5 w-3.5 rounded border-gray-300" /> {col.column_name} {col.data_type} {selected && ( onLabelChange(col.column_name, e.target.value)} placeholder="라벨" className="h-6 w-24 text-[10px]" /> )}
); })}
); }; const content = (
{/* 마스터 데이터 */}
{ds.master.tableName && (
{renderColumnCheckboxes( masterColumns, ds.master.columns, toggleMasterColumn, updateMasterColumnLabel, masterColumnsLoading, )}
)} {/* 디테일 데이터 */} {ds.details.map((detail, idx) => { const detailCols = detailColumnsMap[detail.id] || []; const isLoading = detailColumnsLoading[detail.id] || false; const isExpanded = expandedDetails[detail.id] !== false; return (
{isExpanded && (
{detail.tableName && (
연결 키 {detail.joinKeys.some((k) => k.autoDetected) && ( 자동 감지 )}
{detail.joinKeys.length === 0 && (

FK가 감지되지 않았습니다. 수동으로 추가해주세요.

)}
{detail.joinKeys.map((jk, ki) => (
))}
)} {detail.tableName && (
{renderColumnCheckboxes( detailCols, detail.columns, (colName, dataType) => toggleDetailColumn(detail.id, colName, dataType), (colName, label) => updateDetailColumnLabel(detail.id, colName, label), isLoading, )}
)}
)}
); })} {ds.master.tableName && ( )} {!ds.master.tableName && (

마스터 테이블을 선택하면 데이터 소스를 구성할 수 있습니다.

)}
); if (embedded) return content; return (
{content}
); }