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" && (
+
+
+
+
+ )}
+
+
+
+
+
+
+ >
+ )}
+
+ {/* REST API */}
+ {dataSourceType === "rest_api" && (
+ <>
+
+
+ setApiUrl(e.target.value)}
+ placeholder="https://api.example.com/materials"
+ className="mt-1"
+ />
+
+
+
+
+
+
+
+
+
+
setApiDataPath(e.target.value)}
+ placeholder="data.items"
+ className="mt-1"
+ />
+
예: data.items (응답에서 배열이 있는 경로)
+
+
+
+ >
+ )}
+
+
+
+ {/* 2단계: 쿼리 결과 및 필드 매핑 */}
+ {queryResult && (
+
+ 2단계: 데이터 선택 및 필드 매핑
+
+
+
+
+ {queryResult.rows.length > 10 && (
+
... 및 {queryResult.rows.length - 10}개 더
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setUnit(e.target.value)} placeholder="EA" className="mt-1" />
+
예: EA, BOX, KG, M, L 등
+
+
+
+ )}
+
+ {/* 3단계: 배치 설정 */}
+
+ 3단계: 배치 설정
+
+
+
+
+ setColor(e.target.value)} className="mt-1" />
+
+
+
+
+
+
+
+
+ {/* 하단 버튼 */}
+
+
+
+
+
+
+
+ );
+}