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:
hyeonsu 2025-11-13 12:14:33 +09:00
commit 565ab0b1c0
8 changed files with 1233 additions and 672 deletions

View File

@ -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">

View File

@ -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>

View File

@ -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>
); );
} }

View File

@ -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;
@ -47,11 +47,13 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
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([]);
@ -79,7 +81,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
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) {
@ -93,7 +95,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
// 컬럼 타입 분석 // 컬럼 타입 분석
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";
@ -142,7 +144,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
// 컬럼 타입 분석 // 컬럼 타입 분석
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";
@ -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,33 +321,41 @@ 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>
{/* 색상 팔레트 */} {/* 색상 팔레트 */}
@ -361,11 +375,13 @@ 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"
@ -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,12 +476,9 @@ 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 ||
@ -487,7 +492,7 @@ ORDER BY 하위부서수 DESC`,
date: "📅", date: "📅",
boolean: "✓", boolean: "✓",
object: "📦", object: "📦",
unknown: "❓" unknown: "❓",
}[type]; }[type];
const typeColor = { const typeColor = {
@ -496,42 +501,44 @@ ORDER BY 하위부서수 DESC`,
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>
)} )}

View File

@ -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 {

View File

@ -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>
); );
} }

View File

@ -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 다운로드 시작...");
@ -274,6 +274,7 @@ 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("📸 캔버스 캡처 중...");
@ -297,7 +298,7 @@ export function DashboardViewer({
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);
@ -333,7 +334,7 @@ export function DashboardViewer({
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;
@ -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