diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index b9995e26..d55e8ad3 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -7,11 +7,10 @@ import * as THREE from "three"; interface YardPlacement { id: number; - external_material_id: string; - material_code: string; - material_name: string; - quantity: number; - unit: string; + material_code?: string | null; + material_name?: string | null; + quantity?: number | null; + unit?: string | null; position_x: number; position_y: number; position_z: number; @@ -19,6 +18,9 @@ interface YardPlacement { size_y: number; size_z: number; color: string; + data_source_type?: string | null; + data_source_config?: any; + data_binding?: any; } interface Yard3DCanvasProps { @@ -159,6 +161,9 @@ function MaterialBox({ } }; + // 요소가 설정되었는지 확인 + const isConfigured = !!(placement.material_name && placement.quantity && placement.unit); + return ( ); diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx index 2c6f1bf4..ead548f1 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx @@ -8,11 +8,10 @@ import { Loader2 } from "lucide-react"; interface YardPlacement { id: number; yard_layout_id: number; - external_material_id: string; - material_code: string; - material_name: string; - quantity: number; - unit: string; + material_code?: string | null; + material_name?: string | null; + quantity?: number | null; + unit?: string | null; position_x: number; position_y: number; position_z: number; @@ -20,6 +19,9 @@ interface YardPlacement { size_y: number; size_z: number; color: string; + data_source_type?: string | null; + data_source_config?: any; + data_binding?: any; status?: string; memo?: string; } @@ -130,7 +132,9 @@ export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) { {selectedPlacement && (
-

자재 정보

+

+ {selectedPlacement.material_name ? "자재 정보" : "미설정 요소"} +

-
-
- -
{selectedPlacement.material_name}
-
+ {selectedPlacement.material_name && selectedPlacement.quantity && selectedPlacement.unit ? ( +
+
+ +
{selectedPlacement.material_name}
+
-
- -
- {selectedPlacement.quantity} {selectedPlacement.unit} +
+ +
+ {selectedPlacement.quantity} {selectedPlacement.unit} +
-
+ ) : ( +
+
⚠️
+
데이터 바인딩이
+
설정되지 않았습니다
+
편집 모드에서 설정해주세요
+
+ )}
)}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 3a620841..182339e4 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -267,7 +267,7 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
요소가 없습니다.
- 위의 "요소 추가" 버튼을 클릭하세요. + {'위의 "요소 추가" 버튼을 클릭하세요.'}
) : (
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx new file mode 100644 index 00000000..81fcccd4 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx @@ -0,0 +1,553 @@ +"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 } 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) => 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 any) || "database", + ); + const [query, setQuery] = useState(placement.data_source_config?.query || ""); + const [externalConnectionId, setExternalConnectionId] = useState( + placement.data_source_config?.connectionId?.toString() || "", + ); + const [externalConnections, setExternalConnections] = useState([]); + + // 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(null); + const [isExecuting, setIsExecuting] = useState(false); + const [selectedRowIndex, setSelectedRowIndex] = useState(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(null); + const [isSaving, setIsSaving] = useState(false); + + // 외부 DB 커넥션 목록 로드 + useEffect(() => { + const loadConnections = async () => { + try { + const response = await ExternalDbConnectionAPI.getAll(); + if (response.success) { + setExternalConnections(response.data || []); + } + } 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: any[]; 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 handleSave = async () => { + // 검증 + if (!queryResult) { + setError("먼저 데이터를 조회해주세요."); + return; + } + + if (!materialNameField || !quantityField) { + setError("자재명과 수량 필드를 선택해주세요."); + return; + } + + if (!unit.trim()) { + setError("단위를 입력해주세요."); + return; + } + + if (selectedRowIndex >= queryResult.rows.length) { + setError("선택한 행이 유효하지 않습니다."); + return; + } + + setIsSaving(true); + + try { + 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 = { + 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, + }; + + await onSave(updatedData); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "저장 중 오류가 발생했습니다."; + setError(errorMessage); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ {/* 헤더 */} +
+

데이터 바인딩 설정

+ +
+ + {/* 에러 메시지 */} + {error && ( + + + {error} + + )} + + {/* 컨텐츠 */} +
+
+ {/* 1단계: 데이터 소스 선택 */} + +

1단계: 데이터 소스 선택

+ + setDataSourceType(value)}> +
+ + +
+
+ + +
+
+ + +
+
+ +
+ {/* 현재 DB 또는 외부 DB */} + {dataSourceType !== "rest_api" && ( + <> + {dataSourceType === "external_db" && ( +
+ + +
+ )} + +
+ +