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-15 14:51:54 +09:00
commit 6449eb5ac3
6 changed files with 523 additions and 363 deletions

View File

@ -148,9 +148,19 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
switch (format) { switch (format) {
case "date": 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": 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": case "number":
return Number(value).toLocaleString("ko-KR"); return Number(value).toLocaleString("ko-KR");
case "currency": case "currency":

View File

@ -2,7 +2,19 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button"; 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -78,7 +90,7 @@ const DebouncedInput = ({
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => { const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
setIsEditing(false); setIsEditing(false);
if (onCommit && debounce === 0) { if (onCommit && debounce === 0) {
// 값이 변경되었을 때만 커밋하도록 하면 좋겠지만, // 값이 변경되었을 때만 커밋하도록 하면 좋겠지만,
// 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨) // 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨)
onCommit(type === "number" ? parseFloat(localValue as string) : localValue); 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 [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
useEffect(() => { // 레이아웃 로드 함수
const loadLayout = async () => { const loadLayout = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const response = await getLayoutById(layoutId); const response = await getLayoutById(layoutId);
if (response.success && response.data) { if (response.success && response.data) {
const { layout, objects } = response.data; const { layout, objects } = response.data;
setLayoutData({ layout, objects }); // 레이아웃 데이터 저장 setLayoutData({ layout, objects }); // 레이아웃 데이터 저장
// 외부 DB 연결 ID 복원 // 외부 DB 연결 ID 복원
if (layout.external_db_connection_id) { if (layout.external_db_connection_id) {
setSelectedDbConnection(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 || "레이아웃 조회 실패");
} }
} 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(); loadLayout();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId]); // toast 제거 }, [layoutId]);
// 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시) // 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시)
useEffect(() => { useEffect(() => {
@ -1052,7 +1084,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
}; };
// Location별 자재 개수 로드 (locaKeys를 직접 받음) // 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 connectionId = dbConnectionId || selectedDbConnection;
const tableName = materialTableName || selectedTables.material; const tableName = materialTableName || selectedTables.material;
if (!connectionId || locaKeys.length === 0) return; if (!connectionId || locaKeys.length === 0) return;
@ -1060,7 +1096,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
try { try {
const response = await getMaterialCounts(connectionId, tableName, locaKeys); const response = await getMaterialCounts(connectionId, tableName, locaKeys);
console.log("📊 자재 개수 API 응답:", response); console.log("📊 자재 개수 API 응답:", response);
if (response.success && response.data) { if (response.success && response.data) {
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외) // 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
setPlacedObjects((prev) => setPlacedObjects((prev) =>
@ -1073,10 +1109,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
} }
// 백엔드 응답 필드명: location_key, count (대소문자 모두 체크) // 백엔드 응답 필드명: location_key, count (대소문자 모두 체크)
const materialCount = response.data?.find( const materialCount = response.data?.find(
(mc: any) => (mc: any) => mc.LOCAKEY === obj.locaKey || mc.location_key === obj.locaKey || mc.locakey === obj.locaKey,
mc.LOCAKEY === obj.locaKey ||
mc.location_key === obj.locaKey ||
mc.locakey === obj.locaKey
); );
if (materialCount) { if (materialCount) {
// count 또는 material_count 필드 사용 // count 또는 material_count 필드 사용
@ -1527,6 +1560,16 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{hasUnsavedChanges && <span className="text-warning text-sm font-medium"> </span>} {hasUnsavedChanges && <span className="text-warning text-sm font-medium"> </span>}
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing || isLoading}
title="새로고침"
>
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
{isRefreshing ? "갱신 중..." : "새로고침"}
</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasUnsavedChanges}> <Button size="sm" onClick={handleSave} disabled={isSaving || !hasUnsavedChanges}>
{isSaving ? ( {isSaving ? (
<> <>
@ -1620,27 +1663,20 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</Button> </Button>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select <Select value={selectedTemplateId} onValueChange={(val) => setSelectedTemplateId(val)}>
value={selectedTemplateId}
onValueChange={(val) => setSelectedTemplateId(val)}
>
<SelectTrigger className="h-8 flex-1 text-xs"> <SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder={loadingTemplates ? "로딩 중..." : "템플릿 선택..."} /> <SelectValue placeholder={loadingTemplates ? "로딩 중..." : "템플릿 선택..."} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{mappingTemplates.length === 0 ? ( {mappingTemplates.length === 0 ? (
<div className="text-muted-foreground px-2 py-1 text-xs"> <div className="text-muted-foreground px-2 py-1 text-xs"> 릿 </div>
릿
</div>
) : ( ) : (
mappingTemplates.map((tpl) => ( mappingTemplates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id} className="text-xs"> <SelectItem key={tpl.id} value={tpl.id} className="text-xs">
<div className="flex flex-col"> <div className="flex flex-col">
<span>{tpl.name}</span> <span>{tpl.name}</span>
{tpl.description && ( {tpl.description && (
<span className="text-muted-foreground text-[10px]"> <span className="text-muted-foreground text-[10px]">{tpl.description}</span>
{tpl.description}
</span>
)} )}
</div> </div>
</SelectItem> </SelectItem>
@ -1704,17 +1740,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
}} }}
onLoadColumns={async (tableName: string) => { onLoadColumns={async (tableName: string) => {
try { try {
const response = await ExternalDbConnectionAPI.getTableColumns( const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName);
selectedDbConnection,
tableName,
);
if (response.success && response.data) { if (response.success && response.data) {
// 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그) // 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그)
return response.data.map((col: any) => ({ return response.data.map((col: any) => ({
column_name: column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col),
typeof col === "string"
? col
: col.column_name || col.COLUMN_NAME || String(col),
data_type: col.data_type || col.DATA_TYPE, data_type: col.data_type || col.DATA_TYPE,
description: col.description || col.COLUMN_COMMENT || undefined, description: col.description || col.COLUMN_COMMENT || undefined,
is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY, is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY,
@ -2354,10 +2384,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
> >
</Button> </Button>
<Button <Button onClick={handleSaveTemplate} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
onClick={handleSaveTemplate}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect, useMemo } from "react"; 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -41,130 +41,144 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// 검색 및 필터 // 검색 및 필터
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [filterType, setFilterType] = useState<string>("all"); const [filterType, setFilterType] = useState<string>("all");
const [isRefreshing, setIsRefreshing] = useState(false);
// 레이아웃 데이터 로드 // 레이아웃 데이터 로드 함수
useEffect(() => { const loadLayout = async () => {
const loadLayout = async () => { try {
try { setIsLoading(true);
setIsLoading(true); const response = await getLayoutById(layoutId);
const response = await getLayoutById(layoutId);
if (response.success && response.data) { if (response.success && response.data) {
const { layout, objects } = response.data; const { layout, objects } = response.data;
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원) // 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
setLayoutName(layout.layout_name || layout.layoutName); setLayoutName(layout.layout_name || layout.layoutName);
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId; const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
setExternalDbConnectionId(dbConnectionId); setExternalDbConnectionId(dbConnectionId);
// hierarchy_config 저장 // hierarchy_config 저장
let hierarchyConfigData: any = null; let hierarchyConfigData: any = null;
if (layout.hierarchy_config) { if (layout.hierarchy_config) {
hierarchyConfigData = hierarchyConfigData =
typeof layout.hierarchy_config === "string" typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config;
? JSON.parse(layout.hierarchy_config) setHierarchyConfig(hierarchyConfigData);
: layout.hierarchy_config; }
setHierarchyConfig(hierarchyConfigData);
}
// 객체 데이터 변환 // 객체 데이터 변환
const loadedObjects: PlacedObject[] = objects.map((obj: any) => { const loadedObjects: PlacedObject[] = objects.map((obj: any) => {
const objectType = obj.object_type; const objectType = obj.object_type;
return { return {
id: obj.id, id: obj.id,
type: objectType, type: objectType,
name: obj.object_name, name: obj.object_name,
position: { position: {
x: parseFloat(obj.position_x), x: parseFloat(obj.position_x),
y: parseFloat(obj.position_y), y: parseFloat(obj.position_y),
z: parseFloat(obj.position_z), z: parseFloat(obj.position_z),
}, },
size: { size: {
x: parseFloat(obj.size_x), x: parseFloat(obj.size_x),
y: parseFloat(obj.size_y), y: parseFloat(obj.size_y),
z: parseFloat(obj.size_z), z: parseFloat(obj.size_z),
}, },
rotation: obj.rotation ? parseFloat(obj.rotation) : 0, rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상 color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상
areaKey: obj.area_key, areaKey: obj.area_key,
locaKey: obj.loca_key, locaKey: obj.loca_key,
locType: obj.loc_type, locType: obj.loc_type,
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview: materialPreview:
obj.loc_type === "STP" || !obj.material_preview_height obj.loc_type === "STP" || !obj.material_preview_height
? undefined ? undefined
: { height: parseFloat(obj.material_preview_height) }, : { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id, parentId: obj.parent_id,
displayOrder: obj.display_order, displayOrder: obj.display_order,
locked: obj.locked, locked: obj.locked,
visible: obj.visible !== false, visible: obj.visible !== false,
hierarchyLevel: obj.hierarchy_level, hierarchyLevel: obj.hierarchy_level,
parentKey: obj.parent_key, parentKey: obj.parent_key,
externalKey: obj.external_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의 실제 자재 개수 조회 // materialCount 업데이트
if (dbConnectionId && hierarchyConfigData?.material) { setPlacedObjects((prev) =>
const locationObjects = loadedObjects.filter( prev.map((obj) => {
(obj) => const countData = materialCounts.find((m) => m.id === obj.id);
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") && if (countData && countData.count > 0) {
obj.locaKey return { ...obj, materialCount: countData.count };
);
// 각 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 }; return obj;
}); }),
);
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 || "레이아웃 조회 실패");
} }
} catch (error) { } else {
console.error("레이아웃 로드 실패:", error); throw new Error(response.error || "레이아웃 조회 실패");
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
toast({
variant: "destructive",
title: "오류",
description: errorMessage,
});
} finally {
setIsLoading(false);
} }
}; } 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(); loadLayout();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId]); // toast 제거 - 무한 루프 방지 }, [layoutId]);
// Location의 자재 목록 로드 // Location의 자재 목록 로드
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
@ -322,6 +336,16 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2> <h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
<p className="text-muted-foreground text-sm"> </p> <p className="text-muted-foreground text-sm"> </p>
</div> </div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing || isLoading}
title="새로고침"
>
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
{isRefreshing ? "갱신 중..." : "새로고침"}
</Button>
</div> </div>
{/* 메인 영역 */} {/* 메인 영역 */}
@ -404,59 +428,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// Area가 없으면 기존 평면 리스트 유지 // Area가 없으면 기존 평면 리스트 유지
if (areaObjects.length === 0) { if (areaObjects.length === 0) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{filteredObjects.map((obj) => { {filteredObjects.map((obj) => {
let typeLabel = obj.type; let typeLabel = obj.type;
if (obj.type === "location-bed") typeLabel = "베드(BED)"; if (obj.type === "location-bed") typeLabel = "베드(BED)";
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
else if (obj.type === "crane-mobile") typeLabel = "크레인"; else if (obj.type === "crane-mobile") typeLabel = "크레인";
else if (obj.type === "area") typeLabel = "Area"; else if (obj.type === "area") typeLabel = "Area";
else if (obj.type === "rack") typeLabel = "랙"; else if (obj.type === "rack") typeLabel = "랙";
return ( return (
<div <div
key={obj.id} key={obj.id}
onClick={() => handleObjectClick(obj.id)} onClick={() => handleObjectClick(obj.id)}
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ 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" selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
}`} }`}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium">{obj.name}</p> <p className="text-sm font-medium">{obj.name}</p>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs"> <div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span <span
className="inline-block h-2 w-2 rounded-full" className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: obj.color }} style={{ backgroundColor: obj.color }}
/> />
<span>{typeLabel}</span> <span>{typeLabel}</span>
</div>
</div>
</div>
<div className="mt-2 space-y-1">
{obj.areaKey && (
<p className="text-muted-foreground text-xs">
Area: <span className="font-medium">{obj.areaKey}</span>
</p>
)}
{obj.locaKey && (
<p className="text-muted-foreground text-xs">
Location: <span className="font-medium">{obj.locaKey}</span>
</p>
)}
{obj.materialCount !== undefined && obj.materialCount > 0 && (
<p className="text-xs text-yellow-600">
: <span className="font-semibold">{obj.materialCount}</span>
</p>
)}
</div>
</div> </div>
</div> );
</div> })}
<div className="mt-2 space-y-1">
{obj.areaKey && (
<p className="text-muted-foreground text-xs">
Area: <span className="font-medium">{obj.areaKey}</span>
</p>
)}
{obj.locaKey && (
<p className="text-muted-foreground text-xs">
Location: <span className="font-medium">{obj.locaKey}</span>
</p>
)}
{obj.materialCount !== undefined && obj.materialCount > 0 && (
<p className="text-xs text-yellow-600">
: <span className="font-semibold">{obj.materialCount}</span>
</p>
)}
</div>
</div> </div>
); );
})}
</div>
);
} }
// Area가 있는 경우: Area → Location 계층 아코디언 // Area가 있는 경우: Area → Location 계층 아코디언
@ -525,8 +549,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
/> />
</div> </div>
<p className="text-muted-foreground mt-1 text-[10px]"> <p className="text-muted-foreground mt-1 text-[10px]">
: ({locationObj.position.x.toFixed(1)},{" "} : ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})
{locationObj.position.z.toFixed(1)})
</p> </p>
{locationObj.locaKey && ( {locationObj.locaKey && (
<p className="text-muted-foreground mt-0.5 text-[10px]"> <p className="text-muted-foreground mt-0.5 text-[10px]">

View File

@ -180,9 +180,19 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
switch (format) { switch (format) {
case "date": 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": 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": case "number":
return Number(value).toLocaleString("ko-KR"); return Number(value).toLocaleString("ko-KR");
case "currency": case "currency":

View File

@ -203,14 +203,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
setTripInfoLoading(identifier); setTripInfoLoading(identifier);
try { try {
// user_id 또는 vehicle_number로 조회 (시간은 KST로 변환) // user_id 또는 vehicle_number로 조회 (TIMESTAMPTZ는 변환 불필요)
const query = `SELECT const query = `SELECT
id, vehicle_number, user_id, id, vehicle_number, user_id,
(last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start, last_trip_start,
(last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end, last_trip_end,
last_trip_distance, last_trip_time, last_trip_distance, last_trip_time,
(last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start, last_empty_start,
(last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end, last_empty_end,
last_empty_distance, last_empty_time, last_empty_distance, last_empty_time,
departure, arrival, status departure, arrival, status
FROM vehicles FROM vehicles
@ -281,15 +281,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
if (identifiers.length === 0) return; if (identifiers.length === 0) return;
try { try {
// 모든 마커의 운행/공차 정보를 한 번에 조회 (시간은 KST로 변환) // 모든 마커의 운행/공차 정보를 한 번에 조회 (TIMESTAMPTZ는 변환 불필요)
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", "); const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
const query = `SELECT const query = `SELECT
id, vehicle_number, user_id, id, vehicle_number, user_id,
(last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start, last_trip_start,
(last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end, last_trip_end,
last_trip_distance, last_trip_time, last_trip_distance, last_trip_time,
(last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start, last_empty_start,
(last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end, last_empty_end,
last_empty_distance, last_empty_time, last_empty_distance, last_empty_time,
departure, arrival, status departure, arrival, status
FROM vehicles FROM vehicles

View File

@ -1506,6 +1506,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
tableName: tableConfig.selectedTable, tableName: tableConfig.selectedTable,
selectedLeftData: splitPanelContext?.selectedLeftData, selectedLeftData: splitPanelContext?.selectedLeftData,
linkedFilters: splitPanelContext?.linkedFilters, linkedFilters: splitPanelContext?.linkedFilters,
splitPanelPosition: splitPanelPosition,
}); });
if (splitPanelContext) { if (splitPanelContext) {
@ -1537,6 +1538,39 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
linkedFilterValues[key] = value; 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) { if (Object.keys(linkedFilterValues).length > 0) {
console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues); console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues);
} }
@ -1749,7 +1783,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
searchTerm, searchTerm,
searchValues, searchValues,
isDesignMode, isDesignMode,
splitPanelContext?.selectedLeftData, // 🆕 연결 필터 변경 시 재조회 // 🆕 우측 화면일 때만 selectedLeftData 변경에 반응 (좌측 테이블은 재조회 불필요)
splitPanelPosition,
currentSplitPosition,
splitPanelContext?.selectedLeftData,
]); ]);
const fetchTableDataDebounced = useCallback( const fetchTableDataDebounced = useCallback(
@ -2059,7 +2096,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) // 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) { if (!isCurrentlySelected) {
// 선택된 경우: 데이터 저장 // 선택된 경우: 데이터 저장
splitPanelContext.setSelectedLeftData(row); splitPanelContext.setSelectedLeftData(row);
@ -2077,12 +2125,57 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected }); console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
}; };
// 🆕 셀 클릭 핸들러 (포커스 설정) // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택)
const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setFocusedCell({ rowIndex, colIndex }); setFocusedCell({ rowIndex, colIndex });
// 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용) // 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용)
tableContainerRef.current?.focus(); 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 정의 후 사용 // 🆕 셀 더블클릭 핸들러 (편집 모드 진입) - visibleColumns 정의 후 사용
@ -4066,13 +4159,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시 // 📎 첨부파일 타입: 파일 아이콘과 개수 표시
// 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우 // 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우
const isAttachmentColumn = const isAttachmentColumn =
inputType === "file" || inputType === "file" ||
inputType === "attachment" || inputType === "attachment" ||
column.columnName === "attachments" || column.columnName === "attachments" ||
column.columnName?.toLowerCase().includes("attachment") || column.columnName?.toLowerCase().includes("attachment") ||
column.columnName?.toLowerCase().includes("file"); column.columnName?.toLowerCase().includes("file");
if (isAttachmentColumn) { if (isAttachmentColumn) {
// JSONB 배열 또는 JSON 문자열 파싱 // JSONB 배열 또는 JSON 문자열 파싱
let files: any[] = []; let files: any[] = [];
@ -4098,21 +4191,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 파일 이름 표시 (여러 개면 쉼표로 구분) // 파일 이름 표시 (여러 개면 쉼표로 구분)
const { Paperclip } = require("lucide-react"); const { Paperclip } = require("lucide-react");
const fileNames = files.map((f: any) => f.realFileName || f.real_file_name || f.name || "파일").join(", "); const fileNames = files.map((f: any) => f.realFileName || f.real_file_name || f.name || "파일").join(", ");
return ( return (
<div className="flex items-center gap-1.5 text-sm max-w-full"> <div className="flex max-w-full items-center gap-1.5 text-sm">
<Paperclip className="h-4 w-4 text-gray-500 flex-shrink-0" /> <Paperclip className="h-4 w-4 flex-shrink-0 text-gray-500" />
<span <span className="truncate text-blue-600" title={fileNames}>
className="text-blue-600 truncate"
title={fileNames}
>
{fileNames} {fileNames}
</span> </span>
{files.length > 1 && ( {files.length > 1 && <span className="text-muted-foreground flex-shrink-0 text-xs">({files.length})</span>}
<span className="text-muted-foreground text-xs flex-shrink-0">
({files.length})
</span>
)}
</div> </div>
); );
} }
@ -4677,6 +4763,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
fetchTableLabel(); fetchTableLabel();
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]); }, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
// 🆕 우측 화면일 때만 selectedLeftData 변경에 반응하도록 변수 생성
const isRightPanel = splitPanelPosition === "right" || currentSplitPosition === "right";
const selectedLeftDataForRightPanel = isRightPanel ? splitPanelContext?.selectedLeftData : null;
useEffect(() => { useEffect(() => {
// console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", { // console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
// isDesignMode, // isDesignMode,
@ -4700,7 +4790,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
refreshKey, refreshKey,
refreshTrigger, // 강제 새로고침 트리거 refreshTrigger, // 강제 새로고침 트리거
isDesignMode, isDesignMode,
splitPanelContext?.selectedLeftData, // 🆕 좌측 데이터 선택 변경 시 데이터 새로고침 selectedLeftDataForRightPanel, // 🆕 우측 화면일 때만 좌측 데이터 선택 변경 시 데이터 새로고침
// fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지 // fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지
]); ]);