3D의 데이터 바인딩 재설계를 완료
This commit is contained in:
parent
8932f61298
commit
aba6283e3f
|
|
@ -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 (
|
||||
<Box
|
||||
ref={meshRef}
|
||||
|
|
@ -168,7 +173,6 @@ function MaterialBox({
|
|||
e.stopPropagation();
|
||||
e.nativeEvent?.stopPropagation();
|
||||
e.nativeEvent?.stopImmediatePropagation();
|
||||
console.log("3D Box clicked:", placement.material_name);
|
||||
onClick();
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
|
|
@ -188,10 +192,11 @@ function MaterialBox({
|
|||
>
|
||||
<meshStandardMaterial
|
||||
color={placement.color}
|
||||
opacity={isSelected ? 1 : 0.8}
|
||||
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
|
||||
transparent
|
||||
emissive={isSelected ? "#ffffff" : "#000000"}
|
||||
emissiveIntensity={isSelected ? 0.2 : 0}
|
||||
wireframe={!isConfigured}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<div className="absolute top-4 right-4 z-50 w-64 rounded-lg border border-gray-300 bg-white p-4 shadow-xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-800">자재 정보</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
{selectedPlacement.material_name ? "자재 정보" : "미설정 요소"}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPlacement(null);
|
||||
|
|
@ -141,19 +145,28 @@ export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">자재명</label>
|
||||
<div className="mt-1 text-sm font-semibold text-gray-900">{selectedPlacement.material_name}</div>
|
||||
</div>
|
||||
{selectedPlacement.material_name && selectedPlacement.quantity && selectedPlacement.unit ? (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">자재명</label>
|
||||
<div className="mt-1 text-sm font-semibold text-gray-900">{selectedPlacement.material_name}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">수량</label>
|
||||
<div className="mt-1 text-sm font-semibold text-gray-900">
|
||||
{selectedPlacement.quantity} {selectedPlacement.unit}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">수량</label>
|
||||
<div className="mt-1 text-sm font-semibold text-gray-900">
|
||||
{selectedPlacement.quantity} {selectedPlacement.unit}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-orange-50 p-3 text-center">
|
||||
<div className="mb-2 text-2xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-orange-700">데이터 바인딩이</div>
|
||||
<div className="text-sm font-medium text-orange-700">설정되지 않았습니다</div>
|
||||
<div className="mt-2 text-xs text-orange-600">편집 모드에서 설정해주세요</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -267,7 +267,7 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-gray-500">
|
||||
요소가 없습니다.
|
||||
<br />
|
||||
위의 "요소 추가" 버튼을 클릭하세요.
|
||||
{'위의 "요소 추가" 버튼을 클릭하세요.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -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<YardPlacement>) => Promise<void>;
|
||||
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<string>(
|
||||
placement.data_source_config?.connectionId?.toString() || "",
|
||||
);
|
||||
const [externalConnections, setExternalConnections] = useState<any[]>([]);
|
||||
|
||||
// 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);
|
||||
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<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,
|
||||
};
|
||||
|
||||
await onSave(updatedData);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "저장 중 오류가 발생했습니다.";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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: any) => setDataSourceType(value)}>
|
||||
<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: any) => setApiMethod(value)}>
|
||||
<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={handleSave} disabled={isSaving || !queryResult} className="flex-1">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
"저장"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue