ERP-node/frontend/components/report/designer/properties/VisualQueryBuilder.tsx

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