Merge pull request '지도 위젯(대시보드)수정' (#204) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/204
This commit is contained in:
commit
565ab0b1c0
|
|
@ -853,7 +853,9 @@ export function CanvasElement({
|
||||||
)}
|
)}
|
||||||
{/* 제목 */}
|
{/* 제목 */}
|
||||||
{!element.type || element.type !== "chart" ? (
|
{!element.type || element.type !== "chart" ? (
|
||||||
<span className="text-foreground text-xs font-bold">{element.customTitle || element.title}</span>
|
element.subtype === "map-summary-v2" && !element.customTitle ? null : (
|
||||||
|
<span className="text-foreground text-xs font-bold">{element.customTitle || element.title}</span>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,8 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
setCustomTitle(element.customTitle || "");
|
setCustomTitle(element.customTitle || "");
|
||||||
setShowHeader(element.showHeader !== false);
|
setShowHeader(element.showHeader !== false);
|
||||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
||||||
setDataSources(element.dataSources || []);
|
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 가져옴
|
||||||
|
setDataSources(element.dataSources || element.chartConfig?.dataSources || []);
|
||||||
setQueryResult(null);
|
setQueryResult(null);
|
||||||
|
|
||||||
// 리스트 위젯 설정 초기화
|
// 리스트 위젯 설정 초기화
|
||||||
|
|
@ -297,10 +298,12 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
...(needsDataSource(element.subtype)
|
...(needsDataSource(element.subtype)
|
||||||
? {
|
? {
|
||||||
dataSource,
|
dataSource,
|
||||||
// 다중 데이터 소스 위젯은 dataSources도 포함
|
// 다중 데이터 소스 위젯은 dataSources도 포함 (빈 배열도 허용 - 연결 해제)
|
||||||
...(isMultiDataSourceWidget
|
...(isMultiDataSourceWidget
|
||||||
? {
|
? {
|
||||||
dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [],
|
dataSources: dataSources,
|
||||||
|
// chartConfig에도 dataSources 포함 (일부 위젯은 chartConfig에서 읽음)
|
||||||
|
chartConfig: { ...chartConfig, dataSources: dataSources },
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
|
|
@ -316,14 +319,14 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
element.subtype === "chart" ||
|
element.subtype === "chart" ||
|
||||||
["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(element.subtype)
|
["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(element.subtype)
|
||||||
? {
|
? {
|
||||||
// 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함
|
// 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함 (빈 배열도 허용 - 연결 해제)
|
||||||
chartConfig: isMultiDataSourceWidget
|
chartConfig: isMultiDataSourceWidget
|
||||||
? { ...chartConfig, dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [] }
|
? { ...chartConfig, dataSources: dataSources }
|
||||||
: chartConfig,
|
: chartConfig,
|
||||||
// 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함
|
// 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함 (빈 배열도 허용 - 연결 해제)
|
||||||
...(isMultiDataSourceWidget
|
...(isMultiDataSourceWidget
|
||||||
? {
|
? {
|
||||||
dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [],
|
dataSources: dataSources,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
|
|
@ -520,7 +523,39 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 지도 설정 */}
|
{/* 지도 설정 */}
|
||||||
{element.subtype === "map-summary-v2" && <MapConfigSection queryResult={queryResult} />}
|
{element.subtype === "map-summary-v2" && (
|
||||||
|
<MapConfigSection
|
||||||
|
queryResult={queryResult}
|
||||||
|
refreshInterval={element.chartConfig?.refreshInterval || 5}
|
||||||
|
markerType={element.chartConfig?.markerType || "circle"}
|
||||||
|
onRefreshIntervalChange={(interval) => {
|
||||||
|
setElement((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
chartConfig: {
|
||||||
|
...prev.chartConfig,
|
||||||
|
refreshInterval: interval,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onMarkerTypeChange={(type) => {
|
||||||
|
setElement((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
chartConfig: {
|
||||||
|
...prev.chartConfig,
|
||||||
|
markerType: type,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 리스크 알림 설정 */}
|
{/* 리스크 알림 설정 */}
|
||||||
{element.subtype === "risk-alert-v2" && <RiskAlertSection queryResult={queryResult} />}
|
{element.subtype === "risk-alert-v2" && <RiskAlertSection queryResult={queryResult} />}
|
||||||
|
|
@ -534,7 +569,22 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
<Button variant="outline" onClick={onClose} className="h-9 flex-1 text-sm">
|
<Button variant="outline" onClick={onClose} className="h-9 flex-1 text-sm">
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleApply} className="h-9 flex-1 text-sm">
|
<Button
|
||||||
|
onClick={handleApply}
|
||||||
|
className="h-9 flex-1 text-sm"
|
||||||
|
disabled={
|
||||||
|
// 다중 데이터 소스 위젯: 데이터 소스가 있는데 endpoint가 비어있으면 비활성화
|
||||||
|
// (데이터 소스가 없는 건 OK - 연결 해제하는 경우)
|
||||||
|
(element?.subtype === "map-summary-v2" ||
|
||||||
|
element?.subtype === "chart" ||
|
||||||
|
element?.subtype === "list-v2" ||
|
||||||
|
element?.subtype === "custom-metric-v2" ||
|
||||||
|
element?.subtype === "risk-alert-v2") &&
|
||||||
|
dataSources &&
|
||||||
|
dataSources.length > 0 &&
|
||||||
|
dataSources.some(ds => ds.type === "api" && !ds.endpoint)
|
||||||
|
}
|
||||||
|
>
|
||||||
적용
|
적용
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -530,31 +530,50 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 자동 새로고침 설정 */}
|
{/* 마커 polling 간격 설정 (MapTestWidgetV2 전용) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`refresh-${dataSource.id}`} className="text-xs">
|
<Label htmlFor="marker-refresh-interval" className="text-xs">
|
||||||
자동 새로고침 간격
|
마커 새로고침 간격
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={String(dataSource.refreshInterval || 0)}
|
value={(dataSource.refreshInterval ?? 5).toString()}
|
||||||
onValueChange={(value) => onChange({ refreshInterval: Number(value) })}
|
onValueChange={(value) => onChange({ refreshInterval: parseInt(value) })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger id="marker-refresh-interval" className="h-9 text-xs">
|
||||||
<SelectValue placeholder="새로고침 안 함" />
|
<SelectValue placeholder="간격 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="0">새로고침 안 함</SelectItem>
|
<SelectItem value="0" className="text-xs">없음</SelectItem>
|
||||||
<SelectItem value="10">10초마다</SelectItem>
|
<SelectItem value="5" className="text-xs">5초</SelectItem>
|
||||||
<SelectItem value="30">30초마다</SelectItem>
|
<SelectItem value="10" className="text-xs">10초</SelectItem>
|
||||||
<SelectItem value="60">1분마다</SelectItem>
|
<SelectItem value="30" className="text-xs">30초</SelectItem>
|
||||||
<SelectItem value="300">5분마다</SelectItem>
|
<SelectItem value="60" className="text-xs">1분</SelectItem>
|
||||||
<SelectItem value="600">10분마다</SelectItem>
|
|
||||||
<SelectItem value="1800">30분마다</SelectItem>
|
|
||||||
<SelectItem value="3600">1시간마다</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
설정한 간격마다 자동으로 데이터를 다시 불러옵니다
|
마커 데이터를 자동으로 갱신하는 주기를 설정합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 마커 종류 선택 (MapTestWidgetV2 전용) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="marker-type" className="text-xs">
|
||||||
|
마커 종류
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={dataSource.markerType || "circle"}
|
||||||
|
onValueChange={(value) => onChange({ markerType: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="marker-type" className="h-9 text-xs">
|
||||||
|
<SelectValue placeholder="마커 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="circle" className="text-xs">동그라미</SelectItem>
|
||||||
|
<SelectItem value="arrow" className="text-xs">화살표</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
지도에 표시할 마커의 모양을 선택합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -892,6 +911,128 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 지도 팝업 필드 설정 (MapTestWidgetV2 전용) */}
|
||||||
|
{availableColumns.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="popup-fields" className="text-xs">
|
||||||
|
팝업 표시 필드
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{/* 기존 팝업 필드 목록 */}
|
||||||
|
{dataSource.popupFields && dataSource.popupFields.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{dataSource.popupFields.map((field, index) => (
|
||||||
|
<div key={index} className="space-y-2 rounded-lg border bg-muted/30 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">필드 {index + 1}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields.splice(index, 1);
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드명 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">필드명</Label>
|
||||||
|
<Select
|
||||||
|
value={field.fieldName}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields[index].fieldName = value;
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full text-xs">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableColumns.map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 라벨 입력 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">한글 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={field.label || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields[index].label = e.target.value;
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
placeholder="예: 차량 번호"
|
||||||
|
className="h-8 w-full text-xs"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 포맷 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">표시 포맷</Label>
|
||||||
|
<Select
|
||||||
|
value={field.format || "text"}
|
||||||
|
onValueChange={(value: "text" | "date" | "datetime" | "number" | "url") => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields[index].format = value;
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="text" className="text-xs">텍스트</SelectItem>
|
||||||
|
<SelectItem value="number" className="text-xs">숫자</SelectItem>
|
||||||
|
<SelectItem value="date" className="text-xs">날짜</SelectItem>
|
||||||
|
<SelectItem value="datetime" className="text-xs">날짜시간</SelectItem>
|
||||||
|
<SelectItem value="url" className="text-xs">URL</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필드 추가 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields.push({
|
||||||
|
fieldName: availableColumns[0] || "",
|
||||||
|
label: "",
|
||||||
|
format: "text",
|
||||||
|
});
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
className="h-8 w-full gap-2 text-xs"
|
||||||
|
disabled={availableColumns.length === 0}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
필드 추가
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
마커 클릭 시 팝업에 표시할 필드를 선택하고 한글 라벨을 지정하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ChartDataSource } from "@/components/admin/dashboard/types";
|
import { ChartDataSource } from "@/components/admin/dashboard/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Loader2, CheckCircle, XCircle } from "lucide-react";
|
import { Loader2, CheckCircle, XCircle, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
interface MultiDatabaseConfigProps {
|
interface MultiDatabaseConfigProps {
|
||||||
dataSource: ChartDataSource;
|
dataSource: ChartDataSource;
|
||||||
|
|
@ -45,13 +45,15 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
// ExternalDbConnectionAPI 사용 (인증 토큰 자동 포함)
|
// ExternalDbConnectionAPI 사용 (인증 토큰 자동 포함)
|
||||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||||
const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
|
const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
|
||||||
|
|
||||||
console.log("✅ 외부 DB 커넥션 로드 성공:", connections.length, "개");
|
console.log("✅ 외부 DB 커넥션 로드 성공:", connections.length, "개");
|
||||||
setExternalConnections(connections.map((conn: any) => ({
|
setExternalConnections(
|
||||||
id: String(conn.id),
|
connections.map((conn: any) => ({
|
||||||
name: conn.connection_name,
|
id: String(conn.id),
|
||||||
type: conn.db_type,
|
name: conn.connection_name,
|
||||||
})));
|
type: conn.db_type,
|
||||||
|
})),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 외부 DB 커넥션 로드 실패:", error);
|
console.error("❌ 외부 DB 커넥션 로드 실패:", error);
|
||||||
setExternalConnections([]);
|
setExternalConnections([]);
|
||||||
|
|
@ -73,27 +75,27 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
try {
|
try {
|
||||||
// dashboardApi 사용 (인증 토큰 자동 포함)
|
// dashboardApi 사용 (인증 토큰 자동 포함)
|
||||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
|
|
||||||
if (dataSource.connectionType === "external" && dataSource.externalConnectionId) {
|
if (dataSource.connectionType === "external" && dataSource.externalConnectionId) {
|
||||||
// 외부 DB
|
// 외부 DB
|
||||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||||
const result = await ExternalDbConnectionAPI.executeQuery(
|
const result = await ExternalDbConnectionAPI.executeQuery(
|
||||||
parseInt(dataSource.externalConnectionId),
|
parseInt(dataSource.externalConnectionId),
|
||||||
dataSource.query
|
dataSource.query,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const rows = Array.isArray(result.data.rows) ? result.data.rows : [];
|
const rows = Array.isArray(result.data.rows) ? result.data.rows : [];
|
||||||
const rowCount = rows.length;
|
const rowCount = rows.length;
|
||||||
|
|
||||||
// 컬럼 목록 및 타입 추출
|
// 컬럼 목록 및 타입 추출
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
const columns = Object.keys(rows[0]);
|
const columns = Object.keys(rows[0]);
|
||||||
setAvailableColumns(columns);
|
setAvailableColumns(columns);
|
||||||
|
|
||||||
// 컬럼 타입 분석
|
// 컬럼 타입 분석
|
||||||
const types: Record<string, string> = {};
|
const types: Record<string, string> = {};
|
||||||
columns.forEach(col => {
|
columns.forEach((col) => {
|
||||||
const value = rows[0][col];
|
const value = rows[0][col];
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
types[col] = "unknown";
|
types[col] = "unknown";
|
||||||
|
|
@ -113,17 +115,17 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
});
|
});
|
||||||
setColumnTypes(types);
|
setColumnTypes(types);
|
||||||
setSampleData(rows.slice(0, 3));
|
setSampleData(rows.slice(0, 3));
|
||||||
|
|
||||||
console.log("📊 발견된 컬럼:", columns);
|
console.log("📊 발견된 컬럼:", columns);
|
||||||
console.log("📊 컬럼 타입:", types);
|
console.log("📊 컬럼 타입:", types);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTestResult({
|
setTestResult({
|
||||||
success: true,
|
success: true,
|
||||||
message: "쿼리 실행 성공",
|
message: "쿼리 실행 성공",
|
||||||
rowCount,
|
rowCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 부모로 테스트 결과 전달 (차트 설정용)
|
// 부모로 테스트 결과 전달 (차트 설정용)
|
||||||
if (onTestResult && rows && rows.length > 0) {
|
if (onTestResult && rows && rows.length > 0) {
|
||||||
onTestResult(rows);
|
onTestResult(rows);
|
||||||
|
|
@ -134,15 +136,15 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
} else {
|
} else {
|
||||||
// 현재 DB
|
// 현재 DB
|
||||||
const result = await dashboardApi.executeQuery(dataSource.query);
|
const result = await dashboardApi.executeQuery(dataSource.query);
|
||||||
|
|
||||||
// 컬럼 목록 및 타입 추출
|
// 컬럼 목록 및 타입 추출
|
||||||
if (result.rows && result.rows.length > 0) {
|
if (result.rows && result.rows.length > 0) {
|
||||||
const columns = Object.keys(result.rows[0]);
|
const columns = Object.keys(result.rows[0]);
|
||||||
setAvailableColumns(columns);
|
setAvailableColumns(columns);
|
||||||
|
|
||||||
// 컬럼 타입 분석
|
// 컬럼 타입 분석
|
||||||
const types: Record<string, string> = {};
|
const types: Record<string, string> = {};
|
||||||
columns.forEach(col => {
|
columns.forEach((col) => {
|
||||||
const value = result.rows[0][col];
|
const value = result.rows[0][col];
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
types[col] = "unknown";
|
types[col] = "unknown";
|
||||||
|
|
@ -162,17 +164,17 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
});
|
});
|
||||||
setColumnTypes(types);
|
setColumnTypes(types);
|
||||||
setSampleData(result.rows.slice(0, 3));
|
setSampleData(result.rows.slice(0, 3));
|
||||||
|
|
||||||
console.log("📊 발견된 컬럼:", columns);
|
console.log("📊 발견된 컬럼:", columns);
|
||||||
console.log("📊 컬럼 타입:", types);
|
console.log("📊 컬럼 타입:", types);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTestResult({
|
setTestResult({
|
||||||
success: true,
|
success: true,
|
||||||
message: "쿼리 실행 성공",
|
message: "쿼리 실행 성공",
|
||||||
rowCount: result.rowCount || 0,
|
rowCount: result.rowCount || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 부모로 테스트 결과 전달 (차트 설정용)
|
// 부모로 테스트 결과 전달 (차트 설정용)
|
||||||
if (onTestResult && result.rows && result.rows.length > 0) {
|
if (onTestResult && result.rows && result.rows.length > 0) {
|
||||||
onTestResult(result.rows);
|
onTestResult(result.rows);
|
||||||
|
|
@ -194,25 +196,17 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
<Label className="text-xs">데이터베이스 연결</Label>
|
<Label className="text-xs">데이터베이스 연결</Label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={dataSource.connectionType || "current"}
|
value={dataSource.connectionType || "current"}
|
||||||
onValueChange={(value: "current" | "external") =>
|
onValueChange={(value: "current" | "external") => onChange({ connectionType: value })}
|
||||||
onChange({ connectionType: value })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="current" id={`current-\${dataSource.id}`} />
|
<RadioGroupItem value="current" id={"current-${dataSource.id}"} />
|
||||||
<Label
|
<Label htmlFor={"current-${dataSource.id}"} className="text-xs font-normal">
|
||||||
htmlFor={`current-\${dataSource.id}`}
|
|
||||||
className="text-xs font-normal"
|
|
||||||
>
|
|
||||||
현재 데이터베이스
|
현재 데이터베이스
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="external" id={`external-\${dataSource.id}`} />
|
<RadioGroupItem value="external" id={"external-${dataSource.id}"} />
|
||||||
<Label
|
<Label htmlFor={"external-${dataSource.id}"} className="text-xs font-normal">
|
||||||
htmlFor={`external-\${dataSource.id}`}
|
|
||||||
className="text-xs font-normal"
|
|
||||||
>
|
|
||||||
외부 데이터베이스
|
외부 데이터베이스
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -222,12 +216,12 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
{/* 외부 DB 선택 */}
|
{/* 외부 DB 선택 */}
|
||||||
{dataSource.connectionType === "external" && (
|
{dataSource.connectionType === "external" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`external-conn-\${dataSource.id}`} className="text-xs">
|
<Label htmlFor={"external-conn-${dataSource.id}"} className="text-xs">
|
||||||
외부 데이터베이스 선택 *
|
외부 데이터베이스 선택 *
|
||||||
</Label>
|
</Label>
|
||||||
{loadingConnections ? (
|
{loadingConnections ? (
|
||||||
<div className="flex h-10 items-center justify-center rounded-md border">
|
<div className="flex h-10 items-center justify-center rounded-md border">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -252,62 +246,74 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
{/* SQL 쿼리 */}
|
{/* SQL 쿼리 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor={`query-\${dataSource.id}`} className="text-xs">
|
<Label htmlFor={"query-${dataSource.id}"} className="text-xs">
|
||||||
SQL 쿼리 *
|
SQL 쿼리 *
|
||||||
</Label>
|
</Label>
|
||||||
<Select onValueChange={(value) => {
|
<Select
|
||||||
const samples = {
|
onValueChange={(value) => {
|
||||||
users: `SELECT
|
const samples = {
|
||||||
|
users: `SELECT
|
||||||
dept_name as 부서명,
|
dept_name as 부서명,
|
||||||
COUNT(*) as 회원수
|
COUNT(*) as 회원수
|
||||||
FROM user_info
|
FROM user_info
|
||||||
WHERE dept_name IS NOT NULL
|
WHERE dept_name IS NOT NULL
|
||||||
GROUP BY dept_name
|
GROUP BY dept_name
|
||||||
ORDER BY 회원수 DESC`,
|
ORDER BY 회원수 DESC`,
|
||||||
dept: `SELECT
|
dept: `SELECT
|
||||||
dept_code as 부서코드,
|
dept_code as 부서코드,
|
||||||
dept_name as 부서명,
|
dept_name as 부서명,
|
||||||
location_name as 위치,
|
location_name as 위치,
|
||||||
TO_CHAR(regdate, 'YYYY-MM-DD') as 등록일
|
TO_CHAR(regdate, 'YYYY-MM-DD') as 등록일
|
||||||
FROM dept_info
|
FROM dept_info
|
||||||
ORDER BY dept_code`,
|
ORDER BY dept_code`,
|
||||||
usersByDate: `SELECT
|
usersByDate: `SELECT
|
||||||
DATE_TRUNC('month', regdate)::date as 월,
|
DATE_TRUNC('month', regdate)::date as 월,
|
||||||
COUNT(*) as 신규사용자수
|
COUNT(*) as 신규사용자수
|
||||||
FROM user_info
|
FROM user_info
|
||||||
WHERE regdate >= CURRENT_DATE - INTERVAL '12 months'
|
WHERE regdate >= CURRENT_DATE - INTERVAL '12 months'
|
||||||
GROUP BY DATE_TRUNC('month', regdate)
|
GROUP BY DATE_TRUNC('month', regdate)
|
||||||
ORDER BY 월`,
|
ORDER BY 월`,
|
||||||
usersByPosition: `SELECT
|
usersByPosition: `SELECT
|
||||||
position_name as 직급,
|
position_name as 직급,
|
||||||
COUNT(*) as 인원수
|
COUNT(*) as 인원수
|
||||||
FROM user_info
|
FROM user_info
|
||||||
WHERE position_name IS NOT NULL
|
WHERE position_name IS NOT NULL
|
||||||
GROUP BY position_name
|
GROUP BY position_name
|
||||||
ORDER BY 인원수 DESC`,
|
ORDER BY 인원수 DESC`,
|
||||||
deptHierarchy: `SELECT
|
deptHierarchy: `SELECT
|
||||||
COALESCE(parent_dept_code, '최상위') as 상위부서코드,
|
COALESCE(parent_dept_code, '최상위') as 상위부서코드,
|
||||||
COUNT(*) as 하위부서수
|
COUNT(*) as 하위부서수
|
||||||
FROM dept_info
|
FROM dept_info
|
||||||
GROUP BY parent_dept_code
|
GROUP BY parent_dept_code
|
||||||
ORDER BY 하위부서수 DESC`,
|
ORDER BY 하위부서수 DESC`,
|
||||||
};
|
};
|
||||||
onChange({ query: samples[value as keyof typeof samples] || "" });
|
onChange({ query: samples[value as keyof typeof samples] || "" });
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-7 w-32 text-xs">
|
<SelectTrigger className="h-7 w-32 text-xs">
|
||||||
<SelectValue placeholder="샘플 쿼리" />
|
<SelectValue placeholder="샘플 쿼리" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="users" className="text-xs">부서별 회원수</SelectItem>
|
<SelectItem value="users" className="text-xs">
|
||||||
<SelectItem value="dept" className="text-xs">부서 목록</SelectItem>
|
부서별 회원수
|
||||||
<SelectItem value="usersByDate" className="text-xs">월별 신규사용자</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="usersByPosition" className="text-xs">직급별 인원수</SelectItem>
|
<SelectItem value="dept" className="text-xs">
|
||||||
<SelectItem value="deptHierarchy" className="text-xs">부서 계층구조</SelectItem>
|
부서 목록
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="usersByDate" className="text-xs">
|
||||||
|
월별 신규사용자
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="usersByPosition" className="text-xs">
|
||||||
|
직급별 인원수
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="deptHierarchy" className="text-xs">
|
||||||
|
부서 계층구조
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`query-\${dataSource.id}`}
|
id={"query-${dataSource.id}"}
|
||||||
value={dataSource.query || ""}
|
value={dataSource.query || ""}
|
||||||
onChange={(e) => onChange({ query: e.target.value })}
|
onChange={(e) => onChange({ query: e.target.value })}
|
||||||
placeholder="SELECT * FROM table_name WHERE ..."
|
placeholder="SELECT * FROM table_name WHERE ..."
|
||||||
|
|
@ -315,35 +321,43 @@ ORDER BY 하위부서수 DESC`,
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 자동 새로고침 설정 */}
|
{/* 마커 polling 간격 설정 (MapTestWidgetV2 전용) */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`refresh-${dataSource.id}`} className="text-xs">
|
<Label htmlFor="marker-refresh-interval" className="text-xs">
|
||||||
자동 새로고침 간격
|
데이터 새로고침 간격
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={String(dataSource.refreshInterval || 0)}
|
value={(dataSource.refreshInterval ?? 5).toString()}
|
||||||
onValueChange={(value) => onChange({ refreshInterval: Number(value) })}
|
onValueChange={(value) => onChange({ refreshInterval: parseInt(value) })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger id="marker-refresh-interval" className="h-8 text-xs">
|
||||||
<SelectValue placeholder="새로고침 안 함" />
|
<SelectValue placeholder="간격 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="0">새로고침 안 함</SelectItem>
|
<SelectItem value="0" className="text-xs">
|
||||||
<SelectItem value="10">10초마다</SelectItem>
|
없음
|
||||||
<SelectItem value="30">30초마다</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="60">1분마다</SelectItem>
|
<SelectItem value="5" className="text-xs">
|
||||||
<SelectItem value="300">5분마다</SelectItem>
|
5초
|
||||||
<SelectItem value="600">10분마다</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="1800">30분마다</SelectItem>
|
<SelectItem value="10" className="text-xs">
|
||||||
<SelectItem value="3600">1시간마다</SelectItem>
|
10초
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="30" className="text-xs">
|
||||||
|
30초
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="60" className="text-xs">
|
||||||
|
1분
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-[10px]">마커 데이터를 자동으로 갱신하는 주기를 설정합니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
|
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
|
||||||
<div className="space-y-2 rounded-lg border bg-muted/30 p-2">
|
<div className="bg-muted/30 space-y-2 rounded-lg border p-2">
|
||||||
<h5 className="text-xs font-semibold">🎨 지도 색상</h5>
|
<h5 className="text-xs font-semibold">🎨 지도 색상</h5>
|
||||||
|
|
||||||
{/* 색상 팔레트 */}
|
{/* 색상 팔레트 */}
|
||||||
<div className="grid grid-cols-4 gap-1.5">
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
{[
|
{[
|
||||||
|
|
@ -361,14 +375,16 @@ ORDER BY 하위부서수 DESC`,
|
||||||
<button
|
<button
|
||||||
key={color.name}
|
key={color.name}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange({
|
onClick={() =>
|
||||||
markerColor: color.marker,
|
onChange({
|
||||||
polygonColor: color.polygon,
|
markerColor: color.marker,
|
||||||
polygonOpacity: 0.5,
|
polygonColor: color.polygon,
|
||||||
})}
|
polygonOpacity: 0.5,
|
||||||
|
})
|
||||||
|
}
|
||||||
className={`flex h-12 flex-col items-center justify-center gap-0.5 rounded-md border-2 transition-all hover:scale-105 ${
|
className={`flex h-12 flex-col items-center justify-center gap-0.5 rounded-md border-2 transition-all hover:scale-105 ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border-primary bg-primary/10 shadow-md"
|
? "border-primary bg-primary/10 shadow-md"
|
||||||
: "border-border bg-background hover:border-primary/50"
|
: "border-border bg-background hover:border-primary/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -405,21 +421,13 @@ ORDER BY 하위부서수 DESC`,
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-md p-2 text-xs ${
|
className={`flex items-center gap-2 rounded-md p-2 text-xs ${
|
||||||
testResult.success
|
testResult.success ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
? "bg-success/10 text-success"
|
|
||||||
: "bg-destructive/10 text-destructive"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{testResult.success ? (
|
{testResult.success ? <CheckCircle className="h-3 w-3" /> : <XCircle className="h-3 w-3" />}
|
||||||
<CheckCircle className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
{testResult.message}
|
{testResult.message}
|
||||||
{testResult.rowCount !== undefined && (
|
{testResult.rowCount !== undefined && <span className="ml-1">({testResult.rowCount}행)</span>}
|
||||||
<span className="ml-1">({testResult.rowCount}행)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -431,7 +439,7 @@ ORDER BY 하위부서수 DESC`,
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs font-semibold">메트릭 컬럼 선택</Label>
|
<Label className="text-xs font-semibold">메트릭 컬럼 선택</Label>
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||||
{dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
{dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
||||||
? `${dataSource.selectedColumns.length}개 선택됨`
|
? `${dataSource.selectedColumns.length}개 선택됨`
|
||||||
: "모든 컬럼 표시"}
|
: "모든 컬럼 표시"}
|
||||||
|
|
@ -468,18 +476,15 @@ ORDER BY 하위부서수 DESC`,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 컬럼 카드 그리드 */}
|
{/* 컬럼 카드 그리드 */}
|
||||||
<div className="grid grid-cols-1 gap-1.5 max-h-60 overflow-y-auto">
|
<div className="grid max-h-60 grid-cols-1 gap-1.5 overflow-y-auto">
|
||||||
{availableColumns
|
{availableColumns
|
||||||
.filter(col =>
|
.filter((col) => !columnSearchTerm || col.toLowerCase().includes(columnSearchTerm.toLowerCase()))
|
||||||
!columnSearchTerm ||
|
|
||||||
col.toLowerCase().includes(columnSearchTerm.toLowerCase())
|
|
||||||
)
|
|
||||||
.map((col) => {
|
.map((col) => {
|
||||||
const isSelected =
|
const isSelected =
|
||||||
!dataSource.selectedColumns ||
|
!dataSource.selectedColumns ||
|
||||||
dataSource.selectedColumns.length === 0 ||
|
dataSource.selectedColumns.length === 0 ||
|
||||||
dataSource.selectedColumns.includes(col);
|
dataSource.selectedColumns.includes(col);
|
||||||
|
|
||||||
const type = columnTypes[col] || "unknown";
|
const type = columnTypes[col] || "unknown";
|
||||||
const typeIcon = {
|
const typeIcon = {
|
||||||
number: "🔢",
|
number: "🔢",
|
||||||
|
|
@ -487,51 +492,53 @@ ORDER BY 하위부서수 DESC`,
|
||||||
date: "📅",
|
date: "📅",
|
||||||
boolean: "✓",
|
boolean: "✓",
|
||||||
object: "📦",
|
object: "📦",
|
||||||
unknown: "❓"
|
unknown: "❓",
|
||||||
}[type];
|
}[type];
|
||||||
|
|
||||||
const typeColor = {
|
const typeColor = {
|
||||||
number: "text-primary bg-primary/10",
|
number: "text-primary bg-primary/10",
|
||||||
string: "text-foreground bg-muted",
|
string: "text-foreground bg-muted",
|
||||||
date: "text-primary bg-primary/10",
|
date: "text-primary bg-primary/10",
|
||||||
boolean: "text-success bg-success/10",
|
boolean: "text-success bg-success/10",
|
||||||
object: "text-warning bg-warning/10",
|
object: "text-warning bg-warning/10",
|
||||||
unknown: "text-muted-foreground bg-muted"
|
unknown: "text-muted-foreground bg-muted",
|
||||||
}[type];
|
}[type];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={col}
|
key={col}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentSelected = dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
const currentSelected =
|
||||||
? dataSource.selectedColumns
|
dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
||||||
: availableColumns;
|
? dataSource.selectedColumns
|
||||||
|
: availableColumns;
|
||||||
|
|
||||||
const newSelected = isSelected
|
const newSelected = isSelected
|
||||||
? currentSelected.filter(c => c !== col)
|
? currentSelected.filter((c) => c !== col)
|
||||||
: [...currentSelected, col];
|
: [...currentSelected, col];
|
||||||
|
|
||||||
onChange({ selectedColumns: newSelected });
|
onChange({ selectedColumns: newSelected });
|
||||||
}}
|
}}
|
||||||
className={`
|
className={`relative flex cursor-pointer items-start gap-2 rounded-lg border p-2 transition-all ${
|
||||||
relative flex items-start gap-2 rounded-lg border p-2 cursor-pointer transition-all
|
isSelected
|
||||||
${isSelected
|
? "border-primary bg-primary/5 shadow-sm"
|
||||||
? "border-primary bg-primary/5 shadow-sm"
|
|
||||||
: "border-border bg-card hover:border-primary/50 hover:bg-muted/50"
|
: "border-border bg-card hover:border-primary/50 hover:bg-muted/50"
|
||||||
}
|
} `}
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{/* 체크박스 */}
|
{/* 체크박스 */}
|
||||||
<div className="flex-shrink-0 mt-0.5">
|
<div className="mt-0.5 flex-shrink-0">
|
||||||
<div className={`
|
<div
|
||||||
h-4 w-4 rounded border-2 flex items-center justify-center transition-colors
|
className={`flex h-4 w-4 items-center justify-center rounded border-2 transition-colors ${
|
||||||
${isSelected
|
isSelected ? "border-primary bg-primary" : "border-border bg-background"
|
||||||
? "border-primary bg-primary"
|
} `}
|
||||||
: "border-border bg-background"
|
>
|
||||||
}
|
|
||||||
`}>
|
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
|
className="text-primary-foreground h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|
@ -539,17 +546,17 @@ ORDER BY 하위부서수 DESC`,
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 정보 */}
|
{/* 컬럼 정보 */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium truncate">{col}</span>
|
<span className="truncate text-sm font-medium">{col}</span>
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${typeColor}`}>
|
<span className={`rounded px-1.5 py-0.5 text-xs ${typeColor}`}>
|
||||||
{typeIcon} {type}
|
{typeIcon} {type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 샘플 데이터 */}
|
{/* 샘플 데이터 */}
|
||||||
{sampleData.length > 0 && (
|
{sampleData.length > 0 && (
|
||||||
<div className="mt-1.5 text-xs text-muted-foreground">
|
<div className="text-muted-foreground mt-1.5 text-xs">
|
||||||
<span className="font-medium">예시:</span>{" "}
|
<span className="font-medium">예시:</span>{" "}
|
||||||
{sampleData.slice(0, 2).map((row, i) => (
|
{sampleData.slice(0, 2).map((row, i) => (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
|
|
@ -567,33 +574,28 @@ ORDER BY 하위부서수 DESC`,
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 결과 없음 */}
|
{/* 검색 결과 없음 */}
|
||||||
{columnSearchTerm && availableColumns.filter(col =>
|
{columnSearchTerm &&
|
||||||
col.toLowerCase().includes(columnSearchTerm.toLowerCase())
|
availableColumns.filter((col) => col.toLowerCase().includes(columnSearchTerm.toLowerCase())).length ===
|
||||||
).length === 0 && (
|
0 && (
|
||||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
"{columnSearchTerm}"에 대한 컬럼을 찾을 수 없습니다
|
"{columnSearchTerm}"에 대한 컬럼을 찾을 수 없습니다
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 컬럼 매핑 (쿼리 테스트 성공 후에만 표시) */}
|
{/* 컬럼 매핑 (쿼리 테스트 성공 후에만 표시) */}
|
||||||
{testResult?.success && availableColumns.length > 0 && (
|
{testResult?.success && availableColumns.length > 0 && (
|
||||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
<div className="bg-muted/30 space-y-3 rounded-lg border p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h5 className="text-xs font-semibold">🔄 컬럼 매핑 (선택사항)</h5>
|
<h5 className="text-xs font-semibold">🔄 컬럼 매핑 (선택사항)</h5>
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||||
다른 데이터 소스와 통합할 때 컬럼명을 통일할 수 있습니다
|
다른 데이터 소스와 통합할 때 컬럼명을 통일할 수 있습니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
|
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => onChange({ columnMapping: {} })} className="h-7 text-xs">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onChange({ columnMapping: {} })}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
>
|
|
||||||
초기화
|
초기화
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -605,11 +607,7 @@ ORDER BY 하위부서수 DESC`,
|
||||||
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
|
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
|
||||||
<div key={original} className="flex items-center gap-2">
|
<div key={original} className="flex items-center gap-2">
|
||||||
{/* 원본 컬럼 (읽기 전용) */}
|
{/* 원본 컬럼 (읽기 전용) */}
|
||||||
<Input
|
<Input value={original} disabled className="bg-muted h-8 flex-1 text-xs" />
|
||||||
value={original}
|
|
||||||
disabled
|
|
||||||
className="h-8 flex-1 text-xs bg-muted"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 화살표 */}
|
{/* 화살표 */}
|
||||||
<span className="text-muted-foreground text-xs">→</span>
|
<span className="text-muted-foreground text-xs">→</span>
|
||||||
|
|
@ -658,18 +656,147 @@ ORDER BY 하위부서수 DESC`,
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{availableColumns
|
{availableColumns
|
||||||
.filter(col => !dataSource.columnMapping || !dataSource.columnMapping[col])
|
.filter((col) => !dataSource.columnMapping || !dataSource.columnMapping[col])
|
||||||
.map(col => (
|
.map((col) => (
|
||||||
<SelectItem key={col} value={col} className="text-xs">
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
{col}
|
{col}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))}
|
||||||
}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-muted-foreground text-[10px]">💡 매핑하지 않은 컬럼은 원본 이름 그대로 사용됩니다</p>
|
||||||
💡 매핑하지 않은 컬럼은 원본 이름 그대로 사용됩니다
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 지도 팝업 필드 설정 (MapTestWidgetV2 전용) */}
|
||||||
|
{availableColumns.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="popup-fields" className="text-xs">
|
||||||
|
팝업 표시 필드
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{/* 기존 팝업 필드 목록 */}
|
||||||
|
{dataSource.popupFields && dataSource.popupFields.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{dataSource.popupFields.map((field, index) => (
|
||||||
|
<div key={index} className="bg-muted/30 space-y-2 rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">필드 {index + 1}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields.splice(index, 1);
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드명 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">필드명</Label>
|
||||||
|
<Select
|
||||||
|
value={field.fieldName}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields[index].fieldName = value;
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full text-xs">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableColumns.map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 라벨 입력 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">한글 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={field.label || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields[index].label = e.target.value;
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
placeholder="예: 차량 번호"
|
||||||
|
className="h-8 w-full text-xs"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 포맷 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">표시 포맷</Label>
|
||||||
|
<Select
|
||||||
|
value={field.format || "text"}
|
||||||
|
onValueChange={(value: "text" | "date" | "datetime" | "number" | "url") => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields[index].format = value;
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="text" className="text-xs">
|
||||||
|
텍스트
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="number" className="text-xs">
|
||||||
|
숫자
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="date" className="text-xs">
|
||||||
|
날짜
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="datetime" className="text-xs">
|
||||||
|
날짜시간
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="url" className="text-xs">
|
||||||
|
URL
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필드 추가 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields.push({
|
||||||
|
fieldName: availableColumns[0] || "",
|
||||||
|
label: "",
|
||||||
|
format: "text",
|
||||||
|
});
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
className="h-8 w-full gap-2 text-xs"
|
||||||
|
disabled={availableColumns.length === 0}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
필드 추가
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
마커 클릭 시 팝업에 표시할 필드를 선택하고 한글 라벨을 지정하세요
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export type ElementSubtype =
|
||||||
| "yard-management-3d" // 야드 관리 3D 위젯
|
| "yard-management-3d" // 야드 관리 3D 위젯
|
||||||
| "work-history" // 작업 이력 위젯
|
| "work-history" // 작업 이력 위젯
|
||||||
| "transport-stats"; // 커스텀 통계 카드 위젯
|
| "transport-stats"; // 커스텀 통계 카드 위젯
|
||||||
// | "custom-metric"; // (구버전 - 주석 처리: 2025-10-28, custom-metric-v2로 대체)
|
// | "custom-metric"; // (구버전 - 주석 처리: 2025-10-28, custom-metric-v2로 대체)
|
||||||
|
|
||||||
// 차트 분류
|
// 차트 분류
|
||||||
export type ChartCategory = "axis-based" | "circular";
|
export type ChartCategory = "axis-based" | "circular";
|
||||||
|
|
@ -164,12 +164,20 @@ export interface ChartDataSource {
|
||||||
markerColor?: string; // 마커 색상 (예: "#ff0000")
|
markerColor?: string; // 마커 색상 (예: "#ff0000")
|
||||||
polygonColor?: string; // 폴리곤 색상 (예: "#0000ff")
|
polygonColor?: string; // 폴리곤 색상 (예: "#0000ff")
|
||||||
polygonOpacity?: number; // 폴리곤 투명도 (0.0 ~ 1.0, 기본값: 0.5)
|
polygonOpacity?: number; // 폴리곤 투명도 (0.0 ~ 1.0, 기본값: 0.5)
|
||||||
|
markerType?: string; // 마커 종류 (circle, arrow)
|
||||||
|
|
||||||
// 컬럼 매핑 (다중 데이터 소스 통합용)
|
// 컬럼 매핑 (다중 데이터 소스 통합용)
|
||||||
columnMapping?: Record<string, string>; // { 원본컬럼: 표시이름 } (예: { "name": "product" })
|
columnMapping?: Record<string, string>; // { 원본컬럼: 표시이름 } (예: { "name": "product" })
|
||||||
|
|
||||||
// 메트릭 설정 (CustomMetricTestWidget용)
|
// 메트릭 설정 (CustomMetricTestWidget용)
|
||||||
selectedColumns?: string[]; // 표시할 컬럼 선택 (빈 배열이면 전체 표시)
|
selectedColumns?: string[]; // 표시할 컬럼 선택 (빈 배열이면 전체 표시)
|
||||||
|
|
||||||
|
// 지도 팝업 설정 (MapTestWidgetV2용)
|
||||||
|
popupFields?: {
|
||||||
|
fieldName: string; // DB 컬럼명 (예: vehicle_number)
|
||||||
|
label: string; // 표시할 한글명 (예: 차량 번호)
|
||||||
|
format?: "text" | "date" | "datetime" | "number" | "url"; // 표시 포맷
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChartConfig {
|
export interface ChartConfig {
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,30 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { QueryResult } from "../types";
|
import { QueryResult } from "../types";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { AlertCircle } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
interface MapConfigSectionProps {
|
interface MapConfigSectionProps {
|
||||||
queryResult: QueryResult | null;
|
queryResult: QueryResult | null;
|
||||||
|
refreshInterval?: number;
|
||||||
|
markerType?: string;
|
||||||
|
onRefreshIntervalChange?: (interval: number) => void;
|
||||||
|
onMarkerTypeChange?: (type: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지도 위젯 설정 섹션
|
* 지도 위젯 설정 섹션
|
||||||
* - 위도/경도 매핑
|
* - 자동 새로고침 간격 설정
|
||||||
*
|
* - 마커 종류 선택
|
||||||
* TODO: 상세 설정 UI 추가 필요
|
|
||||||
*/
|
*/
|
||||||
export function MapConfigSection({ queryResult }: MapConfigSectionProps) {
|
export function MapConfigSection({
|
||||||
|
queryResult,
|
||||||
|
refreshInterval = 5,
|
||||||
|
markerType = "circle",
|
||||||
|
onRefreshIntervalChange,
|
||||||
|
onMarkerTypeChange
|
||||||
|
}: MapConfigSectionProps) {
|
||||||
// 쿼리 결과가 없으면 안내 메시지 표시
|
// 쿼리 결과가 없으면 안내 메시지 표시
|
||||||
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
|
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -34,13 +44,56 @@ export function MapConfigSection({ queryResult }: MapConfigSectionProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||||
<Label className="mb-2 block text-xs font-semibold">지도 설정</Label>
|
<Label className="mb-3 block text-xs font-semibold">지도 설정</Label>
|
||||||
<Alert>
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
<div className="space-y-3">
|
||||||
<AlertDescription className="text-xs">
|
{/* 자동 새로고침 간격 */}
|
||||||
지도 상세 설정 UI는 추후 추가 예정입니다.
|
<div className="space-y-1.5">
|
||||||
</AlertDescription>
|
<Label htmlFor="refresh-interval" className="text-xs">
|
||||||
</Alert>
|
자동 새로고침 간격
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={refreshInterval.toString()}
|
||||||
|
onValueChange={(value) => onRefreshIntervalChange?.(parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="refresh-interval" className="h-9 text-xs">
|
||||||
|
<SelectValue placeholder="새로고침 간격 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0" className="text-xs">없음</SelectItem>
|
||||||
|
<SelectItem value="5" className="text-xs">5초</SelectItem>
|
||||||
|
<SelectItem value="10" className="text-xs">10초</SelectItem>
|
||||||
|
<SelectItem value="30" className="text-xs">30초</SelectItem>
|
||||||
|
<SelectItem value="60" className="text-xs">1분</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
마커 데이터를 자동으로 갱신하는 주기를 설정합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 마커 종류 선택 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="marker-type" className="text-xs">
|
||||||
|
마커 종류
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={markerType}
|
||||||
|
onValueChange={(value) => onMarkerTypeChange?.(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="marker-type" className="h-9 text-xs">
|
||||||
|
<SelectValue placeholder="마커 종류 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="circle" className="text-xs">동그라미</SelectItem>
|
||||||
|
<SelectItem value="arrow" className="text-xs">화살표</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
지도에 표시할 마커의 모양을 선택합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ import {
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
// 위젯 동적 import - 모든 위젯
|
// 위젯 동적 import - 모든 위젯
|
||||||
const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false });
|
// const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false });
|
||||||
const MapTestWidget = dynamic(() => import("./widgets/MapTestWidget"), { ssr: false });
|
// const MapTestWidget = dynamic(() => import("./widgets/MapTestWidget"), { ssr: false });
|
||||||
const MapTestWidgetV2 = dynamic(() => import("./widgets/MapTestWidgetV2"), { ssr: false });
|
const MapTestWidgetV2 = dynamic(() => import("./widgets/MapTestWidgetV2"), { ssr: false });
|
||||||
const ChartTestWidget = dynamic(() => import("./widgets/ChartTestWidget"), { ssr: false });
|
const ChartTestWidget = dynamic(() => import("./widgets/ChartTestWidget"), { ssr: false });
|
||||||
const ListTestWidget = dynamic(
|
const ListTestWidget = dynamic(
|
||||||
|
|
@ -27,7 +27,7 @@ const ListTestWidget = dynamic(
|
||||||
const CustomMetricTestWidget = dynamic(() => import("./widgets/CustomMetricTestWidget"), { ssr: false });
|
const CustomMetricTestWidget = dynamic(() => import("./widgets/CustomMetricTestWidget"), { ssr: false });
|
||||||
const RiskAlertTestWidget = dynamic(() => import("./widgets/RiskAlertTestWidget"), { ssr: false });
|
const RiskAlertTestWidget = dynamic(() => import("./widgets/RiskAlertTestWidget"), { ssr: false });
|
||||||
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false });
|
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false });
|
||||||
const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false });
|
// const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false });
|
||||||
const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false });
|
const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false });
|
||||||
const WeatherMapWidget = dynamic(() => import("./widgets/WeatherMapWidget"), { ssr: false });
|
const WeatherMapWidget = dynamic(() => import("./widgets/WeatherMapWidget"), { ssr: false });
|
||||||
const ExchangeWidget = dynamic(() => import("./widgets/ExchangeWidget"), { ssr: false });
|
const ExchangeWidget = dynamic(() => import("./widgets/ExchangeWidget"), { ssr: false });
|
||||||
|
|
@ -51,10 +51,10 @@ const ClockWidget = dynamic(
|
||||||
() => import("@/components/admin/dashboard/widgets/ClockWidget").then((mod) => ({ default: mod.ClockWidget })),
|
() => import("@/components/admin/dashboard/widgets/ClockWidget").then((mod) => ({ default: mod.ClockWidget })),
|
||||||
{ ssr: false },
|
{ ssr: false },
|
||||||
);
|
);
|
||||||
const ListWidget = dynamic(
|
// const ListWidget = dynamic(
|
||||||
() => import("@/components/admin/dashboard/widgets/ListWidget").then((mod) => ({ default: mod.ListWidget })),
|
// () => import("@/components/admin/dashboard/widgets/ListWidget").then((mod) => ({ default: mod.ListWidget })),
|
||||||
{ ssr: false },
|
// { ssr: false },
|
||||||
);
|
// );
|
||||||
|
|
||||||
const YardManagement3DWidget = dynamic(() => import("@/components/admin/dashboard/widgets/YardManagement3DWidget"), {
|
const YardManagement3DWidget = dynamic(() => import("@/components/admin/dashboard/widgets/YardManagement3DWidget"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
@ -68,9 +68,9 @@ const CustomStatsWidget = dynamic(() => import("./widgets/CustomStatsWidget"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const CustomMetricWidget = dynamic(() => import("./widgets/CustomMetricWidget"), {
|
// const CustomMetricWidget = dynamic(() => import("./widgets/CustomMetricWidget"), {
|
||||||
ssr: false,
|
// ssr: false,
|
||||||
});
|
// });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리
|
* 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리
|
||||||
|
|
@ -91,10 +91,10 @@ function renderWidget(element: DashboardElement) {
|
||||||
return <CalculatorWidget element={element} />;
|
return <CalculatorWidget element={element} />;
|
||||||
case "clock":
|
case "clock":
|
||||||
return <ClockWidget element={element} />;
|
return <ClockWidget element={element} />;
|
||||||
case "map-summary":
|
// case "map-summary":
|
||||||
return <MapSummaryWidget element={element} />;
|
// return <MapSummaryWidget element={element} />;
|
||||||
case "map-test":
|
// case "map-test":
|
||||||
return <MapTestWidget element={element} />;
|
// return <MapTestWidget element={element} />;
|
||||||
case "map-summary-v2":
|
case "map-summary-v2":
|
||||||
return <MapTestWidgetV2 element={element} />;
|
return <MapTestWidgetV2 element={element} />;
|
||||||
case "chart":
|
case "chart":
|
||||||
|
|
@ -105,14 +105,14 @@ function renderWidget(element: DashboardElement) {
|
||||||
return <CustomMetricTestWidget element={element} />;
|
return <CustomMetricTestWidget element={element} />;
|
||||||
case "risk-alert-v2":
|
case "risk-alert-v2":
|
||||||
return <RiskAlertTestWidget element={element} />;
|
return <RiskAlertTestWidget element={element} />;
|
||||||
case "risk-alert":
|
// case "risk-alert":
|
||||||
return <RiskAlertWidget element={element} />;
|
// return <RiskAlertWidget element={element} />;
|
||||||
case "calendar":
|
case "calendar":
|
||||||
return <CalendarWidget element={element} />;
|
return <CalendarWidget element={element} />;
|
||||||
case "status-summary":
|
case "status-summary":
|
||||||
return <StatusSummaryWidget element={element} />;
|
return <StatusSummaryWidget element={element} />;
|
||||||
case "custom-metric":
|
// case "custom-metric":
|
||||||
return <CustomMetricWidget element={element} />;
|
// return <CustomMetricWidget element={element} />;
|
||||||
|
|
||||||
// === 운영/작업 지원 ===
|
// === 운영/작업 지원 ===
|
||||||
case "todo":
|
case "todo":
|
||||||
|
|
@ -122,8 +122,8 @@ function renderWidget(element: DashboardElement) {
|
||||||
return <BookingAlertWidget element={element} />;
|
return <BookingAlertWidget element={element} />;
|
||||||
case "document":
|
case "document":
|
||||||
return <DocumentWidget element={element} />;
|
return <DocumentWidget element={element} />;
|
||||||
case "list":
|
// case "list":
|
||||||
return <ListWidget element={element} />;
|
// return <ListWidget element={element} />;
|
||||||
|
|
||||||
case "yard-management-3d":
|
case "yard-management-3d":
|
||||||
// console.log("🏗️ 야드관리 위젯 렌더링:", {
|
// console.log("🏗️ 야드관리 위젯 렌더링:", {
|
||||||
|
|
@ -171,7 +171,7 @@ function renderWidget(element: DashboardElement) {
|
||||||
// === 기본 fallback ===
|
// === 기본 fallback ===
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-muted to-muted-foreground p-4 text-white">
|
<div className="from-muted to-muted-foreground flex h-full w-full items-center justify-center bg-gradient-to-br p-4 text-white">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-2 text-3xl">❓</div>
|
<div className="mb-2 text-3xl">❓</div>
|
||||||
<div className="text-sm">알 수 없는 위젯 타입: {element.subtype}</div>
|
<div className="text-sm">알 수 없는 위젯 타입: {element.subtype}</div>
|
||||||
|
|
@ -212,7 +212,7 @@ export function DashboardViewer({
|
||||||
dataUrl: string,
|
dataUrl: string,
|
||||||
format: "png" | "pdf",
|
format: "png" | "pdf",
|
||||||
canvasWidth: number,
|
canvasWidth: number,
|
||||||
canvasHeight: number
|
canvasHeight: number,
|
||||||
) => {
|
) => {
|
||||||
if (format === "png") {
|
if (format === "png") {
|
||||||
console.log("💾 PNG 다운로드 시작...");
|
console.log("💾 PNG 다운로드 시작...");
|
||||||
|
|
@ -227,7 +227,7 @@ export function DashboardViewer({
|
||||||
} else {
|
} else {
|
||||||
console.log("📄 PDF 생성 중...");
|
console.log("📄 PDF 생성 중...");
|
||||||
const jsPDF = (await import("jspdf")).default;
|
const jsPDF = (await import("jspdf")).default;
|
||||||
|
|
||||||
// dataUrl에서 이미지 크기 계산
|
// dataUrl에서 이미지 크기 계산
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = dataUrl;
|
img.src = dataUrl;
|
||||||
|
|
@ -274,40 +274,41 @@ export function DashboardViewer({
|
||||||
|
|
||||||
console.log("📸 html-to-image 로딩 중...");
|
console.log("📸 html-to-image 로딩 중...");
|
||||||
// html-to-image 동적 import
|
// html-to-image 동적 import
|
||||||
|
// @ts-expect-error - html-to-image 타입 선언 누락
|
||||||
const { toPng } = await import("html-to-image");
|
const { toPng } = await import("html-to-image");
|
||||||
|
|
||||||
console.log("📸 캔버스 캡처 중...");
|
console.log("📸 캔버스 캡처 중...");
|
||||||
|
|
||||||
// 3D/WebGL 렌더링 완료 대기
|
// 3D/WebGL 렌더링 완료 대기
|
||||||
console.log("⏳ 3D 렌더링 완료 대기 중...");
|
console.log("⏳ 3D 렌더링 완료 대기 중...");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
// WebGL 캔버스를 이미지로 변환 (Three.js 캔버스 보존)
|
// WebGL 캔버스를 이미지로 변환 (Three.js 캔버스 보존)
|
||||||
console.log("🎨 WebGL 캔버스 처리 중...");
|
console.log("🎨 WebGL 캔버스 처리 중...");
|
||||||
const webglCanvases = canvas.querySelectorAll("canvas");
|
const webglCanvases = canvas.querySelectorAll("canvas");
|
||||||
const webglImages: { canvas: HTMLCanvasElement; dataUrl: string; rect: DOMRect }[] = [];
|
const webglImages: { canvas: HTMLCanvasElement; dataUrl: string; rect: DOMRect }[] = [];
|
||||||
|
|
||||||
webglCanvases.forEach((webglCanvas) => {
|
webglCanvases.forEach((webglCanvas) => {
|
||||||
try {
|
try {
|
||||||
const rect = webglCanvas.getBoundingClientRect();
|
const rect = webglCanvas.getBoundingClientRect();
|
||||||
const dataUrl = webglCanvas.toDataURL("image/png");
|
const dataUrl = webglCanvas.toDataURL("image/png");
|
||||||
webglImages.push({ canvas: webglCanvas, dataUrl, rect });
|
webglImages.push({ canvas: webglCanvas, dataUrl, rect });
|
||||||
console.log("✅ WebGL 캔버스 캡처:", {
|
console.log("✅ WebGL 캔버스 캡처:", {
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
left: rect.left,
|
left: rect.left,
|
||||||
top: rect.top,
|
top: rect.top,
|
||||||
bottom: rect.bottom
|
bottom: rect.bottom,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("⚠️ WebGL 캔버스 캡처 실패:", error);
|
console.warn("⚠️ WebGL 캔버스 캡처 실패:", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 캔버스의 실제 크기와 위치 가져오기
|
// 캔버스의 실제 크기와 위치 가져오기
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const canvasWidth = canvas.scrollWidth;
|
const canvasWidth = canvas.scrollWidth;
|
||||||
|
|
||||||
// 실제 콘텐츠의 최하단 위치 계산
|
// 실제 콘텐츠의 최하단 위치 계산
|
||||||
// 뷰어 모드에서는 모든 자식 요소를 확인
|
// 뷰어 모드에서는 모든 자식 요소를 확인
|
||||||
const children = canvas.querySelectorAll("*");
|
const children = canvas.querySelectorAll("*");
|
||||||
|
|
@ -323,17 +324,17 @@ export function DashboardViewer({
|
||||||
maxBottom = relativeBottom;
|
maxBottom = relativeBottom;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 실제 콘텐츠 높이 + 여유 공간 (50px)
|
// 실제 콘텐츠 높이 + 여유 공간 (50px)
|
||||||
// maxBottom이 0이면 기본 캔버스 높이 사용
|
// maxBottom이 0이면 기본 캔버스 높이 사용
|
||||||
const canvasHeight = maxBottom > 50 ? maxBottom + 50 : Math.max(canvas.scrollHeight, rect.height);
|
const canvasHeight = maxBottom > 50 ? maxBottom + 50 : Math.max(canvas.scrollHeight, rect.height);
|
||||||
|
|
||||||
console.log("📐 캔버스 정보:", {
|
console.log("📐 캔버스 정보:", {
|
||||||
rect: { x: rect.x, y: rect.y, left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
rect: { x: rect.x, y: rect.y, left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
||||||
scroll: { width: canvasWidth, height: canvas.scrollHeight },
|
scroll: { width: canvasWidth, height: canvas.scrollHeight },
|
||||||
calculated: { width: canvasWidth, height: canvasHeight },
|
calculated: { width: canvasWidth, height: canvasHeight },
|
||||||
maxBottom: maxBottom,
|
maxBottom: maxBottom,
|
||||||
webglCount: webglImages.length
|
webglCount: webglImages.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
// html-to-image로 캔버스 캡처 (WebGL 제외)
|
// html-to-image로 캔버스 캡처 (WebGL 제외)
|
||||||
|
|
@ -344,8 +345,8 @@ export function DashboardViewer({
|
||||||
pixelRatio: 2, // 고해상도
|
pixelRatio: 2, // 고해상도
|
||||||
cacheBust: true,
|
cacheBust: true,
|
||||||
skipFonts: false,
|
skipFonts: false,
|
||||||
preferredFontFormat: 'woff2',
|
preferredFontFormat: "woff2",
|
||||||
filter: (node) => {
|
filter: (node: Node) => {
|
||||||
// WebGL 캔버스는 제외 (나중에 수동으로 합성)
|
// WebGL 캔버스는 제외 (나중에 수동으로 합성)
|
||||||
if (node instanceof HTMLCanvasElement) {
|
if (node instanceof HTMLCanvasElement) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -353,7 +354,7 @@ export function DashboardViewer({
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// WebGL 캔버스를 이미지 위에 합성
|
// WebGL 캔버스를 이미지 위에 합성
|
||||||
if (webglImages.length > 0) {
|
if (webglImages.length > 0) {
|
||||||
console.log("🖼️ WebGL 이미지 합성 중...");
|
console.log("🖼️ WebGL 이미지 합성 중...");
|
||||||
|
|
@ -362,17 +363,17 @@ export function DashboardViewer({
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
img.onload = resolve;
|
img.onload = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 새 캔버스에 합성
|
// 새 캔버스에 합성
|
||||||
const compositeCanvas = document.createElement("canvas");
|
const compositeCanvas = document.createElement("canvas");
|
||||||
compositeCanvas.width = img.width;
|
compositeCanvas.width = img.width;
|
||||||
compositeCanvas.height = img.height;
|
compositeCanvas.height = img.height;
|
||||||
const ctx = compositeCanvas.getContext("2d");
|
const ctx = compositeCanvas.getContext("2d");
|
||||||
|
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
// 기본 이미지 그리기
|
// 기본 이미지 그리기
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
// WebGL 이미지들을 위치에 맞게 그리기
|
// WebGL 이미지들을 위치에 맞게 그리기
|
||||||
for (const { dataUrl: webglDataUrl, rect: webglRect } of webglImages) {
|
for (const { dataUrl: webglDataUrl, rect: webglRect } of webglImages) {
|
||||||
const webglImg = new Image();
|
const webglImg = new Image();
|
||||||
|
|
@ -380,28 +381,28 @@ export function DashboardViewer({
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
webglImg.onload = resolve;
|
webglImg.onload = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 상대 위치 계산 (pixelRatio 2 고려)
|
// 상대 위치 계산 (pixelRatio 2 고려)
|
||||||
const relativeX = (webglRect.left - rect.left) * 2;
|
const relativeX = (webglRect.left - rect.left) * 2;
|
||||||
const relativeY = (webglRect.top - rect.top) * 2;
|
const relativeY = (webglRect.top - rect.top) * 2;
|
||||||
const width = webglRect.width * 2;
|
const width = webglRect.width * 2;
|
||||||
const height = webglRect.height * 2;
|
const height = webglRect.height * 2;
|
||||||
|
|
||||||
ctx.drawImage(webglImg, relativeX, relativeY, width, height);
|
ctx.drawImage(webglImg, relativeX, relativeY, width, height);
|
||||||
console.log("✅ WebGL 이미지 합성 완료:", { x: relativeX, y: relativeY, width, height });
|
console.log("✅ WebGL 이미지 합성 완료:", { x: relativeX, y: relativeY, width, height });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 합성된 이미지를 dataUrl로 변환
|
// 합성된 이미지를 dataUrl로 변환
|
||||||
const compositeDataUrl = compositeCanvas.toDataURL("image/png");
|
const compositeDataUrl = compositeCanvas.toDataURL("image/png");
|
||||||
console.log("✅ 최종 합성 완료");
|
console.log("✅ 최종 합성 완료");
|
||||||
|
|
||||||
// 합성된 이미지로 다운로드
|
// 합성된 이미지로 다운로드
|
||||||
return await handleDownloadWithDataUrl(compositeDataUrl, format, canvasWidth, canvasHeight);
|
return await handleDownloadWithDataUrl(compositeDataUrl, format, canvasWidth, canvasHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ 캡처 완료 (WebGL 없음)");
|
console.log("✅ 캡처 완료 (WebGL 없음)");
|
||||||
|
|
||||||
// WebGL이 없는 경우 기본 다운로드
|
// WebGL이 없는 경우 기본 다운로드
|
||||||
await handleDownloadWithDataUrl(dataUrl, format, canvasWidth, canvasHeight);
|
await handleDownloadWithDataUrl(dataUrl, format, canvasWidth, canvasHeight);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -409,7 +410,8 @@ export function DashboardViewer({
|
||||||
alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`);
|
alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[backgroundColor, dashboardTitle],
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[backgroundColor, dashboardTitle, handleDownloadWithDataUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 캔버스 설정 계산
|
// 캔버스 설정 계산
|
||||||
|
|
@ -528,11 +530,11 @@ export function DashboardViewer({
|
||||||
// 요소가 없는 경우
|
// 요소가 없는 경우
|
||||||
if (elements.length === 0) {
|
if (elements.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center bg-muted">
|
<div className="bg-muted flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-4 text-6xl">📊</div>
|
<div className="mb-4 text-6xl">📊</div>
|
||||||
<div className="mb-2 text-xl font-medium text-foreground">표시할 요소가 없습니다</div>
|
<div className="text-foreground mb-2 text-xl font-medium">표시할 요소가 없습니다</div>
|
||||||
<div className="text-sm text-muted-foreground">대시보드 편집기에서 차트나 위젯을 추가해보세요</div>
|
<div className="text-muted-foreground text-sm">대시보드 편집기에서 차트나 위젯을 추가해보세요</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -541,8 +543,8 @@ export function DashboardViewer({
|
||||||
return (
|
return (
|
||||||
<DashboardProvider>
|
<DashboardProvider>
|
||||||
{/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
|
{/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
|
||||||
<div className="hidden min-h-screen bg-muted py-8 lg:block" style={{ backgroundColor }}>
|
<div className="bg-muted hidden min-h-screen py-8 lg:block" style={{ backgroundColor }}>
|
||||||
<div className="mx-auto px-4" style={{ width: '100%', maxWidth: 'none' }}>
|
<div className="mx-auto px-4" style={{ width: "100%", maxWidth: "none" }}>
|
||||||
{/* 다운로드 버튼 */}
|
{/* 다운로드 버튼 */}
|
||||||
<div className="mb-4 flex justify-end">
|
<div className="mb-4 flex justify-end">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -584,7 +586,7 @@ export function DashboardViewer({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 태블릿 이하: 반응형 세로 정렬 */}
|
{/* 태블릿 이하: 반응형 세로 정렬 */}
|
||||||
<div className="block min-h-screen bg-muted p-4 lg:hidden" style={{ backgroundColor }}>
|
<div className="bg-muted block min-h-screen p-4 lg:hidden" style={{ backgroundColor }}>
|
||||||
<div className="mx-auto max-w-3xl space-y-4">
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
{/* 다운로드 버튼 */}
|
{/* 다운로드 버튼 */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|
@ -646,38 +648,21 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
||||||
// 태블릿 이하: 세로 스택 카드 스타일
|
// 태블릿 이하: 세로 스택 카드 스타일
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative overflow-hidden rounded-lg border border-border bg-background shadow-sm"
|
className="border-border bg-background relative overflow-hidden rounded-lg border shadow-sm"
|
||||||
style={{ minHeight: "300px" }}
|
style={{ minHeight: "300px" }}
|
||||||
>
|
>
|
||||||
{element.showHeader !== false && (
|
{element.showHeader !== false && (
|
||||||
<div className="flex items-center justify-between px-2 py-1">
|
<div className="flex items-center justify-between px-2 py-1">
|
||||||
<h3 className="text-xs font-semibold text-foreground">{element.customTitle || element.title}</h3>
|
{/* map-summary-v2는 customTitle이 없으면 제목 숨김 */}
|
||||||
<button
|
{element.subtype === "map-summary-v2" && !element.customTitle ? null : (
|
||||||
onClick={onRefresh}
|
<h3 className="text-foreground text-xs font-semibold">{element.customTitle || element.title}</h3>
|
||||||
disabled={isLoading}
|
)}
|
||||||
className="text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
|
||||||
title="새로고침"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={element.showHeader !== false ? "p-2" : "p-2"} style={{ minHeight: "250px" }}>
|
<div className={element.showHeader !== false ? "p-2" : "p-2"} style={{ minHeight: "250px" }}>
|
||||||
{!isMounted ? (
|
{!isMounted ? (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<div className="border-primary h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
) : element.type === "chart" ? (
|
) : element.type === "chart" ? (
|
||||||
<ChartRenderer element={element} data={data} width={undefined} height={250} />
|
<ChartRenderer element={element} data={data} width={undefined} height={250} />
|
||||||
|
|
@ -686,10 +671,10 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-background">
|
<div className="bg-opacity-75 bg-background absolute inset-0 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
<div className="text-sm text-foreground">업데이트 중...</div>
|
<div className="text-foreground text-sm">업데이트 중...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -704,7 +689,7 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute overflow-hidden rounded-lg border border-border bg-background shadow-sm"
|
className="border-border bg-background absolute overflow-hidden rounded-lg border shadow-sm"
|
||||||
style={{
|
style={{
|
||||||
left: `${leftPercentage}%`,
|
left: `${leftPercentage}%`,
|
||||||
top: element.position.y,
|
top: element.position.y,
|
||||||
|
|
@ -714,33 +699,16 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
||||||
>
|
>
|
||||||
{element.showHeader !== false && (
|
{element.showHeader !== false && (
|
||||||
<div className="flex items-center justify-between px-2 py-1">
|
<div className="flex items-center justify-between px-2 py-1">
|
||||||
<h3 className="text-xs font-semibold text-foreground">{element.customTitle || element.title}</h3>
|
{/* map-summary-v2는 customTitle이 없으면 제목 숨김 */}
|
||||||
<button
|
{element.subtype === "map-summary-v2" && !element.customTitle ? null : (
|
||||||
onClick={onRefresh}
|
<h3 className="text-foreground text-xs font-semibold">{element.customTitle || element.title}</h3>
|
||||||
disabled={isLoading}
|
)}
|
||||||
className="text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
|
||||||
title="새로고침"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={element.showHeader !== false ? "h-[calc(100%-32px)] w-full" : "h-full w-full"}>
|
<div className={element.showHeader !== false ? "h-[calc(100%-32px)] w-full" : "h-full w-full"}>
|
||||||
{!isMounted ? (
|
{!isMounted ? (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<div className="border-primary h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
) : element.type === "chart" ? (
|
) : element.type === "chart" ? (
|
||||||
<ChartRenderer
|
<ChartRenderer
|
||||||
|
|
@ -754,10 +722,10 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-background">
|
<div className="bg-opacity-75 bg-background absolute inset-0 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
<div className="text-sm text-foreground">업데이트 중...</div>
|
<div className="text-foreground text-sm">업데이트 중...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue