diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index fbf55750..194f7210 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -148,9 +148,19 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { switch (format) { case "date": - return new Date(value).toLocaleDateString("ko-KR"); + try { + const dateVal = new Date(value); + return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" }); + } catch { + return String(value); + } case "datetime": - return new Date(value).toLocaleString("ko-KR"); + try { + const dateVal = new Date(value); + return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" }); + } catch { + return String(value); + } case "number": return Number(value).toLocaleString("ko-KR"); case "currency": diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index b99b58af..f511a7b1 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -2,7 +2,19 @@ import { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react"; +import { + ArrowLeft, + Save, + Loader2, + Grid3x3, + Move, + Box, + Package, + Truck, + Check, + ParkingCircle, + RefreshCw, +} from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -78,7 +90,7 @@ const DebouncedInput = ({ const handleBlur = (e: React.FocusEvent) => { setIsEditing(false); if (onCommit && debounce === 0) { - // 값이 변경되었을 때만 커밋하도록 하면 좋겠지만, + // 값이 변경되었을 때만 커밋하도록 하면 좋겠지만, // 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨) onCommit(type === "number" ? parseFloat(localValue as string) : localValue); } @@ -545,150 +557,170 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi // 레이아웃 데이터 로드 const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null); + const [isRefreshing, setIsRefreshing] = useState(false); - useEffect(() => { - const loadLayout = async () => { - try { - setIsLoading(true); - const response = await getLayoutById(layoutId); + // 레이아웃 로드 함수 + const loadLayout = async () => { + try { + setIsLoading(true); + const response = await getLayoutById(layoutId); - if (response.success && response.data) { - const { layout, objects } = response.data; - setLayoutData({ layout, objects }); // 레이아웃 데이터 저장 + if (response.success && response.data) { + const { layout, objects } = response.data; + setLayoutData({ layout, objects }); // 레이아웃 데이터 저장 - // 외부 DB 연결 ID 복원 - if (layout.external_db_connection_id) { - setSelectedDbConnection(layout.external_db_connection_id); - } - - // 계층 구조 설정 로드 - if (layout.hierarchy_config) { - try { - // hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용 - const config = - typeof layout.hierarchy_config === "string" - ? JSON.parse(layout.hierarchy_config) - : layout.hierarchy_config; - setHierarchyConfig(config); - - // 선택된 테이블 정보도 복원 - const newSelectedTables: any = { - warehouse: config.warehouse?.tableName || "", - area: "", - location: "", - material: "", - }; - - if (config.levels && config.levels.length > 0) { - // 레벨 1 = Area - if (config.levels[0]?.tableName) { - newSelectedTables.area = config.levels[0].tableName; - } - // 레벨 2 = Location - if (config.levels[1]?.tableName) { - newSelectedTables.location = config.levels[1].tableName; - } - } - - // 자재 테이블 정보 - if (config.material?.tableName) { - newSelectedTables.material = config.material.tableName; - } - - setSelectedTables(newSelectedTables); - } catch (e) { - console.error("계층 구조 설정 파싱 실패:", e); - } - } - - // 객체 데이터 변환 (DB -> PlacedObject) - const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ - id: obj.id, - type: obj.object_type, - name: obj.object_name, - position: { - x: parseFloat(obj.position_x), - y: parseFloat(obj.position_y), - z: parseFloat(obj.position_z), - }, - size: { - x: parseFloat(obj.size_x), - y: parseFloat(obj.size_y), - z: parseFloat(obj.size_z), - }, - rotation: obj.rotation ? parseFloat(obj.rotation) : 0, - color: obj.color, - areaKey: obj.area_key, - locaKey: obj.loca_key, - locType: obj.loc_type, - materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, - materialPreview: - obj.loc_type === "STP" || !obj.material_preview_height - ? undefined - : { height: parseFloat(obj.material_preview_height) }, - parentId: obj.parent_id, - displayOrder: obj.display_order, - locked: obj.locked, - visible: obj.visible !== false, - hierarchyLevel: obj.hierarchy_level || 1, - parentKey: obj.parent_key, - externalKey: obj.external_key, - })); - - setPlacedObjects(loadedObjects); - - // 다음 임시 ID 설정 (기존 ID 중 최소값 - 1) - const minId = Math.min(...loadedObjects.map((o) => o.id), 0); - setNextObjectId(minId - 1); - - setHasUnsavedChanges(false); - - toast({ - title: "레이아웃 불러오기 완료", - description: `${loadedObjects.length}개의 객체를 불러왔습니다.`, - }); - - // 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" || - obj.type === "location-stp" || - obj.type === "location-temp" || - obj.type === "location-dest") && - obj.locaKey, - ); - if (locationObjects.length > 0 && dbConnectionId && materialTableName) { - const locaKeys = locationObjects.map((obj) => obj.locaKey!); - setTimeout(() => { - loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName); - }, 100); - } - } else { - throw new Error(response.error || "레이아웃 조회 실패"); + // 외부 DB 연결 ID 복원 + if (layout.external_db_connection_id) { + setSelectedDbConnection(layout.external_db_connection_id); } - } catch (error) { - console.error("레이아웃 로드 실패:", error); - const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; - toast({ - variant: "destructive", - title: "오류", - description: errorMessage, - }); - } finally { - setIsLoading(false); - } - }; + // 계층 구조 설정 로드 + if (layout.hierarchy_config) { + try { + // hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용 + const config = + typeof layout.hierarchy_config === "string" + ? JSON.parse(layout.hierarchy_config) + : layout.hierarchy_config; + setHierarchyConfig(config); + + // 선택된 테이블 정보도 복원 + const newSelectedTables: any = { + warehouse: config.warehouse?.tableName || "", + area: "", + location: "", + material: "", + }; + + if (config.levels && config.levels.length > 0) { + // 레벨 1 = Area + if (config.levels[0]?.tableName) { + newSelectedTables.area = config.levels[0].tableName; + } + // 레벨 2 = Location + if (config.levels[1]?.tableName) { + newSelectedTables.location = config.levels[1].tableName; + } + } + + // 자재 테이블 정보 + if (config.material?.tableName) { + newSelectedTables.material = config.material.tableName; + } + + setSelectedTables(newSelectedTables); + } catch (e) { + console.error("계층 구조 설정 파싱 실패:", e); + } + } + + // 객체 데이터 변환 (DB -> PlacedObject) + const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ + id: obj.id, + type: obj.object_type, + name: obj.object_name, + position: { + x: parseFloat(obj.position_x), + y: parseFloat(obj.position_y), + z: parseFloat(obj.position_z), + }, + size: { + x: parseFloat(obj.size_x), + y: parseFloat(obj.size_y), + z: parseFloat(obj.size_z), + }, + rotation: obj.rotation ? parseFloat(obj.rotation) : 0, + color: obj.color, + areaKey: obj.area_key, + locaKey: obj.loca_key, + locType: obj.loc_type, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, + parentId: obj.parent_id, + displayOrder: obj.display_order, + locked: obj.locked, + visible: obj.visible !== false, + hierarchyLevel: obj.hierarchy_level || 1, + parentKey: obj.parent_key, + externalKey: obj.external_key, + })); + + setPlacedObjects(loadedObjects); + + // 다음 임시 ID 설정 (기존 ID 중 최소값 - 1) + const minId = Math.min(...loadedObjects.map((o) => o.id), 0); + setNextObjectId(minId - 1); + + setHasUnsavedChanges(false); + + toast({ + title: "레이아웃 불러오기 완료", + description: `${loadedObjects.length}개의 객체를 불러왔습니다.`, + }); + + // 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" || + obj.type === "location-stp" || + obj.type === "location-temp" || + obj.type === "location-dest") && + obj.locaKey, + ); + if (locationObjects.length > 0 && dbConnectionId && materialTableName) { + const locaKeys = locationObjects.map((obj) => obj.locaKey!); + setTimeout(() => { + loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName); + }, 100); + } + } else { + throw new Error(response.error || "레이아웃 조회 실패"); + } + } catch (error) { + console.error("레이아웃 로드 실패:", error); + const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; + toast({ + variant: "destructive", + title: "오류", + description: errorMessage, + }); + } finally { + setIsLoading(false); + } + }; + + // 위젯 새로고침 핸들러 + const handleRefresh = async () => { + if (hasUnsavedChanges) { + const confirmed = window.confirm( + "저장되지 않은 변경사항이 있습니다. 새로고침하면 변경사항이 사라집니다. 계속하시겠습니까?", + ); + if (!confirmed) return; + } + setIsRefreshing(true); + setSelectedObject(null); + setMaterials([]); + await loadLayout(); + setIsRefreshing(false); + toast({ + title: "새로고침 완료", + description: "데이터가 갱신되었습니다.", + }); + }; + + // 초기 로드 + useEffect(() => { loadLayout(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layoutId]); // toast 제거 + }, [layoutId]); // 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시) useEffect(() => { @@ -1052,7 +1084,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi }; // Location별 자재 개수 로드 (locaKeys를 직접 받음) - const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => { + const loadMaterialCountsForLocations = async ( + locaKeys: string[], + dbConnectionId?: number, + materialTableName?: string, + ) => { const connectionId = dbConnectionId || selectedDbConnection; const tableName = materialTableName || selectedTables.material; if (!connectionId || locaKeys.length === 0) return; @@ -1060,7 +1096,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi try { const response = await getMaterialCounts(connectionId, tableName, locaKeys); console.log("📊 자재 개수 API 응답:", response); - + if (response.success && response.data) { // 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외) setPlacedObjects((prev) => @@ -1073,10 +1109,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi } // 백엔드 응답 필드명: location_key, count (대소문자 모두 체크) const materialCount = response.data?.find( - (mc: any) => - mc.LOCAKEY === obj.locaKey || - mc.location_key === obj.locaKey || - mc.locakey === obj.locaKey + (mc: any) => mc.LOCAKEY === obj.locaKey || mc.location_key === obj.locaKey || mc.locakey === obj.locaKey, ); if (materialCount) { // count 또는 material_count 필드 사용 @@ -1527,6 +1560,16 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
{hasUnsavedChanges && 미저장 변경사항 있음} +
- setSelectedTemplateId(val)}> {mappingTemplates.length === 0 ? ( -
- 사용 가능한 템플릿이 없습니다 -
+
사용 가능한 템플릿이 없습니다
) : ( mappingTemplates.map((tpl) => (
{tpl.name} {tpl.description && ( - - {tpl.description} - + {tpl.description} )}
@@ -1704,17 +1740,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi }} onLoadColumns={async (tableName: string) => { try { - const response = await ExternalDbConnectionAPI.getTableColumns( - selectedDbConnection, - tableName, - ); + const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName); if (response.success && response.data) { // 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그) return response.data.map((col: any) => ({ - column_name: - typeof col === "string" - ? col - : col.column_name || col.COLUMN_NAME || String(col), + column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col), data_type: col.data_type || col.DATA_TYPE, description: col.description || col.COLUMN_COMMENT || undefined, is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY, @@ -2354,10 +2384,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi > 취소 - diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 91804987..71462ebe 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useMemo } from "react"; -import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react"; +import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; @@ -41,130 +41,144 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) // 검색 및 필터 const [searchQuery, setSearchQuery] = useState(""); const [filterType, setFilterType] = useState("all"); + const [isRefreshing, setIsRefreshing] = useState(false); - // 레이아웃 데이터 로드 - useEffect(() => { - const loadLayout = async () => { - try { - setIsLoading(true); - const response = await getLayoutById(layoutId); + // 레이아웃 데이터 로드 함수 + const loadLayout = async () => { + try { + setIsLoading(true); + const response = await getLayoutById(layoutId); - if (response.success && response.data) { - const { layout, objects } = response.data; + if (response.success && response.data) { + const { layout, objects } = response.data; - // 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원) - setLayoutName(layout.layout_name || layout.layoutName); - const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId; - setExternalDbConnectionId(dbConnectionId); + // 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원) + setLayoutName(layout.layout_name || layout.layoutName); + const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId; + setExternalDbConnectionId(dbConnectionId); - // hierarchy_config 저장 - let hierarchyConfigData: any = null; - if (layout.hierarchy_config) { - hierarchyConfigData = - typeof layout.hierarchy_config === "string" - ? JSON.parse(layout.hierarchy_config) - : layout.hierarchy_config; - setHierarchyConfig(hierarchyConfigData); - } + // hierarchy_config 저장 + let hierarchyConfigData: any = null; + if (layout.hierarchy_config) { + hierarchyConfigData = + typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config; + setHierarchyConfig(hierarchyConfigData); + } - // 객체 데이터 변환 - const loadedObjects: PlacedObject[] = objects.map((obj: any) => { - const objectType = obj.object_type; - return { - id: obj.id, - type: objectType, - name: obj.object_name, - position: { - x: parseFloat(obj.position_x), - y: parseFloat(obj.position_y), - z: parseFloat(obj.position_z), - }, - size: { - x: parseFloat(obj.size_x), - y: parseFloat(obj.size_y), - z: parseFloat(obj.size_z), - }, - rotation: obj.rotation ? parseFloat(obj.rotation) : 0, - color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상 - areaKey: obj.area_key, - locaKey: obj.loca_key, - locType: obj.loc_type, - materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, - materialPreview: - obj.loc_type === "STP" || !obj.material_preview_height - ? undefined - : { height: parseFloat(obj.material_preview_height) }, - parentId: obj.parent_id, - displayOrder: obj.display_order, - locked: obj.locked, - visible: obj.visible !== false, - hierarchyLevel: obj.hierarchy_level, - parentKey: obj.parent_key, - externalKey: obj.external_key, - }; + // 객체 데이터 변환 + const loadedObjects: PlacedObject[] = objects.map((obj: any) => { + const objectType = obj.object_type; + return { + id: obj.id, + type: objectType, + name: obj.object_name, + position: { + x: parseFloat(obj.position_x), + y: parseFloat(obj.position_y), + z: parseFloat(obj.position_z), + }, + size: { + x: parseFloat(obj.size_x), + y: parseFloat(obj.size_y), + z: parseFloat(obj.size_z), + }, + rotation: obj.rotation ? parseFloat(obj.rotation) : 0, + color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상 + areaKey: obj.area_key, + locaKey: obj.loca_key, + locType: obj.loc_type, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, + parentId: obj.parent_id, + displayOrder: obj.display_order, + locked: obj.locked, + visible: obj.visible !== false, + hierarchyLevel: obj.hierarchy_level, + parentKey: obj.parent_key, + externalKey: obj.external_key, + }; + }); + + 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 }; }); - setPlacedObjects(loadedObjects); + const materialCounts = await Promise.all(materialCountPromises); - // 외부 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); + // 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 { 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 || "레이아웃 조회 실패"); + return obj; + }), + ); } - } catch (error) { - console.error("레이아웃 로드 실패:", error); - const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; - toast({ - variant: "destructive", - title: "오류", - description: errorMessage, - }); - } finally { - setIsLoading(false); + } else { + throw new Error(response.error || "레이아웃 조회 실패"); } - }; + } catch (error) { + console.error("레이아웃 로드 실패:", error); + const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; + toast({ + variant: "destructive", + title: "오류", + description: errorMessage, + }); + } finally { + setIsLoading(false); + } + }; + // 위젯 새로고침 핸들러 + const handleRefresh = async () => { + setIsRefreshing(true); + setSelectedObject(null); + setMaterials([]); + setShowInfoPanel(false); + await loadLayout(); + setIsRefreshing(false); + toast({ + title: "새로고침 완료", + description: "데이터가 갱신되었습니다.", + }); + }; + + // 초기 로드 + useEffect(() => { loadLayout(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layoutId]); // toast 제거 - 무한 루프 방지 + }, [layoutId]); // Location의 자재 목록 로드 const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { @@ -322,6 +336,16 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)

{layoutName || "디지털 트윈 야드"}

읽기 전용 뷰

+ {/* 메인 영역 */} @@ -404,59 +428,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) // Area가 없으면 기존 평면 리스트 유지 if (areaObjects.length === 0) { return ( -
- {filteredObjects.map((obj) => { - let typeLabel = obj.type; - if (obj.type === "location-bed") typeLabel = "베드(BED)"; - else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; - else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; - else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; - else if (obj.type === "crane-mobile") typeLabel = "크레인"; - else if (obj.type === "area") typeLabel = "Area"; - else if (obj.type === "rack") typeLabel = "랙"; +
+ {filteredObjects.map((obj) => { + let typeLabel = obj.type; + if (obj.type === "location-bed") typeLabel = "베드(BED)"; + else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; + else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; + else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; + else if (obj.type === "crane-mobile") typeLabel = "크레인"; + else if (obj.type === "area") typeLabel = "Area"; + else if (obj.type === "rack") typeLabel = "랙"; - return ( -
handleObjectClick(obj.id)} - className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ - selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" - }`} - > -
-
-

{obj.name}

-
- - {typeLabel} + return ( +
handleObjectClick(obj.id)} + className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ + selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" + }`} + > +
+
+

{obj.name}

+
+ + {typeLabel} +
+
+
+
+ {obj.areaKey && ( +

+ Area: {obj.areaKey} +

+ )} + {obj.locaKey && ( +

+ Location: {obj.locaKey} +

+ )} + {obj.materialCount !== undefined && obj.materialCount > 0 && ( +

+ 자재: {obj.materialCount}개 +

+ )} +
-
-
-
- {obj.areaKey && ( -

- Area: {obj.areaKey} -

- )} - {obj.locaKey && ( -

- Location: {obj.locaKey} -

- )} - {obj.materialCount !== undefined && obj.materialCount > 0 && ( -

- 자재: {obj.materialCount}개 -

- )} -
+ ); + })}
); - })} -
- ); } // Area가 있는 경우: Area → Location 계층 아코디언 @@ -525,8 +549,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) />

- 위치: ({locationObj.position.x.toFixed(1)},{" "} - {locationObj.position.z.toFixed(1)}) + 위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})

{locationObj.locaKey && (

diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx index bc6b3299..d1303d10 100644 --- a/frontend/components/dashboard/widgets/ListTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx @@ -180,9 +180,19 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { switch (format) { case "date": - return new Date(value).toLocaleDateString("ko-KR"); + try { + const dateVal = new Date(value); + return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" }); + } catch { + return String(value); + } case "datetime": - return new Date(value).toLocaleString("ko-KR"); + try { + const dateVal = new Date(value); + return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" }); + } catch { + return String(value); + } case "number": return Number(value).toLocaleString("ko-KR"); case "currency": diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index b3c9e2fb..151c7eff 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -203,14 +203,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { setTripInfoLoading(identifier); try { - // user_id 또는 vehicle_number로 조회 (시간은 KST로 변환) + // user_id 또는 vehicle_number로 조회 (TIMESTAMPTZ는 변환 불필요) const query = `SELECT id, vehicle_number, user_id, - (last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start, - (last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end, + last_trip_start, + last_trip_end, last_trip_distance, last_trip_time, - (last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start, - (last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end, + last_empty_start, + last_empty_end, last_empty_distance, last_empty_time, departure, arrival, status FROM vehicles @@ -281,15 +281,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { if (identifiers.length === 0) return; try { - // 모든 마커의 운행/공차 정보를 한 번에 조회 (시간은 KST로 변환) + // 모든 마커의 운행/공차 정보를 한 번에 조회 (TIMESTAMPTZ는 변환 불필요) const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", "); const query = `SELECT id, vehicle_number, user_id, - (last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start, - (last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end, + last_trip_start, + last_trip_end, last_trip_distance, last_trip_time, - (last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start, - (last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end, + last_empty_start, + last_empty_end, last_empty_distance, last_empty_time, departure, arrival, status FROM vehicles diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index b722e31c..651675b8 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1506,6 +1506,7 @@ export const TableListComponent: React.FC = ({ tableName: tableConfig.selectedTable, selectedLeftData: splitPanelContext?.selectedLeftData, linkedFilters: splitPanelContext?.linkedFilters, + splitPanelPosition: splitPanelPosition, }); if (splitPanelContext) { @@ -1537,6 +1538,39 @@ export const TableListComponent: React.FC = ({ linkedFilterValues[key] = value; } } + + // 🆕 자동 컬럼 매칭: linkedFilters가 설정되어 있지 않아도 + // 우측 화면(splitPanelPosition === "right")이고 좌측 데이터가 선택되어 있으면 + // 동일한 컬럼명이 있는 경우 자동으로 필터링 적용 + if ( + splitPanelPosition === "right" && + hasSelectedLeftData && + Object.keys(linkedFilterValues).length === 0 && + !hasLinkedFiltersConfigured + ) { + const leftData = splitPanelContext.selectedLeftData!; + const tableColumns = (tableConfig.columns || []).map((col) => col.columnName); + + // 좌측 데이터의 컬럼 중 현재 테이블에 동일한 컬럼이 있는지 확인 + for (const [colName, colValue] of Object.entries(leftData)) { + // null, undefined, 빈 문자열 제외 + if (colValue === null || colValue === undefined || colValue === "") continue; + // id, objid 등 기본 키는 제외 (너무 일반적인 컬럼명) + if (colName === "id" || colName === "objid" || colName === "company_code") continue; + + // 현재 테이블에 동일한 컬럼이 있는지 확인 + if (tableColumns.includes(colName)) { + linkedFilterValues[colName] = colValue; + hasLinkedFiltersConfigured = true; + console.log(`🔗 [TableList] 자동 컬럼 매칭: ${colName} = ${colValue}`); + } + } + + if (Object.keys(linkedFilterValues).length > 0) { + console.log("🔗 [TableList] 자동 컬럼 매칭 필터 적용:", linkedFilterValues); + } + } + if (Object.keys(linkedFilterValues).length > 0) { console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues); } @@ -1749,7 +1783,10 @@ export const TableListComponent: React.FC = ({ searchTerm, searchValues, isDesignMode, - splitPanelContext?.selectedLeftData, // 🆕 연결 필터 변경 시 재조회 + // 🆕 우측 화면일 때만 selectedLeftData 변경에 반응 (좌측 테이블은 재조회 불필요) + splitPanelPosition, + currentSplitPosition, + splitPanelContext?.selectedLeftData, ]); const fetchTableDataDebounced = useCallback( @@ -2059,7 +2096,18 @@ export const TableListComponent: React.FC = ({ // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) - if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { + // currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음) + const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; + + console.log("🔗 [TableList] 행 클릭 - 분할 패널 위치 확인:", { + splitPanelPosition, + currentSplitPosition, + effectiveSplitPosition, + hasSplitPanelContext: !!splitPanelContext, + disableAutoDataTransfer: splitPanelContext?.disableAutoDataTransfer, + }); + + if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { if (!isCurrentlySelected) { // 선택된 경우: 데이터 저장 splitPanelContext.setSelectedLeftData(row); @@ -2077,12 +2125,57 @@ export const TableListComponent: React.FC = ({ console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected }); }; - // 🆕 셀 클릭 핸들러 (포커스 설정) + // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택) const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { e.stopPropagation(); setFocusedCell({ rowIndex, colIndex }); // 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용) tableContainerRef.current?.focus(); + + // 🆕 분할 패널 내에서 셀 클릭 시에도 해당 행 선택 처리 + // filteredData에서 해당 행의 데이터 가져오기 + const row = filteredData[rowIndex]; + if (!row) return; + + const rowKey = getRowKey(row, rowIndex); + const isCurrentlySelected = selectedRows.has(rowKey); + + // 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달 + const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; + + console.log("🔗 [TableList] 셀 클릭 - 분할 패널 위치 확인:", { + rowIndex, + colIndex, + splitPanelPosition, + currentSplitPosition, + effectiveSplitPosition, + hasSplitPanelContext: !!splitPanelContext, + isCurrentlySelected, + }); + + if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { + // 이미 선택된 행과 다른 행을 클릭한 경우에만 처리 + if (!isCurrentlySelected) { + // 기존 선택 해제하고 새 행 선택 + setSelectedRows(new Set([rowKey])); + setIsAllSelected(false); + + // 분할 패널 컨텍스트에 데이터 저장 + splitPanelContext.setSelectedLeftData(row); + console.log("🔗 [TableList] 셀 클릭으로 분할 패널 좌측 데이터 저장:", { + row, + parentDataMapping: splitPanelContext.parentDataMapping, + }); + + // onSelectedRowsChange 콜백 호출 + if (onSelectedRowsChange) { + onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection); + } + if (onFormDataChange) { + onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] }); + } + } + } }; // 🆕 셀 더블클릭 핸들러 (편집 모드 진입) - visibleColumns 정의 후 사용 @@ -4066,13 +4159,13 @@ export const TableListComponent: React.FC = ({ // 📎 첨부파일 타입: 파일 아이콘과 개수 표시 // 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우 - const isAttachmentColumn = - inputType === "file" || - inputType === "attachment" || + const isAttachmentColumn = + inputType === "file" || + inputType === "attachment" || column.columnName === "attachments" || column.columnName?.toLowerCase().includes("attachment") || column.columnName?.toLowerCase().includes("file"); - + if (isAttachmentColumn) { // JSONB 배열 또는 JSON 문자열 파싱 let files: any[] = []; @@ -4098,21 +4191,14 @@ export const TableListComponent: React.FC = ({ // 파일 이름 표시 (여러 개면 쉼표로 구분) const { Paperclip } = require("lucide-react"); const fileNames = files.map((f: any) => f.realFileName || f.real_file_name || f.name || "파일").join(", "); - + return ( -

- - +
+ + {fileNames} - {files.length > 1 && ( - - ({files.length}) - - )} + {files.length > 1 && ({files.length})}
); } @@ -4677,6 +4763,10 @@ export const TableListComponent: React.FC = ({ fetchTableLabel(); }, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]); + // 🆕 우측 화면일 때만 selectedLeftData 변경에 반응하도록 변수 생성 + const isRightPanel = splitPanelPosition === "right" || currentSplitPosition === "right"; + const selectedLeftDataForRightPanel = isRightPanel ? splitPanelContext?.selectedLeftData : null; + useEffect(() => { // console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", { // isDesignMode, @@ -4700,7 +4790,7 @@ export const TableListComponent: React.FC = ({ refreshKey, refreshTrigger, // 강제 새로고침 트리거 isDesignMode, - splitPanelContext?.selectedLeftData, // 🆕 좌측 데이터 선택 변경 시 데이터 새로고침 + selectedLeftDataForRightPanel, // 🆕 우측 화면일 때만 좌측 데이터 선택 변경 시 데이터 새로고침 // fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지 ]);