diff --git a/backend-node/src/routes/roleRoutes.ts b/backend-node/src/routes/roleRoutes.ts index 21c17ecb..0f8a64b0 100644 --- a/backend-node/src/routes/roleRoutes.ts +++ b/backend-node/src/routes/roleRoutes.ts @@ -22,6 +22,15 @@ const router = Router(); // 모든 role 라우트에 인증 미들웨어 적용 router.use(authenticateToken); +/** + * 사용자 권한 그룹 조회 (/:id 보다 먼저 정의해야 함) + */ +// 현재 사용자가 속한 권한 그룹 조회 +router.get("/user/my-groups", getUserRoleGroups); + +// 특정 사용자가 속한 권한 그룹 조회 +router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups); + /** * 권한 그룹 CRUD */ @@ -67,13 +76,4 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions); // 메뉴 권한 설정 router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions); -/** - * 사용자 권한 그룹 조회 - */ -// 현재 사용자가 속한 권한 그룹 조회 -router.get("/user/my-groups", getUserRoleGroups); - -// 특정 사용자가 속한 권한 그룹 조회 -router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups); - export default router; diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 090985ba..6d254cfe 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -916,7 +916,7 @@ export function CanvasElement({ ) : element.type === "widget" && element.subtype === "weather" ? ( // 날씨 위젯 렌더링
- +
) : element.type === "widget" && element.subtype === "exchange" ? ( // 환율 위젯 렌더링 diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index 194f7210..2f05a927 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -14,7 +14,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { getApiUrl } from "@/lib/utils/apiUrl"; -import { Truck, Clock, MapPin, Package, Info } from "lucide-react"; +import { Truck, Clock, MapPin, Package, Info, ChevronLeft, ChevronRight } from "lucide-react"; interface ListWidgetProps { element: DashboardElement; @@ -32,6 +32,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); + const [containerHeight, setContainerHeight] = useState(0); + const containerRef = React.useRef(null); // 행 상세 팝업 상태 const [detailPopupOpen, setDetailPopupOpen] = useState(false); @@ -39,6 +41,25 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { const [detailPopupLoading, setDetailPopupLoading] = useState(false); const [additionalDetailData, setAdditionalDetailData] = useState | null>(null); + // 컨테이너 높이 감지 + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerHeight(entry.contentRect.height); + } + }); + + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + }, []); + + // 컴팩트 모드 여부 (높이 300px 이하 또는 element 높이가 300px 이하) + const elementHeight = element?.size?.height || 0; + const isCompactHeight = elementHeight > 0 ? elementHeight < 300 : (containerHeight > 0 && containerHeight < 300); + const config = element.listConfig || { columnMode: "auto", viewMode: "table", @@ -541,14 +562,64 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows; return ( -
- {/* 제목 - 항상 표시 */} -
-

{element.customTitle || element.title}

-
+
+ {/* 컴팩트 모드 (세로 1칸) - 캐러셀 형태로 한 건씩 표시 */} + {isCompactHeight ? ( +
+ {data && data.rows.length > 0 && displayColumns.filter((col) => col.visible).length > 0 ? ( +
+ {/* 이전 버튼 */} + - {/* 테이블 뷰 */} - {config.viewMode === "table" && ( + {/* 현재 데이터 */} +
+ {displayColumns.filter((col) => col.visible).slice(0, 4).map((col, colIdx) => ( + + {String(data.rows[currentPage - 1]?.[col.dataKey || col.field] ?? "").substring(0, 25)} + {colIdx < Math.min(displayColumns.filter((c) => c.visible).length, 4) - 1 && " | "} + + ))} +
+ + {/* 다음 버튼 */} + +
+ ) : ( +
데이터 없음
+ )} + + {/* 현재 위치 표시 (작게) */} + {data && data.rows.length > 0 && ( +
+ {currentPage} / {data.rows.length} +
+ )} +
+ ) : ( + <> + {/* 제목 - 항상 표시 */} +
+

{element.customTitle || element.title}

+
+ + {/* 테이블 뷰 */} + {config.viewMode === "table" && (
{config.showHeader && ( @@ -642,36 +713,38 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { )} - {/* 페이지네이션 */} - {config.enablePagination && totalPages > 1 && ( -
-
- {startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개 -
-
- -
- {currentPage} - / - {totalPages} + {/* 페이지네이션 */} + {config.enablePagination && totalPages > 1 && ( +
+
+ {startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개 +
+
+ +
+ {currentPage} + / + {totalPages} +
+ +
- -
-
+ )} + )} {/* 행 상세 팝업 */} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 03abee6f..6ee36ca1 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -2146,32 +2146,32 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
- + {(hierarchyConfig?.material?.displayColumns || []).map((col) => ( - + {col.label} ))} - {materials.map((material, index) => { - const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; - const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY"; - const displayColumns = hierarchyConfig?.material?.displayColumns || []; + {materials.map((material, index) => { + const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; + const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY"; + const displayColumns = hierarchyConfig?.material?.displayColumns || []; const layerNumber = material[layerColumn] || index + 1; - return ( + return ( - {layerNumber}단 + {layerNumber}단 {displayColumns.map((col) => ( - + {material[col.column] || "-"} - ))} + ))} - ); - })} + ); + })}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index a702a047..c5d3e463 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, 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([]); @@ -43,6 +47,64 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) const [filterType, setFilterType] = useState("all"); const [isRefreshing, setIsRefreshing] = useState(false); + // 외부 업체 모드 + const [isExternalMode, setIsExternalMode] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const canvasContainerRef = useRef(null); + + // 외부 업체 역할 체크 + useEffect(() => { + const checkExternalRole = async () => { + try { + const response = await apiCall("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 = () => { + setIsFullscreen(!!document.fullscreenElement); + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); + }, []); + // 레이아웃 데이터 로드 함수 const loadLayout = async () => { try { @@ -334,23 +396,42 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)

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

-

읽기 전용 뷰

+

+ {isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"} +

+
+
+ {/* 전체 화면 버튼 */} + +
-
{/* 메인 영역 */}
- {/* 좌측: 검색/필터 */} + {/* 좌측: 검색/필터 - 외부 모드에서는 숨김 */} + {!isExternalMode && (
{/* 검색 */} @@ -575,9 +656,13 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) )}
+ )} {/* 중앙: 3D 캔버스 */} -
+
{!isLoading && ( {}} /> )} + {/* 풀스크린 모드일 때 종료 버튼 */} + {isFullscreen && ( + + )}
- {/* 우측: 정보 패널 */} + {/* 우측: 정보 패널 - 외부 모드에서는 숨김 */} + {!isExternalMode && (
{selectedObject ? (
@@ -645,14 +743,14 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {/* 테이블 형태로 전체 조회 */}
- +
- + {(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => ( @@ -660,25 +758,25 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) - {materials.map((material, index) => { + {materials.map((material, index) => { const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; - const displayColumns = hierarchyConfig?.material?.displayColumns || []; - return ( + const displayColumns = hierarchyConfig?.material?.displayColumns || []; + return ( - - {displayColumns.map((colConfig: any) => ( - - ))} + ))} - ); - })} + ); + })}
{colConfig.label}
+ {material[layerColumn]}단 + {displayColumns.map((colConfig: any) => ( + {material[colConfig.column] || "-"}
@@ -693,6 +791,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
)}
+ )}
); diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx index d1303d10..a1609b1a 100644 --- a/frontend/components/dashboard/widgets/ListTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx @@ -13,7 +13,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Loader2, RefreshCw, Truck, Clock, MapPin, Package, Info } from "lucide-react"; +import { Loader2, RefreshCw, Truck, Clock, MapPin, Package, Info, ChevronLeft, ChevronRight } from "lucide-react"; import { applyColumnMapping } from "@/lib/utils/columnMapping"; import { getApiUrl } from "@/lib/utils/apiUrl"; @@ -41,6 +41,8 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [lastRefreshTime, setLastRefreshTime] = useState(null); + const [containerHeight, setContainerHeight] = useState(0); + const containerRef = React.useRef(null); // 행 상세 팝업 상태 const [detailPopupOpen, setDetailPopupOpen] = useState(false); @@ -48,6 +50,25 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { const [detailPopupLoading, setDetailPopupLoading] = useState(false); const [additionalDetailData, setAdditionalDetailData] = useState | null>(null); + // 컨테이너 높이 감지 + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerHeight(entry.contentRect.height); + } + }); + + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + }, []); + + // 컴팩트 모드 여부 (높이 300px 이하 또는 element 높이가 300px 이하) + const elementHeight = element?.size?.height || 0; + const isCompactHeight = elementHeight > 0 ? elementHeight < 300 : (containerHeight > 0 && containerHeight < 300); + // // console.log("🧪 ListTestWidget 렌더링!", element); const dataSources = useMemo(() => { @@ -743,87 +764,139 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { }; return ( -
- {/* 헤더 */} -
-
-

- {element?.customTitle || "리스트"} -

-

- {dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행 - {lastRefreshTime && ( - - • {lastRefreshTime.toLocaleTimeString("ko-KR")} - +

+ {/* 컴팩트 모드 (세로 1칸) - 캐러셀 형태로 한 건씩 표시 */} + {isCompactHeight ? ( +
+ {data && data.rows.length > 0 && displayColumns.length > 0 ? ( +
+ {/* 이전 버튼 */} + + + {/* 현재 데이터 */} +
+ {displayColumns.slice(0, 4).map((field, fieldIdx) => ( + + {String(data.rows[currentPage - 1]?.[field] ?? "").substring(0, 25)} + {fieldIdx < Math.min(displayColumns.length, 4) - 1 && " | "} + + ))} +
+ + {/* 다음 버튼 */} + +
+ ) : ( +
데이터 없음
+ )} + + {/* 현재 위치 표시 (작게) */} + {data && data.rows.length > 0 && ( +
+ {currentPage} / {data.rows.length} +
+ )} +
+ ) : ( + <> + {/* 헤더 */} +
+
+

+ {element?.customTitle || "리스트"} +

+

+ {dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행 + {lastRefreshTime && ( + + • {lastRefreshTime.toLocaleTimeString("ko-KR")} + + )} +

+
+
+ + {isLoading && } +
+
+ + {/* 컨텐츠 */} +
+ {error ? ( +
+

{error}

+
+ ) : !dataSources || dataSources.length === 0 ? ( +
+

+ 데이터 소스를 연결해주세요 +

+
+ ) : !data || data.rows.length === 0 ? ( +
+

+ 데이터가 없습니다 +

+
+ ) : config.viewMode === "card" ? ( + renderCards() + ) : ( + renderTable() )} -

-
-
- - {isLoading && } -
-
+
- {/* 컨텐츠 */} -
- {error ? ( -
-

{error}

-
- ) : !dataSources || dataSources.length === 0 ? ( -
-

- 데이터 소스를 연결해주세요 -

-
- ) : !data || data.rows.length === 0 ? ( -
-

- 데이터가 없습니다 -

-
- ) : config.viewMode === "card" ? ( - renderCards() - ) : ( - renderTable() - )} -
- - {/* 페이지네이션 */} - {config.enablePagination && data && data.rows.length > 0 && totalPages > 1 && ( -
-
- 총 {data.totalRows}개 항목 (페이지 {currentPage}/{totalPages}) -
-
- - -
-
+ {/* 페이지네이션 */} + {config.enablePagination && data && data.rows.length > 0 && totalPages > 1 && ( +
+
+ 총 {data.totalRows}개 항목 (페이지 {currentPage}/{totalPages}) +
+
+ + +
+
+ )} + )} {/* 행 상세 팝업 */} diff --git a/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx b/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx index 30ee99a5..1806ff34 100644 --- a/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx +++ b/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -8,6 +8,9 @@ import { RefreshCw, AlertTriangle, Cloud, Construction, Database as DatabaseIcon import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; import { getApiUrl } from "@/lib/utils/apiUrl"; +// 컴팩트 모드 임계값 (픽셀) +const COMPACT_HEIGHT_THRESHOLD = 180; + type AlertType = "accident" | "weather" | "construction" | "system" | "security" | "other"; interface Alert { @@ -31,6 +34,29 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp const [error, setError] = useState(null); const [filter, setFilter] = useState("all"); const [lastRefreshTime, setLastRefreshTime] = useState(null); + + // 컨테이너 높이 측정을 위한 ref + const containerRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(300); + + // 컴팩트 모드 여부 (element.size.height 또는 실제 컨테이너 높이 기반) + const isCompact = element?.size?.height + ? element.size.height < COMPACT_HEIGHT_THRESHOLD + : containerHeight < COMPACT_HEIGHT_THRESHOLD; + + // 컨테이너 높이 측정 + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerHeight(entry.contentRect.height); + } + }); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); const dataSources = useMemo(() => { return element?.dataSources || element?.chartConfig?.dataSources; @@ -549,8 +575,57 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp ); } + // 통계 계산 + const stats = { + accident: alerts.filter((a) => a.type === "accident").length, + weather: alerts.filter((a) => a.type === "weather").length, + construction: alerts.filter((a) => a.type === "construction").length, + high: alerts.filter((a) => a.severity === "high").length, + }; + + // 컴팩트 모드 렌더링 - 알림 목록만 스크롤 + if (isCompact) { + return ( +
+ {filteredAlerts.length === 0 ? ( +
+

알림 없음

+
+ ) : ( + filteredAlerts.map((alert, idx) => ( +
+
+ {getTypeIcon(alert.type)} + {alert.title} + + {alert.severity === "high" ? "긴급" : alert.severity === "medium" ? "주의" : "정보"} + +
+ {alert.location && ( +

{alert.location}

+ )} +
+ )) + )} +
+ ); + } + + // 일반 모드 렌더링 return ( -
+
{/* 헤더 */}
@@ -631,7 +706,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
{alert.location && ( -

📍 {alert.location}

+

{alert.location}

)}

{alert.description}

diff --git a/frontend/components/dashboard/widgets/RiskAlertWidget.tsx b/frontend/components/dashboard/widgets/RiskAlertWidget.tsx index 3e638f3c..7728cc5d 100644 --- a/frontend/components/dashboard/widgets/RiskAlertWidget.tsx +++ b/frontend/components/dashboard/widgets/RiskAlertWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -8,6 +8,9 @@ import { RefreshCw, AlertTriangle, Cloud, Construction } from "lucide-react"; import { apiClient } from "@/lib/api/client"; import { DashboardElement } from "@/components/admin/dashboard/types"; +// 컴팩트 모드 임계값 (픽셀) +const COMPACT_HEIGHT_THRESHOLD = 180; + // 알림 타입 type AlertType = "accident" | "weather" | "construction"; @@ -32,6 +35,29 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) { const [filter, setFilter] = useState("all"); const [lastUpdated, setLastUpdated] = useState(null); const [newAlertIds, setNewAlertIds] = useState>(new Set()); + + // 컨테이너 높이 측정을 위한 ref + const containerRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(300); + + // 컴팩트 모드 여부 (element.size.height 또는 실제 컨테이너 높이 기반) + const isCompact = element?.size?.height + ? element.size.height < COMPACT_HEIGHT_THRESHOLD + : containerHeight < COMPACT_HEIGHT_THRESHOLD; + + // 컨테이너 높이 측정 + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerHeight(entry.contentRect.height); + } + }); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); // 데이터 로드 (백엔드 캐시 조회) const loadData = async () => { @@ -176,8 +202,49 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) { high: alerts.filter((a) => a.severity === "high").length, }; + // 컴팩트 모드 렌더링 - 알림 목록만 스크롤 + if (isCompact) { + return ( +
+ {filteredAlerts.length === 0 ? ( +
+

알림 없음

+
+ ) : ( + filteredAlerts.map((alert) => ( +
+
+ {getAlertIcon(alert.type)} + {alert.title} + + {alert.severity === "high" ? "긴급" : alert.severity === "medium" ? "주의" : "정보"} + +
+ {alert.location && ( +

{alert.location}

+ )} +
+ )) + )} +
+ ); + } + + // 일반 모드 렌더링 return ( -
+
{/* 헤더 */}
@@ -294,7 +361,7 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) { {/* 안내 메시지 */}
- 💡 1분마다 자동으로 업데이트됩니다 + 1분마다 자동으로 업데이트됩니다
); diff --git a/frontend/components/dashboard/widgets/WeatherWidget.tsx b/frontend/components/dashboard/widgets/WeatherWidget.tsx index 56c3aaf6..3d1d7157 100644 --- a/frontend/components/dashboard/widgets/WeatherWidget.tsx +++ b/frontend/components/dashboard/widgets/WeatherWidget.tsx @@ -3,9 +3,10 @@ /** * 날씨 위젯 컴포넌트 * - 실시간 날씨 정보를 표시 + * - 컴팩트 모드: 높이가 작을 때 핵심 정보만 표시 */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { getWeather, WeatherData } from '@/lib/api/openApi'; import { Cloud, @@ -26,6 +27,9 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { cn } from '@/lib/utils'; import { DashboardElement } from '@/components/admin/dashboard/types'; +// 컴팩트 모드 임계값 (픽셀) +const COMPACT_HEIGHT_THRESHOLD = 180; + interface WeatherWidgetProps { element?: DashboardElement; city?: string; @@ -45,6 +49,29 @@ export default function WeatherWidget({ const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); + // 컨테이너 높이 측정을 위한 ref + const containerRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(300); + + // 컴팩트 모드 여부 (element.size.height 또는 실제 컨테이너 높이 기반) + const isCompact = element?.size?.height + ? element.size.height < COMPACT_HEIGHT_THRESHOLD + : containerHeight < COMPACT_HEIGHT_THRESHOLD; + + // 컨테이너 높이 측정 + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerHeight(entry.contentRect.height); + } + }); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + // 표시할 날씨 정보 선택 const [selectedItems, setSelectedItems] = useState([ 'temperature', @@ -323,12 +350,105 @@ export default function WeatherWidget({ ); } + // 날씨 아이콘 렌더링 헬퍼 + const renderWeatherIcon = (weatherMain: string, size: "sm" | "md" = "sm") => { + const iconClass = size === "sm" ? "h-5 w-5" : "h-8 w-8"; + switch (weatherMain.toLowerCase()) { + case 'clear': + return ; + case 'clouds': + return ; + case 'rain': + case 'drizzle': + return ; + case 'snow': + return ; + default: + return ; + } + }; + + // 컴팩트 모드 렌더링 + if (isCompact) { + return ( +
+ {/* 컴팩트 헤더 - 도시명, 온도, 날씨 아이콘 한 줄에 표시 */} +
+
+ {renderWeatherIcon(weather.weatherMain, "md")} +
+
+ + {weather.temperature}°C + + + {weather.weatherDescription} + +
+ + + + + + + + + 도시를 찾을 수 없습니다. + + {cities.map((city) => ( + { + handleCityChange(currentValue === selectedCity ? selectedCity : currentValue); + setOpen(false); + }} + > + + {city.label} + + ))} + + + + + +
+
+ +
+
+ ); + } + + // 일반 모드 렌더링 return ( -
+
{/* 헤더 */}
-

🌤️ {element?.customTitle || "날씨"}

+

{element?.customTitle || "날씨"}

@@ -438,22 +558,7 @@ export default function WeatherWidget({
- {(() => { - const iconClass = "h-5 w-5"; - switch (weather.weatherMain.toLowerCase()) { - case 'clear': - return ; - case 'clouds': - return ; - case 'rain': - case 'drizzle': - return ; - case 'snow': - return ; - default: - return ; - } - })()} + {renderWeatherIcon(weather.weatherMain)}
diff --git a/frontend/components/report/designer/QueryManager.tsx b/frontend/components/report/designer/QueryManager.tsx index e9ccb813..39b0d173 100644 --- a/frontend/components/report/designer/QueryManager.tsx +++ b/frontend/components/report/designer/QueryManager.tsx @@ -274,15 +274,15 @@ export function QueryManager() {
- -
+ > + + +
{/* 쿼리 이름 */}
diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index 2ff70c73..ded27f37 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -486,11 +486,11 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) } } return component; - }), - ); + }), + ); return { ...page, components: componentsWithBase64 }; - }), - ); + }), + ); // 쿼리 결과 수집 const queryResults: Record[] }> = {};