feature/v2-unified-renewal #379

Merged
kjs merged 145 commits from feature/v2-unified-renewal into main 2026-02-03 12:11:19 +09:00
7 changed files with 1436 additions and 640 deletions
Showing only changes of commit 9597494685 - Show all commits

View File

@ -22,6 +22,15 @@ const router = Router();
// 모든 role 라우트에 인증 미들웨어 적용 // 모든 role 라우트에 인증 미들웨어 적용
router.use(authenticateToken); router.use(authenticateToken);
/**
* (/:id )
*/
// 현재 사용자가 속한 권한 그룹 조회
router.get("/user/my-groups", getUserRoleGroups);
// 특정 사용자가 속한 권한 그룹 조회
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
/** /**
* CRUD * CRUD
*/ */
@ -67,13 +76,4 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions);
// 메뉴 권한 설정 // 메뉴 권한 설정
router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions); router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions);
/**
*
*/
// 현재 사용자가 속한 권한 그룹 조회
router.get("/user/my-groups", getUserRoleGroups);
// 특정 사용자가 속한 권한 그룹 조회
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
export default router; export default router;

File diff suppressed because it is too large Load Diff

View File

@ -916,7 +916,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "weather" ? ( ) : element.type === "widget" && element.subtype === "weather" ? (
// 날씨 위젯 렌더링 // 날씨 위젯 렌더링
<div className="widget-interactive-area h-full w-full"> <div className="widget-interactive-area h-full w-full">
<WeatherWidget city="서울" refreshInterval={600000} /> <WeatherWidget element={element} city="서울" refreshInterval={600000} />
</div> </div>
) : element.type === "widget" && element.subtype === "exchange" ? ( ) : element.type === "widget" && element.subtype === "exchange" ? (
// 환율 위젯 렌더링 // 환율 위젯 렌더링

View File

@ -2146,9 +2146,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Table> <Table>
<TableHeader className="bg-muted sticky top-0"> <TableHeader className="bg-muted sticky top-0">
<TableRow> <TableRow>
<TableHead className="w-[60px] text-xs"></TableHead> <TableHead className="w-[70px] whitespace-nowrap px-3 py-3 text-sm"></TableHead>
{(hierarchyConfig?.material?.displayColumns || []).map((col) => ( {(hierarchyConfig?.material?.displayColumns || []).map((col) => (
<TableHead key={col.column} className="text-xs"> <TableHead key={col.column} className="px-3 py-3 text-sm">
{col.label} {col.label}
</TableHead> </TableHead>
))} ))}
@ -2163,9 +2163,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
return ( return (
<TableRow key={material[keyColumn] || `material-${index}`}> <TableRow key={material[keyColumn] || `material-${index}`}>
<TableCell className="text-xs font-medium">{layerNumber}</TableCell> <TableCell className="whitespace-nowrap px-3 py-3 text-sm font-medium">{layerNumber}</TableCell>
{displayColumns.map((col) => ( {displayColumns.map((col) => (
<TableCell key={col.column} className="text-xs"> <TableCell key={col.column} className="px-3 py-3 text-sm">
{material[col.column] || "-"} {material[col.column] || "-"}
</TableCell> </TableCell>
))} ))}

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo, useRef } from "react";
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react"; import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw, Maximize, Minimize } 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";
@ -12,6 +12,7 @@ import type { PlacedObject, MaterialData } from "@/types/digitalTwin";
import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin"; import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin";
import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants"; import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { apiCall } from "@/lib/api/client";
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
ssr: false, ssr: false,
@ -26,6 +27,9 @@ interface DigitalTwinViewerProps {
layoutId: number; layoutId: number;
} }
// 외부 업체 역할 코드
const EXTERNAL_VENDOR_ROLE = "LSTHIRA_EXTERNAL_VENDOR";
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) { export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
const { toast } = useToast(); const { toast } = useToast();
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]); const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
@ -43,6 +47,73 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
const [filterType, setFilterType] = useState<string>("all"); const [filterType, setFilterType] = useState<string>("all");
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
// 외부 업체 모드
const [isExternalMode, setIsExternalMode] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [layoutKey, setLayoutKey] = useState(0); // 레이아웃 강제 리렌더링용
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null); // 마지막 갱신 시간
const canvasContainerRef = useRef<HTMLDivElement>(null);
// 외부 업체 역할 체크
useEffect(() => {
const checkExternalRole = async () => {
try {
const response = await apiCall<any[]>("GET", "/roles/user/my-groups");
console.log("=== 사용자 권한 그룹 조회 ===");
console.log("API 응답:", response);
console.log("찾는 역할:", EXTERNAL_VENDOR_ROLE);
if (response.success && response.data) {
console.log("권한 그룹 목록:", response.data);
// 사용자의 권한 그룹 중 LSTHIRA_EXTERNAL_VENDOR가 있는지 확인
const hasExternalRole = response.data.some((group: any) => {
console.log("체크 중인 그룹:", group.authCode, group.authName);
return group.authCode === EXTERNAL_VENDOR_ROLE || group.authName === EXTERNAL_VENDOR_ROLE;
});
console.log("외부 업체 역할 보유:", hasExternalRole);
setIsExternalMode(hasExternalRole);
}
} catch (error) {
console.error("역할 조회 실패:", error);
}
};
checkExternalRole();
}, []);
// 전체 화면 토글 (3D 캔버스 영역만)
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
// 3D 캔버스 컨테이너만 풀스크린
canvasContainerRef.current?.requestFullscreen();
setIsFullscreen(true);
} else {
document.exitFullscreen();
setIsFullscreen(false);
}
};
// 전체 화면 변경 감지
useEffect(() => {
const handleFullscreenChange = () => {
const isNowFullscreen = !!document.fullscreenElement;
setIsFullscreen(isNowFullscreen);
// 전체화면 종료 시 레이아웃 강제 리렌더링
if (!isNowFullscreen) {
setTimeout(() => {
setLayoutKey((prev) => prev + 1);
window.dispatchEvent(new Event("resize"));
}, 50);
}
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
// 레이아웃 데이터 로드 함수 // 레이아웃 데이터 로드 함수
const loadLayout = async () => { const loadLayout = async () => {
try { try {
@ -144,6 +215,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
}), }),
); );
} }
// 마지막 갱신 시간 기록
setLastRefreshedAt(new Date());
} else { } else {
throw new Error(response.error || "레이아웃 조회 실패"); throw new Error(response.error || "레이아웃 조회 실패");
} }
@ -180,6 +253,155 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId]); }, [layoutId]);
// 10초 주기 자동 갱신 (중앙 관제 화면 자동 새로고침)
useEffect(() => {
const AUTO_REFRESH_INTERVAL = 10000; // 10초
const silentRefresh = async () => {
// 로딩 중이거나 새로고침 중이면 스킵
if (isLoading || isRefreshing) return;
try {
// 레이아웃 데이터 조용히 갱신
const response = await getLayoutById(layoutId);
if (response.success && response.data) {
const { layout, objects } = response.data;
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
// 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,
};
});
// 외부 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 {
// 자동 갱신 시에는 에러 로그 생략
}
return { id: obj.id, count: 0 };
});
const materialCounts = await Promise.all(materialCountPromises);
// materialCount 업데이트
const updatedObjects = loadedObjects.map((obj) => {
const countData = materialCounts.find((m) => m.id === obj.id);
if (countData && countData.count > 0) {
return { ...obj, materialCount: countData.count };
}
return obj;
});
setPlacedObjects(updatedObjects);
} else {
setPlacedObjects(loadedObjects);
}
// 선택된 객체가 있으면 자재 목록도 갱신
if (selectedObject && dbConnectionId && hierarchyConfigData?.material) {
const currentObj = loadedObjects.find((o) => o.id === selectedObject.id);
if (
currentObj &&
(currentObj.type === "location-bed" ||
currentObj.type === "location-temp" ||
currentObj.type === "location-dest") &&
currentObj.locaKey
) {
const matResponse = await getMaterials(dbConnectionId, {
tableName: hierarchyConfigData.material.tableName,
keyColumn: hierarchyConfigData.material.keyColumn,
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
layerColumn: hierarchyConfigData.material.layerColumn,
locaKey: currentObj.locaKey,
});
if (matResponse.success && matResponse.data) {
const layerColumn = hierarchyConfigData.material.layerColumn || "LOLAYER";
const sortedMaterials = matResponse.data.sort(
(a: any, b: any) => (b[layerColumn] || 0) - (a[layerColumn] || 0),
);
setMaterials(sortedMaterials);
}
}
}
// 마지막 갱신 시간 기록
setLastRefreshedAt(new Date());
}
} catch {
// 자동 갱신 실패 시 조용히 무시 (사용자 경험 방해 안 함)
}
};
// 10초마다 자동 갱신
const intervalId = setInterval(silentRefresh, AUTO_REFRESH_INTERVAL);
// 컴포넌트 언마운트 시 인터벌 정리
return () => clearInterval(intervalId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId, isLoading, isRefreshing, selectedObject]);
// Location의 자재 목록 로드 // Location의 자재 목록 로드
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
if (!hierarchyConfig?.material) { if (!hierarchyConfig?.material) {
@ -200,7 +422,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
}); });
if (response.success && response.data) { if (response.success && response.data) {
const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER"; const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER";
const sortedMaterials = response.data.sort((a: any, b: any) => (a[layerColumn] || 0) - (b[layerColumn] || 0)); // 층 내림차순 정렬 (높은 층이 위로)
const sortedMaterials = response.data.sort((a: any, b: any) => (b[layerColumn] || 0) - (a[layerColumn] || 0));
setMaterials(sortedMaterials); setMaterials(sortedMaterials);
} else { } else {
setMaterials([]); setMaterials([]);
@ -334,8 +557,28 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
<div className="flex items-center justify-between border-b p-4"> <div className="flex items-center justify-between border-b p-4">
<div> <div>
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2> <h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
<p className="text-muted-foreground text-sm"> </p> <div className="flex items-center gap-3">
<p className="text-muted-foreground text-sm">{isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"}</p>
{lastRefreshedAt && (
<span className="text-muted-foreground text-xs">
: {lastRefreshedAt.toLocaleTimeString("ko-KR")}
</span>
)}
</div> </div>
</div>
<div className="flex items-center gap-2">
{/* 전체 화면 버튼 - 외부 업체 모드에서만 표시 */}
{isExternalMode && (
<Button
variant="outline"
size="sm"
onClick={toggleFullscreen}
title={isFullscreen ? "전체 화면 종료" : "전체 화면"}
>
{isFullscreen ? <Minimize className="mr-2 h-4 w-4" /> : <Maximize className="mr-2 h-4 w-4" />}
{isFullscreen ? "종료" : "전체 화면"}
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -347,10 +590,12 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
{isRefreshing ? "갱신 중..." : "새로고침"} {isRefreshing ? "갱신 중..." : "새로고침"}
</Button> </Button>
</div> </div>
</div>
{/* 메인 영역 */} {/* 메인 영역 */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* 좌측: 검색/필터 */} {/* 좌측: 검색/필터 - 외부 모드에서는 숨김 */}
{!isExternalMode && (
<div className="flex h-full w-64 flex-shrink-0 flex-col border-r"> <div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
{/* 검색 */} {/* 검색 */}
@ -575,9 +820,15 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
)} )}
</div> </div>
</div> </div>
)}
{/* 중앙 + 우측 컨테이너 (전체화면 시 함께 표시) */}
<div
ref={canvasContainerRef}
className={`relative flex flex-1 overflow-hidden ${isFullscreen ? "bg-background" : ""}`}
>
{/* 중앙: 3D 캔버스 */} {/* 중앙: 3D 캔버스 */}
<div className="relative flex-1"> <div className="relative min-w-0 flex-1">
{!isLoading && ( {!isLoading && (
<Yard3DCanvas <Yard3DCanvas
placements={canvasPlacements} placements={canvasPlacements}
@ -590,7 +841,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
</div> </div>
{/* 우측: 정보 패널 */} {/* 우측: 정보 패널 */}
<div className="h-full w-[480px] flex-shrink-0 overflow-y-auto border-l"> <div className="h-full w-[480px] min-w-[480px] flex-shrink-0 overflow-y-auto border-l">
{selectedObject ? ( {selectedObject ? (
<div className="p-4"> <div className="p-4">
<div className="mb-4"> <div className="mb-4">
@ -640,20 +891,15 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<Label className="mb-2 block text-sm font-semibold"> <Label className="mb-2 block text-sm font-semibold"> ({materials.length})</Label>
({materials.length})
</Label>
{/* 테이블 형태로 전체 조회 */} {/* 테이블 형태로 전체 조회 */}
<div className="max-h-[400px] overflow-auto rounded-lg border"> <div className="h-[580px] overflow-auto rounded-lg border">
<table className="w-full text-xs"> <table className="w-full text-sm">
<thead className="bg-muted sticky top-0"> <thead className="bg-muted sticky top-0">
<tr> <tr>
<th className="border-b px-2 py-2 text-left font-semibold"></th> <th className="border-b px-3 py-3 text-left font-semibold whitespace-nowrap"></th>
{(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => ( {(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => (
<th <th key={colConfig.column} className="border-b px-3 py-3 text-left font-semibold">
key={colConfig.column}
className="border-b px-2 py-2 text-left font-semibold"
>
{colConfig.label} {colConfig.label}
</th> </th>
))} ))}
@ -668,11 +914,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
key={`${material.STKKEY}-${index}`} key={`${material.STKKEY}-${index}`}
className="hover:bg-accent border-b transition-colors last:border-0" className="hover:bg-accent border-b transition-colors last:border-0"
> >
<td className="px-2 py-2 font-medium"> <td className="px-3 py-3 font-medium whitespace-nowrap">
{material[layerColumn]} {material[layerColumn]}
</td> </td>
{displayColumns.map((colConfig: any) => ( {displayColumns.map((colConfig: any) => (
<td key={colConfig.column} className="px-2 py-2"> <td key={colConfig.column} className="px-3 py-3">
{material[colConfig.column] || "-"} {material[colConfig.column] || "-"}
</td> </td>
))} ))}
@ -693,6 +939,20 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
</div> </div>
)} )}
</div> </div>
{/* 풀스크린 모드일 때 종료 버튼 */}
{isFullscreen && (
<Button
variant="outline"
size="sm"
onClick={toggleFullscreen}
className="bg-background/80 absolute top-4 right-4 z-50 backdrop-blur-sm"
>
<Minimize className="mr-2 h-4 w-4" />
</Button>
)}
</div>
</div> </div>
</div> </div>
); );