ERP-node/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx

538 lines
20 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ArrowLeft, Loader2, Play } from "lucide-react";
import { YardPlacement, YardDataSourceConfig, YardDataBinding, QueryResult } from "./types";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
import { dashboardApi } from "@/lib/api/dashboard";
import { ExternalDbConnectionAPI, type ExternalDbConnection } from "@/lib/api/externalDbConnection";
import { Textarea } from "@/components/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
interface YardElementConfigPanelProps {
placement: YardPlacement;
onSave: (updatedData: Partial<YardPlacement>) => void; // Promise 제거 (즉시 로컬 상태 업데이트)
onCancel: () => void;
}
export default function YardElementConfigPanel({ placement, onSave, onCancel }: YardElementConfigPanelProps) {
// 데이터 소스 설정
const [dataSourceType, setDataSourceType] = useState<"database" | "external_db" | "rest_api">(
(placement.data_source_config?.type as "database" | "external_db" | "rest_api") || "database",
);
const [query, setQuery] = useState(placement.data_source_config?.query || "");
const [externalConnectionId, setExternalConnectionId] = useState<string>(
placement.data_source_config?.connectionId?.toString() || "",
);
const [externalConnections, setExternalConnections] = useState<ExternalDbConnection[]>([]);
// REST API 설정
const [apiUrl, setApiUrl] = useState(placement.data_source_config?.url || "");
const [apiMethod, setApiMethod] = useState<"GET" | "POST">(placement.data_source_config?.method || "GET");
const [apiDataPath, setApiDataPath] = useState(placement.data_source_config?.dataPath || "");
// 쿼리 결과 및 매핑
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
const [isExecuting, setIsExecuting] = useState(false);
const [selectedRowIndex, setSelectedRowIndex] = useState<number>(placement.data_binding?.selectedRowIndex ?? 0);
const [materialNameField, setMaterialNameField] = useState(placement.data_binding?.materialNameField || "");
const [quantityField, setQuantityField] = useState(placement.data_binding?.quantityField || "");
const [unit, setUnit] = useState(placement.data_binding?.unit || "EA");
// 배치 설정
const [color, setColor] = useState(placement.color || "#3b82f6");
const [sizeX, setSizeX] = useState(placement.size_x);
const [sizeY, setSizeY] = useState(placement.size_y);
const [sizeZ, setSizeZ] = useState(placement.size_z);
// 에러
const [error, setError] = useState<string | null>(null);
// 외부 DB 커넥션 목록 로드
useEffect(() => {
const loadConnections = async () => {
try {
const connections = await ExternalDbConnectionAPI.getConnections();
setExternalConnections(connections || []);
} catch (err) {
console.error("외부 DB 커넥션 로드 실패:", err);
}
};
if (dataSourceType === "external_db") {
loadConnections();
}
}, [dataSourceType]);
// 쿼리 실행
const executeQuery = async () => {
if (!query.trim()) {
setError("쿼리를 입력해주세요.");
return;
}
if (dataSourceType === "external_db" && !externalConnectionId) {
setError("외부 DB 커넥션을 선택해주세요.");
return;
}
setIsExecuting(true);
setError(null);
try {
let apiResult: { columns: string[]; rows: Record<string, unknown>[]; rowCount: number };
if (dataSourceType === "external_db" && externalConnectionId) {
const result = await ExternalDbConnectionAPI.executeQuery(parseInt(externalConnectionId), query.trim());
if (!result.success) {
throw new Error(result.message || "외부 DB 쿼리 실행에 실패했습니다.");
}
apiResult = {
columns: result.data?.[0] ? Object.keys(result.data[0]) : [],
rows: result.data || [],
rowCount: result.data?.length || 0,
};
} else {
apiResult = await dashboardApi.executeQuery(query.trim());
}
setQueryResult({
columns: apiResult.columns,
rows: apiResult.rows,
totalRows: apiResult.rowCount,
});
// 자동으로 첫 번째 필드 선택
if (apiResult.columns.length > 0) {
if (!materialNameField) setMaterialNameField(apiResult.columns[0]);
if (!quantityField && apiResult.columns.length > 1) setQuantityField(apiResult.columns[1]);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "쿼리 실행 중 오류가 발생했습니다.";
setError(errorMessage);
} finally {
setIsExecuting(false);
}
};
// REST API 호출
const executeRestApi = async () => {
if (!apiUrl.trim()) {
setError("API URL을 입력해주세요.");
return;
}
setIsExecuting(true);
setError(null);
try {
const response = await fetch(apiUrl, {
method: apiMethod,
});
if (!response.ok) {
throw new Error(`API 호출 실패: ${response.status}`);
}
let data = await response.json();
// dataPath가 있으면 해당 경로의 데이터 추출
if (apiDataPath) {
const pathParts = apiDataPath.split(".");
for (const part of pathParts) {
data = data[part];
}
}
// 배열이 아니면 배열로 변환
if (!Array.isArray(data)) {
data = [data];
}
const columns = data.length > 0 ? Object.keys(data[0]) : [];
setQueryResult({
columns,
rows: data,
totalRows: data.length,
});
// 자동으로 첫 번째 필드 선택
if (columns.length > 0) {
if (!materialNameField) setMaterialNameField(columns[0]);
if (!quantityField && columns.length > 1) setQuantityField(columns[1]);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "API 호출 중 오류가 발생했습니다.";
setError(errorMessage);
} finally {
setIsExecuting(false);
}
};
// 적용 (로컬 상태만 업데이트, 서버 저장은 나중에 일괄 처리)
const handleApply = () => {
// 검증
if (!queryResult) {
setError("먼저 데이터를 조회해주세요.");
return;
}
if (!materialNameField || !quantityField) {
setError("자재명과 수량 필드를 선택해주세요.");
return;
}
if (!unit.trim()) {
setError("단위를 입력해주세요.");
return;
}
if (selectedRowIndex >= queryResult.rows.length) {
setError("선택한 행이 유효하지 않습니다.");
return;
}
const selectedRow = queryResult.rows[selectedRowIndex];
const materialName = selectedRow[materialNameField];
const quantity = selectedRow[quantityField];
const dataSourceConfig: YardDataSourceConfig = {
type: dataSourceType,
query: dataSourceType !== "rest_api" ? query : undefined,
connectionId: dataSourceType === "external_db" ? parseInt(externalConnectionId) : undefined,
url: dataSourceType === "rest_api" ? apiUrl : undefined,
method: dataSourceType === "rest_api" ? apiMethod : undefined,
dataPath: dataSourceType === "rest_api" && apiDataPath ? apiDataPath : undefined,
};
const dataBinding: YardDataBinding = {
selectedRowIndex,
materialNameField,
quantityField,
unit: unit.trim(),
};
const updatedData: Partial<YardPlacement> = {
material_name: String(materialName),
quantity: Number(quantity),
unit: unit.trim(),
color,
size_x: sizeX,
size_y: sizeY,
size_z: sizeZ,
data_source_type: dataSourceType,
data_source_config: dataSourceConfig,
data_binding: dataBinding,
};
onSave(updatedData); // 동기적으로 즉시 로컬 상태 업데이트
};
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b p-4">
<h3 className="text-sm font-semibold"> </h3>
<Button variant="ghost" size="sm" onClick={onCancel}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</div>
{/* 에러 메시지 */}
{error && (
<Alert variant="destructive" className="m-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* 컨텐츠 */}
<div className="flex-1 overflow-auto p-4">
<div className="space-y-6">
{/* 1단계: 데이터 소스 선택 */}
<Card className="p-4">
<h4 className="mb-3 text-sm font-semibold">1단계: 데이터 </h4>
<RadioGroup
value={dataSourceType}
onValueChange={(value) => setDataSourceType(value as "database" | "external_db" | "rest_api")}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="database" id="db" />
<Label htmlFor="db"> DB</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="external_db" id="external" />
<Label htmlFor="external"> DB</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="rest_api" id="api" />
<Label htmlFor="api">REST API</Label>
</div>
</RadioGroup>
<div className="mt-4 space-y-3">
{/* 현재 DB 또는 외부 DB */}
{dataSourceType !== "rest_api" && (
<>
{dataSourceType === "external_db" && (
<div>
<Label className="text-xs"> DB </Label>
<Select value={externalConnectionId} onValueChange={setExternalConnectionId}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="커넥션 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]">
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={String(conn.id)}>
<div className="flex items-center gap-2">
<span className="font-medium">{conn.connection_name}</span>
<span className="text-xs text-gray-500">({conn.db_type.toUpperCase()})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label className="text-xs">SQL </Label>
<Textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="SELECT material_name, quantity FROM inventory"
className="mt-1 h-24 resize-none font-mono text-xs"
/>
</div>
<Button onClick={executeQuery} disabled={isExecuting} size="sm" className="w-full">
{isExecuting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
</>
)}
</Button>
</>
)}
{/* REST API */}
{dataSourceType === "rest_api" && (
<>
<div>
<Label className="text-xs">API URL</Label>
<Input
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
placeholder="https://api.example.com/materials"
className="mt-1"
/>
</div>
<div>
<Label className="text-xs">Method</Label>
<Select value={apiMethod} onValueChange={(value) => setApiMethod(value as "GET" | "POST")}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> ()</Label>
<Input
value={apiDataPath}
onChange={(e) => setApiDataPath(e.target.value)}
placeholder="data.items"
className="mt-1"
/>
<p className="mt-1 text-xs text-gray-500">: data.items ( )</p>
</div>
<Button onClick={executeRestApi} disabled={isExecuting} size="sm" className="w-full">
{isExecuting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
</>
)}
</Button>
</>
)}
</div>
</Card>
{/* 2단계: 쿼리 결과 및 필드 매핑 */}
{queryResult && (
<Card className="p-4">
<h4 className="mb-3 text-sm font-semibold">2단계: 데이터 </h4>
<div className="mb-3">
<Label className="text-xs"> ({queryResult.totalRows})</Label>
<div className="mt-2 max-h-40 overflow-auto rounded border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
{queryResult.columns.map((col, idx) => (
<TableHead key={idx}>{col}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{queryResult.rows.slice(0, 10).map((row, idx) => (
<TableRow
key={idx}
className={selectedRowIndex === idx ? "bg-blue-50" : ""}
onClick={() => setSelectedRowIndex(idx)}
>
<TableCell>
<input
type="radio"
checked={selectedRowIndex === idx}
onChange={() => setSelectedRowIndex(idx)}
/>
</TableCell>
{queryResult.columns.map((col, colIdx) => (
<TableCell key={colIdx}>{String(row[col] ?? "")}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
{queryResult.rows.length > 10 && (
<p className="mt-2 text-xs text-gray-500">... {queryResult.rows.length - 10} </p>
)}
</div>
<div className="space-y-3">
<div>
<Label className="text-xs"> </Label>
<Select value={materialNameField} onValueChange={setMaterialNameField}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{queryResult.columns.map((col) => (
<SelectItem key={col} value={col}>
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select value={quantityField} onValueChange={setQuantityField}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{queryResult.columns.map((col) => (
<SelectItem key={col} value={col}>
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Input value={unit} onChange={(e) => setUnit(e.target.value)} placeholder="EA" className="mt-1" />
<p className="mt-1 text-xs text-gray-500">: EA, BOX, KG, M, L </p>
</div>
</div>
</Card>
)}
{/* 3단계: 배치 설정 */}
<Card className="p-4">
<h4 className="mb-3 text-sm font-semibold">3단계: 배치 </h4>
<div className="space-y-3">
<div>
<Label className="text-xs"></Label>
<Input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="mt-1" />
</div>
<div>
<Label className="text-xs"></Label>
<div className="mt-1 grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"></Label>
<Input
type="number"
step="1"
value={sizeX}
onChange={(e) => setSizeX(parseFloat(e.target.value) || 1)}
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
step="1"
value={sizeY}
onChange={(e) => setSizeY(parseFloat(e.target.value) || 1)}
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
step="1"
value={sizeZ}
onChange={(e) => setSizeZ(parseFloat(e.target.value) || 1)}
/>
</div>
</div>
</div>
</div>
</Card>
</div>
</div>
{/* 하단 버튼 */}
<div className="border-t p-4">
<div className="flex gap-2">
<Button variant="outline" onClick={onCancel} className="flex-1">
</Button>
<Button onClick={handleApply} disabled={!queryResult} className="flex-1">
</Button>
</div>
</div>
</div>
);
}