Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs
2025-12-05 15:22:28 +09:00
31 changed files with 7018 additions and 1604 deletions

View File

@@ -648,7 +648,14 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
});
// Location 객체들의 자재 개수 로드
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
const dbConnectionId = layout.external_db_connection_id;
const hierarchyConfigParsed =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
const materialTableName = hierarchyConfigParsed?.material?.tableName;
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" ||
@@ -657,10 +664,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
obj.type === "location-dest") &&
obj.locaKey,
);
if (locationObjects.length > 0) {
if (locationObjects.length > 0 && dbConnectionId && materialTableName) {
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
setTimeout(() => {
loadMaterialCountsForLocations(locaKeys);
loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName);
}, 100);
}
} else {
@@ -1045,11 +1052,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
};
// Location별 자재 개수 로드 (locaKeys를 직접 받음)
const loadMaterialCountsForLocations = async (locaKeys: string[]) => {
if (!selectedDbConnection || locaKeys.length === 0) return;
const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => {
const connectionId = dbConnectionId || selectedDbConnection;
const tableName = materialTableName || selectedTables.material;
if (!connectionId || locaKeys.length === 0) return;
try {
const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys);
const response = await getMaterialCounts(connectionId, tableName, locaKeys);
console.log("📊 자재 개수 API 응답:", response);
if (response.success && response.data) {
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
setPlacedObjects((prev) =>
@@ -1060,13 +1071,23 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
) {
return obj;
}
const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey);
// 백엔드 응답 필드명: location_key, count (대소문자 모두 체크)
const materialCount = response.data?.find(
(mc: any) =>
mc.LOCAKEY === obj.locaKey ||
mc.location_key === obj.locaKey ||
mc.locakey === obj.locaKey
);
if (materialCount) {
// count 또는 material_count 필드 사용
const count = materialCount.count || materialCount.material_count || 0;
const maxLayer = materialCount.max_layer || count;
console.log(`📊 ${obj.locaKey}: 자재 ${count}`);
return {
...obj,
materialCount: materialCount.material_count,
materialCount: Number(count),
materialPreview: {
height: materialCount.max_layer * 1.5, // 층당 1.5 높이 (시각적)
height: maxLayer * 1.5, // 층당 1.5 높이 (시각적)
},
};
}

View File

@@ -54,15 +54,17 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
setLayoutName(layout.layout_name || layout.layoutName);
setExternalDbConnectionId(layout.external_db_connection_id || layout.externalDbConnectionId);
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
setExternalDbConnectionId(dbConnectionId);
// hierarchy_config 저장
let hierarchyConfigData: any = null;
if (layout.hierarchy_config) {
const config =
hierarchyConfigData =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
setHierarchyConfig(config);
setHierarchyConfig(hierarchyConfigData);
}
// 객체 데이터 변환
@@ -103,6 +105,47 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
});
setPlacedObjects(loadedObjects);
// 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회
if (dbConnectionId && hierarchyConfigData?.material) {
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.locaKey
);
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
const materialCountPromises = locationObjects.map(async (obj) => {
try {
const matResponse = await getMaterials(dbConnectionId, {
tableName: hierarchyConfigData.material.tableName,
keyColumn: hierarchyConfigData.material.keyColumn,
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
layerColumn: hierarchyConfigData.material.layerColumn,
locaKey: obj.locaKey!,
});
if (matResponse.success && matResponse.data) {
return { id: obj.id, count: matResponse.data.length };
}
} catch (e) {
console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e);
}
return { id: obj.id, count: 0 };
});
const materialCounts = await Promise.all(materialCountPromises);
// materialCount 업데이트
setPlacedObjects((prev) =>
prev.map((obj) => {
const countData = materialCounts.find((m) => m.id === obj.id);
if (countData && countData.count > 0) {
return { ...obj, materialCount: countData.count };
}
return obj;
})
);
}
} else {
throw new Error(response.error || "레이아웃 조회 실패");
}

View File

@@ -1,7 +1,7 @@
"use client";
import { Canvas, useThree } from "@react-three/fiber";
import { OrbitControls, Grid, Box, Text } from "@react-three/drei";
import { OrbitControls, Box, Text } from "@react-three/drei";
import { Suspense, useRef, useState, useEffect, useMemo } from "react";
import * as THREE from "three";
@@ -525,68 +525,77 @@ function MaterialBox({
case "location-bed":
case "location-temp":
case "location-dest":
// 베드 타입 Location: 초록색 상자
// 베드 타입 Location: 회색 철판들이 데이터 개수만큼 쌓이는 형태
const locPlateCount = placement.material_count || placement.quantity || 5; // 데이터 개수
const locVisiblePlateCount = locPlateCount; // 데이터 개수만큼 모두 렌더링
const locPlateThickness = 0.15; // 각 철판 두께
const locPlateGap = 0.03; // 철판 사이 미세한 간격
// 실제 렌더링되는 폴리곤 기준으로 높이 계산
const locVisibleStackHeight = locVisiblePlateCount * (locPlateThickness + locPlateGap);
// 그룹의 position_y를 상쇄해서 바닥(y=0)부터 시작하도록
const locYOffset = -placement.position_y;
const locPlateBaseY = locYOffset + locPlateThickness / 2;
return (
<>
<Box args={[boxWidth, boxHeight, boxDepth]}>
<meshStandardMaterial
color={placement.color}
roughness={0.5}
metalness={0.3}
emissive={isSelected ? placement.color : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
/>
</Box>
{/* 대표 자재 스택 (자재가 있을 때만) */}
{placement.material_count !== undefined &&
placement.material_count > 0 &&
placement.material_preview_height && (
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */}
{Array.from({ length: locVisiblePlateCount }).map((_, idx) => {
const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap);
// 약간의 랜덤 오프셋으로 자연스러움 추가
const xOffset = (Math.sin(idx * 0.5) * 0.02);
const zOffset = (Math.cos(idx * 0.7) * 0.02);
return (
<Box
args={[boxWidth * 0.7, placement.material_preview_height, boxDepth * 0.7]}
position={[0, boxHeight / 2 + placement.material_preview_height / 2, 0]}
key={`loc-plate-${idx}`}
args={[boxWidth, locPlateThickness, boxDepth]}
position={[xOffset, yPos, zOffset]}
>
<meshStandardMaterial
color="#ef4444"
roughness={0.6}
metalness={0.2}
emissive={isSelected ? "#ef4444" : "#000000"}
color="#6b7280" // 회색 (고정)
roughness={0.4}
metalness={0.7}
emissive={isSelected ? "#9ca3af" : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
transparent
opacity={0.7}
/>
{/* 각 철판 외곽선 */}
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, locPlateThickness, boxDepth)]} />
<lineBasicMaterial color="#374151" opacity={0.8} transparent />
</lineSegments>
</Box>
)}
{/* Location 이름 */}
);
})}
{/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */}
{placement.name && (
<Text
position={[0, boxHeight / 2 + 0.3, 0]}
position={[0, locYOffset + locVisibleStackHeight + 0.3, boxDepth * 0.3]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
color="#ffffff"
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
color="#374151"
anchorX="center"
anchorY="middle"
outlineWidth={0.03}
outlineColor="#000000"
outlineColor="#ffffff"
>
{placement.name}
</Text>
)}
{/* 자재 개수 */}
{placement.material_count !== undefined && placement.material_count > 0 && (
{/* 수량 표시 텍스트 - 실제 폴리곤 높이 기준, 앞쪽(-Z)에 배치 */}
{locPlateCount > 0 && (
<Text
position={[0, boxHeight / 2 + 0.6, 0]}
position={[0, locYOffset + locVisibleStackHeight + 0.3, -boxDepth * 0.3]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
color="#fbbf24"
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
color="#1f2937"
anchorX="center"
anchorY="middle"
outlineWidth={0.03}
outlineColor="#000000"
outlineWidth={0.02}
outlineColor="#ffffff"
>
{`자재: ${placement.material_count}`}
{`${locPlateCount}`}
</Text>
)}
</>
@@ -886,83 +895,79 @@ function MaterialBox({
case "plate-stack":
default:
// 후판 스택: 팔레트 + 박스 (기존 렌더링)
// 후판 스택: 회색 철판들이 데이터 개수만큼 쌓이는 형태
const plateCount = placement.material_count || placement.quantity || 5; // 데이터 개수 (기본 5장)
const visiblePlateCount = plateCount; // 데이터 개수만큼 모두 렌더링
const plateThickness = 0.15; // 각 철판 두께
const plateGap = 0.03; // 철판 사이 미세한 간격
// 실제 렌더링되는 폴리곤 기준으로 높이 계산
const visibleStackHeight = visiblePlateCount * (plateThickness + plateGap);
// 그룹의 position_y를 상쇄해서 바닥(y=0)부터 시작하도록
const yOffset = -placement.position_y;
const plateBaseY = yOffset + plateThickness / 2;
return (
<>
{/* 팔레트 그룹 - 박스 하단에 붙어있도록 */}
<group position={[0, palletYOffset, 0]}>
{/* 상단 가로 판자들 (5개) */}
{[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => (
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */}
{Array.from({ length: visiblePlateCount }).map((_, idx) => {
const yPos = plateBaseY + idx * (plateThickness + plateGap);
// 약간의 랜덤 오프셋으로 자연스러움 추가 (실제 철판처럼)
const xOffset = (Math.sin(idx * 0.5) * 0.02);
const zOffset = (Math.cos(idx * 0.7) * 0.02);
return (
<Box
key={`top-${idx}`}
args={[boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15]}
position={[0, palletHeight * 0.35, zOffset]}
key={`plate-${idx}`}
args={[boxWidth, plateThickness, boxDepth]}
position={[xOffset, yPos, zOffset]}
>
<meshStandardMaterial color="#8B4513" roughness={0.95} metalness={0.0} />
<meshStandardMaterial
color="#6b7280" // 회색 (고정)
roughness={0.4}
metalness={0.7}
emissive={isSelected ? "#9ca3af" : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
/>
{/* 각 철판 외곽선 */}
<lineSegments>
<edgesGeometry
args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15)]}
/>
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, plateThickness, boxDepth)]} />
<lineBasicMaterial color="#374151" opacity={0.8} transparent />
</lineSegments>
</Box>
))}
{/* 중간 세로 받침대 (3개) */}
{[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => (
<Box
key={`middle-${idx}`}
args={[boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2]}
position={[xOffset, 0, 0]}
>
<meshStandardMaterial color="#654321" roughness={0.98} metalness={0.0} />
<lineSegments>
<edgesGeometry
args={[new THREE.BoxGeometry(boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2)]}
/>
<lineBasicMaterial color="#000000" opacity={0.4} transparent />
</lineSegments>
</Box>
))}
{/* 하단 가로 판자들 (3개) */}
{[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => (
<Box
key={`bottom-${idx}`}
args={[boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18]}
position={[0, -palletHeight * 0.35, zOffset]}
>
<meshStandardMaterial color="#6B4423" roughness={0.97} metalness={0.0} />
<lineSegments>
<edgesGeometry
args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18)]}
/>
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
</lineSegments>
</Box>
))}
</group>
{/* 메인 박스 */}
<Box args={[boxWidth, boxHeight, boxDepth]} position={[0, 0, 0]}>
{/* 메인 재질 - 골판지 느낌 */}
<meshStandardMaterial
color={placement.color}
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
transparent
emissive={isSelected ? "#ffffff" : "#000000"}
emissiveIntensity={isSelected ? 0.2 : 0}
wireframe={!isConfigured}
roughness={0.95}
metalness={0.05}
/>
{/* 외곽선 - 더 진하게 */}
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth)]} />
<lineBasicMaterial color="#000000" opacity={0.6} transparent linewidth={1.5} />
</lineSegments>
</Box>
);
})}
{/* 수량 표시 텍스트 (상단) - 앞쪽(-Z)에 배치 */}
{plateCount > 0 && (
<Text
position={[0, yOffset + visibleStackHeight + 0.3, -boxDepth * 0.3]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
color="#374151"
anchorX="center"
anchorY="middle"
outlineWidth={0.03}
outlineColor="#ffffff"
>
{`${plateCount}`}
</Text>
)}
{/* 자재명 표시 (있는 경우) - 뒤쪽(+Z)에 배치 */}
{placement.material_name && (
<Text
position={[0, yOffset + visibleStackHeight + 0.3, boxDepth * 0.3]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
color="#1f2937"
anchorX="center"
anchorY="middle"
outlineWidth={0.02}
outlineColor="#ffffff"
>
{placement.material_name}
</Text>
)}
</>
);
}
@@ -1114,20 +1119,11 @@ function Scene({
{/* 배경색 */}
<color attach="background" args={["#f3f4f6"]} />
{/* 바닥 그리드 (타일을 4등분) */}
<Grid
args={[100, 100]}
cellSize={gridSize / 2} // 타일을 2x2로 나눔 (2.5칸)
cellThickness={0.6}
cellColor="#d1d5db" // 얇은 선 (서브 그리드) - 밝은 회색
sectionSize={gridSize} // 타일 경계선 (5칸마다)
sectionThickness={1.5}
sectionColor="#6b7280" // 타일 경계는 조금 어둡게
fadeDistance={200}
fadeStrength={1}
followCamera={false}
infiniteGrid={true}
/>
{/* 바닥 - 단색 평면 (그리드 제거) */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]}>
<planeGeometry args={[200, 200]} />
<meshStandardMaterial color="#e5e7eb" roughness={0.9} metalness={0.1} />
</mesh>
{/* 자재 박스들 */}
{placements.map((placement) => (

View File

@@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, Edit2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview";
import {
@@ -47,6 +47,10 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const [rightTitle, setRightTitle] = useState("규칙 편집");
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
const [editingRightTitle, setEditingRightTitle] = useState(false);
// 구분자 관련 상태
const [separatorType, setSeparatorType] = useState<SeparatorType>("-");
const [customSeparator, setCustomSeparator] = useState("");
useEffect(() => {
loadRules();
@@ -87,6 +91,50 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
}
}, [currentRule, onChange]);
// currentRule이 변경될 때 구분자 상태 동기화
useEffect(() => {
if (currentRule) {
const sep = currentRule.separator ?? "-";
// 빈 문자열이면 "none"
if (sep === "") {
setSeparatorType("none");
setCustomSeparator("");
return;
}
// 미리 정의된 구분자인지 확인 (none, custom 제외)
const predefinedOption = SEPARATOR_OPTIONS.find(
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
);
if (predefinedOption) {
setSeparatorType(predefinedOption.value);
setCustomSeparator("");
} else {
// 직접 입력된 구분자
setSeparatorType("custom");
setCustomSeparator(sep);
}
}
}, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시)
// 구분자 변경 핸들러
const handleSeparatorChange = useCallback((type: SeparatorType) => {
setSeparatorType(type);
if (type !== "custom") {
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
const newSeparator = option?.displayValue ?? "";
setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null);
setCustomSeparator("");
}
}, []);
// 직접 입력 구분자 변경 핸들러
const handleCustomSeparatorChange = useCallback((value: string) => {
// 최대 2자 제한
const trimmedValue = value.slice(0, 2);
setCustomSeparator(trimmedValue);
setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null);
}, []);
const handleAddPart = useCallback(() => {
if (!currentRule) return;
@@ -373,7 +421,44 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</div>
</div>
{/* 두 번째 줄: 자동 감지된 테이블 정보 표시 */}
{/* 두 번째 줄: 구분자 설정 */}
<div className="flex items-end gap-3">
<div className="w-48 space-y-2">
<Label className="text-sm font-medium"></Label>
<Select
value={separatorType}
onValueChange={(value) => handleSeparatorChange(value as SeparatorType)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="구분자 선택" />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{separatorType === "custom" && (
<div className="w-32 space-y-2">
<Label className="text-sm font-medium"> </Label>
<Input
value={customSeparator}
onChange={(e) => handleCustomSeparatorChange(e.target.value)}
className="h-9"
placeholder="최대 2자"
maxLength={2}
/>
</div>
)}
<p className="text-muted-foreground pb-2 text-xs">
</p>
</div>
{/* 세 번째 줄: 자동 감지된 테이블 정보 표시 */}
{currentTableName && (
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>

View File

@@ -304,7 +304,24 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
};
// 저장 버튼 클릭 시 - UPDATE 액션 실행
const handleSave = async () => {
const handleSave = async (saveData?: any) => {
// universal-form-modal 등에서 자체 저장 완료 후 호출된 경우 스킵
if (saveData?._saveCompleted) {
console.log("[EditModal] 자체 저장 완료된 컴포넌트에서 호출됨 - 저장 스킵");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("onSave 콜백 에러:", callbackError);
}
}
handleClose();
return;
}
if (!screenData?.screenInfo?.tableName) {
toast.error("테이블 정보가 없습니다.");
return;

View File

@@ -1953,6 +1953,139 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
)}
{/* 🆕 버튼 활성화 조건 설정 */}
<div className="mt-4 border-t pt-4">
<h5 className="mb-3 text-xs font-medium text-muted-foreground"> </h5>
{/* 출발지/도착지 필수 체크 */}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="require-location">/ </Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="require-location"
checked={config.action?.requireLocationFields === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.requireLocationFields", checked)}
/>
</div>
{config.action?.requireLocationFields && (
<div className="mt-3 space-y-2 rounded-md bg-orange-50 p-3 dark:bg-orange-950">
<div className="grid grid-cols-2 gap-2">
<div>
<Label> </Label>
<Input
placeholder="departure"
value={config.action?.trackingDepartureField || "departure"}
onChange={(e) => onUpdateProperty("componentConfig.action.trackingDepartureField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Input
placeholder="destination"
value={config.action?.trackingArrivalField || "destination"}
onChange={(e) => onUpdateProperty("componentConfig.action.trackingArrivalField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
</div>
)}
{/* 상태 기반 활성화 조건 */}
<div className="mt-4 flex items-center justify-between">
<div>
<Label htmlFor="enable-on-status"> </Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="enable-on-status"
checked={config.action?.enableOnStatusCheck === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.enableOnStatusCheck", checked)}
/>
</div>
{config.action?.enableOnStatusCheck && (
<div className="mt-3 space-y-2 rounded-md bg-purple-50 p-3 dark:bg-purple-950">
<div>
<Label> </Label>
<Select
value={config.action?.statusCheckTableName || "vehicles"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.statusCheckTableName", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground">
(기본: vehicles)
</p>
</div>
<div>
<Label> </Label>
<Input
placeholder="user_id"
value={config.action?.statusCheckKeyField || "user_id"}
onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckKeyField", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
ID로 (기본: user_id)
</p>
</div>
<div>
<Label> </Label>
<Input
placeholder="status"
value={config.action?.statusCheckField || "status"}
onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckField", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
(기본: status)
</p>
</div>
<div>
<Label> </Label>
<Select
value={config.action?.statusConditionType || "enableOn"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.statusConditionType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enableOn"> </SelectItem>
<SelectItem value="disableOn"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label> ( )</Label>
<Input
placeholder="예: active, inactive"
value={config.action?.statusConditionValues || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.statusConditionValues", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
(,)
</p>
</div>
</div>
)}
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong>