|
|
|
|
@ -1,7 +1,7 @@
|
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect, useMemo } from "react";
|
|
|
|
|
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react";
|
|
|
|
|
import { useState, useEffect, useMemo, useRef } from "react";
|
|
|
|
|
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw, Maximize, Minimize } from "lucide-react";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
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 { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
|
|
|
|
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
|
|
|
|
import { apiCall } from "@/lib/api/client";
|
|
|
|
|
|
|
|
|
|
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
|
|
|
|
ssr: false,
|
|
|
|
|
@ -26,6 +27,9 @@ interface DigitalTwinViewerProps {
|
|
|
|
|
layoutId: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 외부 업체 역할 코드
|
|
|
|
|
const EXTERNAL_VENDOR_ROLE = "LSTHIRA_EXTERNAL_VENDOR";
|
|
|
|
|
|
|
|
|
|
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
|
|
|
|
|
const { toast } = useToast();
|
|
|
|
|
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
|
|
|
|
|
@ -43,6 +47,73 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|
|
|
|
const [filterType, setFilterType] = useState<string>("all");
|
|
|
|
|
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 () => {
|
|
|
|
|
try {
|
|
|
|
|
@ -144,6 +215,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// 마지막 갱신 시간 기록
|
|
|
|
|
setLastRefreshedAt(new Date());
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(response.error || "레이아웃 조회 실패");
|
|
|
|
|
}
|
|
|
|
|
@ -180,6 +253,155 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [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의 자재 목록 로드
|
|
|
|
|
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
|
|
|
|
|
if (!hierarchyConfig?.material) {
|
|
|
|
|
@ -200,7 +422,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|
|
|
|
});
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
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);
|
|
|
|
|
} else {
|
|
|
|
|
setMaterials([]);
|
|
|
|
|
@ -334,8 +557,28 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|
|
|
|
<div className="flex items-center justify-between border-b p-4">
|
|
|
|
|
<div>
|
|
|
|
|
<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 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
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
@ -347,10 +590,12 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|
|
|
|
{isRefreshing ? "갱신 중..." : "새로고침"}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 메인 영역 */}
|
|
|
|
|
<div className="flex flex-1 overflow-hidden">
|
|
|
|
|
{/* 좌측: 검색/필터 */}
|
|
|
|
|
{/* 좌측: 검색/필터 - 외부 모드에서는 숨김 */}
|
|
|
|
|
{!isExternalMode && (
|
|
|
|
|
<div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
|
|
|
|
|
<div className="space-y-4 p-4">
|
|
|
|
|
{/* 검색 */}
|
|
|
|
|
@ -575,9 +820,15 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 중앙 + 우측 컨테이너 (전체화면 시 함께 표시) */}
|
|
|
|
|
<div
|
|
|
|
|
ref={canvasContainerRef}
|
|
|
|
|
className={`relative flex flex-1 overflow-hidden ${isFullscreen ? "bg-background" : ""}`}
|
|
|
|
|
>
|
|
|
|
|
{/* 중앙: 3D 캔버스 */}
|
|
|
|
|
<div className="relative flex-1">
|
|
|
|
|
<div className="relative min-w-0 flex-1">
|
|
|
|
|
{!isLoading && (
|
|
|
|
|
<Yard3DCanvas
|
|
|
|
|
placements={canvasPlacements}
|
|
|
|
|
@ -590,7 +841,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|
|
|
|
</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 ? (
|
|
|
|
|
<div className="p-4">
|
|
|
|
|
<div className="mb-4">
|
|
|
|
|
@ -640,20 +891,15 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="mb-2 block text-sm font-semibold">
|
|
|
|
|
자재 목록 ({materials.length}개)
|
|
|
|
|
</Label>
|
|
|
|
|
<Label className="mb-2 block text-sm font-semibold">자재 목록 ({materials.length}개)</Label>
|
|
|
|
|
{/* 테이블 형태로 전체 조회 */}
|
|
|
|
|
<div className="max-h-[400px] overflow-auto rounded-lg border">
|
|
|
|
|
<table className="w-full text-xs">
|
|
|
|
|
<div className="h-[580px] overflow-auto rounded-lg border">
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
<thead className="bg-muted sticky top-0">
|
|
|
|
|
<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) => (
|
|
|
|
|
<th
|
|
|
|
|
key={colConfig.column}
|
|
|
|
|
className="border-b px-2 py-2 text-left font-semibold"
|
|
|
|
|
>
|
|
|
|
|
<th key={colConfig.column} className="border-b px-3 py-3 text-left font-semibold">
|
|
|
|
|
{colConfig.label}
|
|
|
|
|
</th>
|
|
|
|
|
))}
|
|
|
|
|
@ -668,11 +914,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|
|
|
|
key={`${material.STKKEY}-${index}`}
|
|
|
|
|
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]}단
|
|
|
|
|
</td>
|
|
|
|
|
{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] || "-"}
|
|
|
|
|
</td>
|
|
|
|
|
))}
|
|
|
|
|
@ -693,6 +939,20 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
|