442 lines
17 KiB
TypeScript
442 lines
17 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Label } from "@/components/ui/label";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
|
|
import {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
} from "@/components/ui/select";
|
||
|
|
import { Database, Columns3, FunctionSquare, Play, Plus, Trash2, Code, Loader2 } from "lucide-react";
|
||
|
|
import { reportApi } from "@/lib/api/reportApi";
|
||
|
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||
|
|
import type { ComponentConfig, VisualQuery, VisualQueryFormulaColumn } from "@/types/report";
|
||
|
|
|
||
|
|
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
interface SchemaTable {
|
||
|
|
table_name: string;
|
||
|
|
table_type: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface SchemaColumn {
|
||
|
|
column_name: string;
|
||
|
|
data_type: string;
|
||
|
|
is_nullable: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
component: ComponentConfig;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 컴포넌트 ──────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export function VisualQueryBuilder({ component }: Props) {
|
||
|
|
const { updateComponent, setQueryResult } = useReportDesigner();
|
||
|
|
|
||
|
|
const vq: VisualQuery = component.visualQuery ?? {
|
||
|
|
tableName: "",
|
||
|
|
limit: 100,
|
||
|
|
columns: [],
|
||
|
|
formulaColumns: [],
|
||
|
|
};
|
||
|
|
|
||
|
|
// ─── 스키마 상태 ───────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
const [tables, setTables] = useState<SchemaTable[]>([]);
|
||
|
|
const [columns, setColumns] = useState<SchemaColumn[]>([]);
|
||
|
|
const [tablesLoading, setTablesLoading] = useState(false);
|
||
|
|
const [columnsLoading, setColumnsLoading] = useState(false);
|
||
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||
|
|
const [generatedSql, setGeneratedSql] = useState("");
|
||
|
|
const [previewError, setPreviewError] = useState("");
|
||
|
|
|
||
|
|
// ─── 테이블 목록 로드 ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
let cancelled = false;
|
||
|
|
const loadTables = async () => {
|
||
|
|
setTablesLoading(true);
|
||
|
|
try {
|
||
|
|
const res = await reportApi.getSchemaTableList();
|
||
|
|
if (!cancelled && res.success) setTables(res.data);
|
||
|
|
} catch {
|
||
|
|
/* 무시 */
|
||
|
|
} finally {
|
||
|
|
if (!cancelled) setTablesLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
loadTables();
|
||
|
|
return () => { cancelled = true; };
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// ─── 테이블 변경 시 컬럼 로드 ──────────────────────────────────────────────────
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!vq.tableName) {
|
||
|
|
setColumns([]);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
let cancelled = false;
|
||
|
|
const loadColumns = async () => {
|
||
|
|
setColumnsLoading(true);
|
||
|
|
try {
|
||
|
|
const res = await reportApi.getSchemaTableColumns(vq.tableName);
|
||
|
|
if (!cancelled && res.success) setColumns(res.data);
|
||
|
|
} catch {
|
||
|
|
/* 무시 */
|
||
|
|
} finally {
|
||
|
|
if (!cancelled) setColumnsLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
loadColumns();
|
||
|
|
return () => { cancelled = true; };
|
||
|
|
}, [vq.tableName]);
|
||
|
|
|
||
|
|
// ─── visualQuery 업데이트 헬퍼 ────────────────────────────────────────────────
|
||
|
|
|
||
|
|
const updateVQ = useCallback(
|
||
|
|
(patch: Partial<VisualQuery>) => {
|
||
|
|
updateComponent(component.id, { visualQuery: { ...vq, ...patch } });
|
||
|
|
},
|
||
|
|
[component.id, vq, updateComponent],
|
||
|
|
);
|
||
|
|
|
||
|
|
// ─── 테이블 선택 ───────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
const handleTableChange = useCallback(
|
||
|
|
(tableName: string) => {
|
||
|
|
updateComponent(component.id, {
|
||
|
|
visualQuery: {
|
||
|
|
tableName: tableName === "none" ? "" : tableName,
|
||
|
|
limit: vq.limit ?? 100,
|
||
|
|
columns: [],
|
||
|
|
formulaColumns: [],
|
||
|
|
},
|
||
|
|
});
|
||
|
|
},
|
||
|
|
[component.id, vq.limit, updateComponent],
|
||
|
|
);
|
||
|
|
|
||
|
|
// ─── 컬럼 토글 ─────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
const toggleColumn = useCallback(
|
||
|
|
(colName: string) => {
|
||
|
|
const selected = vq.columns.includes(colName)
|
||
|
|
? vq.columns.filter((c) => c !== colName)
|
||
|
|
: [...vq.columns, colName];
|
||
|
|
updateVQ({ columns: selected });
|
||
|
|
},
|
||
|
|
[vq.columns, updateVQ],
|
||
|
|
);
|
||
|
|
|
||
|
|
const selectAllColumns = useCallback(() => {
|
||
|
|
updateVQ({ columns: columns.map((c) => c.column_name) });
|
||
|
|
}, [columns, updateVQ]);
|
||
|
|
|
||
|
|
const deselectAllColumns = useCallback(() => {
|
||
|
|
updateVQ({ columns: [] });
|
||
|
|
}, [updateVQ]);
|
||
|
|
|
||
|
|
// ─── 수식 컬럼 관리 ────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
const addFormulaColumn = useCallback(() => {
|
||
|
|
const fc: VisualQueryFormulaColumn = {
|
||
|
|
alias: `calc_${(vq.formulaColumns.length + 1)}`,
|
||
|
|
header: `수식 ${vq.formulaColumns.length + 1}`,
|
||
|
|
expression: "",
|
||
|
|
};
|
||
|
|
updateVQ({ formulaColumns: [...vq.formulaColumns, fc] });
|
||
|
|
}, [vq.formulaColumns, updateVQ]);
|
||
|
|
|
||
|
|
const updateFormulaColumn = useCallback(
|
||
|
|
(idx: number, patch: Partial<VisualQueryFormulaColumn>) => {
|
||
|
|
const updated = vq.formulaColumns.map((fc, i) => (i === idx ? { ...fc, ...patch } : fc));
|
||
|
|
updateVQ({ formulaColumns: updated });
|
||
|
|
},
|
||
|
|
[vq.formulaColumns, updateVQ],
|
||
|
|
);
|
||
|
|
|
||
|
|
const removeFormulaColumn = useCallback(
|
||
|
|
(idx: number) => {
|
||
|
|
updateVQ({ formulaColumns: vq.formulaColumns.filter((_, i) => i !== idx) });
|
||
|
|
},
|
||
|
|
[vq.formulaColumns, updateVQ],
|
||
|
|
);
|
||
|
|
|
||
|
|
// ─── SQL 미리보기 빌드 ────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
const previewSql = useMemo(() => {
|
||
|
|
if (!vq.tableName || (vq.columns.length === 0 && vq.formulaColumns.length === 0)) return "";
|
||
|
|
const parts: string[] = [];
|
||
|
|
for (const col of vq.columns) parts.push(`"${col}"`);
|
||
|
|
for (const fc of vq.formulaColumns) {
|
||
|
|
if (fc.expression && fc.alias) parts.push(`(${fc.expression}) AS "${fc.alias}"`);
|
||
|
|
}
|
||
|
|
if (parts.length === 0) return "";
|
||
|
|
const limit = Math.min(Math.max(vq.limit ?? 100, 1), 10000);
|
||
|
|
return `SELECT ${parts.join(", ")} FROM "${vq.tableName}" LIMIT ${limit}`;
|
||
|
|
}, [vq]);
|
||
|
|
|
||
|
|
// ─── 미리보기 실행 ────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
const resultKey = `visual_${component.id}`;
|
||
|
|
|
||
|
|
const handlePreview = useCallback(async () => {
|
||
|
|
if (!vq.tableName || (vq.columns.length === 0 && vq.formulaColumns.length === 0)) return;
|
||
|
|
setPreviewLoading(true);
|
||
|
|
setPreviewError("");
|
||
|
|
try {
|
||
|
|
const res = await reportApi.previewVisualQuery(vq);
|
||
|
|
if (res.success) {
|
||
|
|
setQueryResult(resultKey, res.data.fields, res.data.rows);
|
||
|
|
setGeneratedSql(res.data.sql);
|
||
|
|
} else {
|
||
|
|
setPreviewError("쿼리 실행에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} catch (err: any) {
|
||
|
|
setPreviewError(err?.response?.data?.message || err.message || "오류가 발생했습니다.");
|
||
|
|
} finally {
|
||
|
|
setPreviewLoading(false);
|
||
|
|
}
|
||
|
|
}, [vq, resultKey, setQueryResult]);
|
||
|
|
|
||
|
|
const hasSelection = vq.columns.length > 0 || vq.formulaColumns.length > 0;
|
||
|
|
|
||
|
|
// ─── 렌더링 ────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-3">
|
||
|
|
{/* [1] 데이터 소스 */}
|
||
|
|
<Card className="border-blue-200 bg-blue-50">
|
||
|
|
<CardHeader className="pb-2">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Database className="h-4 w-4 text-blue-600" />
|
||
|
|
<CardTitle className="text-xs text-blue-900">데이터 소스</CardTitle>
|
||
|
|
</div>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-2">
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs">테이블</Label>
|
||
|
|
<Select
|
||
|
|
value={vq.tableName || "none"}
|
||
|
|
onValueChange={handleTableChange}
|
||
|
|
disabled={tablesLoading}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="h-8 bg-white text-xs">
|
||
|
|
<SelectValue placeholder={tablesLoading ? "로딩 중..." : "테이블 선택"} />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="none">선택 안함</SelectItem>
|
||
|
|
{tables.map((t) => (
|
||
|
|
<SelectItem key={t.table_name} value={t.table_name}>
|
||
|
|
{t.table_name}
|
||
|
|
<span className="ml-1 text-[10px] text-gray-400">
|
||
|
|
{t.table_type === "VIEW" ? "(뷰)" : ""}
|
||
|
|
</span>
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs">최대 행 수</Label>
|
||
|
|
<Input
|
||
|
|
type="number"
|
||
|
|
min={1}
|
||
|
|
max={10000}
|
||
|
|
value={vq.limit ?? 100}
|
||
|
|
onChange={(e) => updateVQ({ limit: parseInt(e.target.value) || 100 })}
|
||
|
|
className="h-8 bg-white text-xs"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* [2] 컬럼 선택 */}
|
||
|
|
{vq.tableName && (
|
||
|
|
<Card className="border-green-200 bg-green-50">
|
||
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Columns3 className="h-4 w-4 text-green-600" />
|
||
|
|
<CardTitle className="text-xs text-green-900">
|
||
|
|
컬럼 선택
|
||
|
|
{vq.columns.length > 0 && (
|
||
|
|
<span className="ml-1 font-normal text-green-600">
|
||
|
|
({vq.columns.length}/{columns.length})
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</CardTitle>
|
||
|
|
</div>
|
||
|
|
<div className="flex gap-1">
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="ghost"
|
||
|
|
className="h-6 px-2 text-[10px]"
|
||
|
|
onClick={selectAllColumns}
|
||
|
|
>
|
||
|
|
전체 선택
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="ghost"
|
||
|
|
className="h-6 px-2 text-[10px]"
|
||
|
|
onClick={deselectAllColumns}
|
||
|
|
>
|
||
|
|
전체 해제
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{columnsLoading ? (
|
||
|
|
<div className="flex items-center justify-center py-4">
|
||
|
|
<Loader2 className="h-4 w-4 animate-spin text-green-600" />
|
||
|
|
<span className="ml-2 text-xs text-green-700">컬럼 로딩 중...</span>
|
||
|
|
</div>
|
||
|
|
) : columns.length > 0 ? (
|
||
|
|
<div className="grid max-h-48 grid-cols-2 gap-1 overflow-y-auto">
|
||
|
|
{columns.map((col) => {
|
||
|
|
const checked = vq.columns.includes(col.column_name);
|
||
|
|
return (
|
||
|
|
<label
|
||
|
|
key={col.column_name}
|
||
|
|
className={`flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs transition-colors ${
|
||
|
|
checked ? "bg-green-200/60 text-green-900" : "text-gray-600 hover:bg-green-100/40"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={checked}
|
||
|
|
onChange={() => toggleColumn(col.column_name)}
|
||
|
|
className="h-3.5 w-3.5 rounded border-gray-300 text-green-600"
|
||
|
|
/>
|
||
|
|
<span className="truncate font-mono">{col.column_name}</span>
|
||
|
|
<span className="ml-auto shrink-0 text-[9px] text-gray-400">
|
||
|
|
{col.data_type}
|
||
|
|
</span>
|
||
|
|
</label>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<p className="py-4 text-center text-xs text-gray-500">컬럼 정보가 없습니다.</p>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* [3] 수식 컬럼 */}
|
||
|
|
{vq.tableName && (
|
||
|
|
<Card className="border-amber-200 bg-amber-50">
|
||
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<FunctionSquare className="h-4 w-4 text-amber-600" />
|
||
|
|
<CardTitle className="text-xs text-amber-900">
|
||
|
|
수식 컬럼
|
||
|
|
{vq.formulaColumns.length > 0 && (
|
||
|
|
<span className="ml-1 font-normal text-amber-600">
|
||
|
|
({vq.formulaColumns.length})
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</CardTitle>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="outline"
|
||
|
|
className="h-6 bg-white px-2 text-[10px]"
|
||
|
|
onClick={addFormulaColumn}
|
||
|
|
>
|
||
|
|
<Plus className="mr-1 h-3 w-3" />
|
||
|
|
추가
|
||
|
|
</Button>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-2">
|
||
|
|
{vq.formulaColumns.length > 0 ? (
|
||
|
|
vq.formulaColumns.map((fc, idx) => (
|
||
|
|
<div
|
||
|
|
key={idx}
|
||
|
|
className="flex items-start gap-1 rounded border border-amber-200 bg-white p-2"
|
||
|
|
>
|
||
|
|
<div className="flex flex-1 flex-col gap-1">
|
||
|
|
<div className="flex gap-1">
|
||
|
|
<Input
|
||
|
|
value={fc.alias}
|
||
|
|
onChange={(e) => updateFormulaColumn(idx, { alias: e.target.value })}
|
||
|
|
placeholder="필드명"
|
||
|
|
className="h-7 flex-1 font-mono text-xs"
|
||
|
|
/>
|
||
|
|
<Input
|
||
|
|
value={fc.header}
|
||
|
|
onChange={(e) => updateFormulaColumn(idx, { header: e.target.value })}
|
||
|
|
placeholder="표시 헤더"
|
||
|
|
className="h-7 flex-1 text-xs"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<Input
|
||
|
|
value={fc.expression}
|
||
|
|
onChange={(e) => updateFormulaColumn(idx, { expression: e.target.value })}
|
||
|
|
placeholder='SQL 표현식 (예: "price" * "quantity")'
|
||
|
|
className="h-7 font-mono text-xs"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="ghost"
|
||
|
|
className="mt-0.5 h-6 w-6 shrink-0 p-0 text-gray-400 hover:text-red-500"
|
||
|
|
onClick={() => removeFormulaColumn(idx)}
|
||
|
|
>
|
||
|
|
<Trash2 className="h-3.5 w-3.5" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
))
|
||
|
|
) : (
|
||
|
|
<p className="py-2 text-center text-[10px] text-gray-500">
|
||
|
|
계산이 필요한 컬럼을 추가하세요. (예: price * quantity)
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* [4] SQL 미리보기 + 실행 */}
|
||
|
|
{vq.tableName && hasSelection && (
|
||
|
|
<Card className="border-gray-200 bg-gray-50">
|
||
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Code className="h-4 w-4 text-gray-600" />
|
||
|
|
<CardTitle className="text-xs text-gray-700">생성 SQL</CardTitle>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="default"
|
||
|
|
className="h-7 bg-blue-600 px-3 text-xs hover:bg-blue-700"
|
||
|
|
onClick={handlePreview}
|
||
|
|
disabled={previewLoading}
|
||
|
|
>
|
||
|
|
{previewLoading ? (
|
||
|
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Play className="mr-1 h-3 w-3" />
|
||
|
|
)}
|
||
|
|
미리보기
|
||
|
|
</Button>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<pre className="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-[10px] leading-relaxed text-green-300">
|
||
|
|
{generatedSql || previewSql || "컬럼을 선택하세요."}
|
||
|
|
</pre>
|
||
|
|
{previewError && (
|
||
|
|
<p className="mt-2 text-xs text-red-600">{previewError}</p>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|