538 lines
20 KiB
TypeScript
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-muted-foreground">({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-muted-foreground">예: 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">
|
|
<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-primary/10" : ""}
|
|
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-muted-foreground">... 및 {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-muted-foreground">예: 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>
|
|
);
|
|
}
|