Merge pull request '대시보드 수정사항 적용' (#207) from common/feat/dashboard-map into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/207
This commit is contained in:
hyeonsu 2025-11-14 12:11:07 +09:00
commit 0c1292c55b
9 changed files with 214 additions and 143 deletions

View File

@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
import { ApiConfig } from "./data-sources/ApiConfig";
import { QueryEditor } from "./QueryEditor";
@ -146,6 +147,9 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
// 커스텀 메트릭 설정
const [customMetricConfig, setCustomMetricConfig] = useState<CustomMetricConfig>({});
// 자동 새로고침 간격 (지도 위젯용)
const [refreshInterval, setRefreshInterval] = useState<number>(5);
// 사이드바 열릴 때 초기화
useEffect(() => {
if (isOpen && element) {
@ -155,6 +159,8 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 가져옴
setDataSources(element.dataSources || element.chartConfig?.dataSources || []);
setQueryResult(null);
// 자동 새로고침 간격 초기화
setRefreshInterval(element.chartConfig?.refreshInterval ?? 5);
// 리스트 위젯 설정 초기화
if (element.subtype === "list-v2" && element.listConfig) {
@ -290,6 +296,24 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
element.subtype === "custom-metric-v2" ||
element.subtype === "risk-alert-v2";
// chartConfig 구성 (위젯 타입별로 다르게 처리)
let finalChartConfig = { ...chartConfig };
if (isMultiDataSourceWidget) {
finalChartConfig = {
...finalChartConfig,
dataSources: dataSources,
};
}
// 지도 위젯인 경우 refreshInterval 추가
if (element.subtype === "map-summary-v2") {
finalChartConfig = {
...finalChartConfig,
refreshInterval,
};
}
const updatedElement: DashboardElement = {
...element,
customTitle: customTitle.trim() || undefined,
@ -302,8 +326,6 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
...(isMultiDataSourceWidget
? {
dataSources: dataSources,
// chartConfig에도 dataSources 포함 (일부 위젯은 chartConfig에서 읽음)
chartConfig: { ...chartConfig, dataSources: dataSources },
}
: {}),
}
@ -314,21 +336,10 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
listConfig,
}
: {}),
// 차트 설정 (차트 타입이거나 차트 기능이 있는 위젯)
...(element.type === "chart" ||
element.subtype === "chart" ||
["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(element.subtype)
// 차트 설정 (모든 위젯 공통)
...(needsDataSource(element.subtype)
? {
// 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함 (빈 배열도 허용 - 연결 해제)
chartConfig: isMultiDataSourceWidget
? { ...chartConfig, dataSources: dataSources }
: chartConfig,
// 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함 (빈 배열도 허용 - 연결 해제)
...(isMultiDataSourceWidget
? {
dataSources: dataSources,
}
: {}),
chartConfig: finalChartConfig,
}
: {}),
// 커스텀 메트릭 설정
@ -341,6 +352,10 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
console.log("🔧 [WidgetConfigSidebar] handleApply 호출:", {
subtype: element.subtype,
isMultiDataSourceWidget,
dataSources,
listConfig,
finalChartConfig,
customMetricConfig,
updatedElement,
});
@ -356,6 +371,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
listConfig,
chartConfig,
customMetricConfig,
refreshInterval,
onApply,
onClose,
]);
@ -432,6 +448,40 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
<Switch id="show-header" checked={showHeader} onCheckedChange={setShowHeader} />
</div>
</div>
{/* 자동 새로고침 설정 (지도 위젯 전용) */}
{element.subtype === "map-summary-v2" && (
<div className="bg-background rounded-lg p-3 shadow-sm">
<Label htmlFor="refresh-interval" className="mb-2 block text-xs font-semibold">
</Label>
<Select value={refreshInterval.toString()} onValueChange={(value) => setRefreshInterval(parseInt(value))}>
<SelectTrigger id="refresh-interval" className="h-9 text-sm">
<SelectValue placeholder="간격 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0" className="text-sm">
</SelectItem>
<SelectItem value="5" className="text-sm">
5
</SelectItem>
<SelectItem value="10" className="text-sm">
10
</SelectItem>
<SelectItem value="30" className="text-sm">
30
</SelectItem>
<SelectItem value="60" className="text-sm">
1
</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1.5 text-xs">
</p>
</div>
)}
</div>
</TabsContent>

View File

@ -530,30 +530,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
))}
</div>
{/* 마커 polling 간격 설정 (MapTestWidgetV2 전용) */}
<div className="space-y-2">
<Label htmlFor="marker-refresh-interval" className="text-xs">
</Label>
<Select
value={(dataSource.refreshInterval ?? 5).toString()}
onValueChange={(value) => onChange({ refreshInterval: parseInt(value) })}
>
<SelectTrigger id="marker-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>
{/* 마커 종류 선택 (MapTestWidgetV2 전용) */}
<div className="space-y-2">

View File

@ -321,37 +321,28 @@ ORDER BY 하위부서수 DESC`,
/>
</div>
{/* 마커 polling 간격 설정 (MapTestWidgetV2 전용) */}
{/* 마커 종류 선택 (MapTestWidgetV2 전용) */}
<div className="space-y-2">
<Label htmlFor="marker-refresh-interval" className="text-xs">
<Label htmlFor="marker-type" className="text-xs">
</Label>
<Select
value={(dataSource.refreshInterval ?? 5).toString()}
onValueChange={(value) => onChange({ refreshInterval: parseInt(value) })}
value={dataSource.markerType || "circle"}
onValueChange={(value) => onChange({ markerType: value })}
>
<SelectTrigger id="marker-refresh-interval" className="h-8 text-xs">
<SelectValue placeholder="간격 선택" />
<SelectTrigger id="marker-type" className="h-8 text-xs">
<SelectValue placeholder="마커 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0" className="text-xs">
<SelectItem value="circle" 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 value="arrow" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground text-[10px]"> </p>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}

View File

@ -155,7 +155,6 @@ export interface ChartDataSource {
jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results")
// 공통
refreshInterval?: number; // 자동 새로고침 (초, 0이면 수동)
lastExecuted?: string; // 마지막 실행 시간
lastError?: string; // 마지막 오류 메시지
mapDisplayType?: "auto" | "marker" | "polygon"; // 지도 표시 방식 (auto: 자동, marker: 마커, polygon: 영역)
@ -184,6 +183,9 @@ export interface ChartConfig {
// 다중 데이터 소스 (테스트 위젯용)
dataSources?: ChartDataSource[]; // 여러 데이터 소스 (REST API + Database 혼합 가능)
// 위젯 레벨 설정 (MapTestWidgetV2용)
refreshInterval?: number; // 위젯 전체 자동 새로고침 간격 (초, 0이면 수동)
// 멀티 차트 설정 (ChartTestWidget용)
chartType?: string; // 차트 타입 (line, bar, pie, etc.)
mergeMode?: boolean; // 데이터 병합 모드 (여러 데이터 소스를 하나의 라인/바로 합침)

View File

@ -209,10 +209,10 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
if (loading) {
return (
<div className="flex h-full items-center justify-center bg-background">
<div className="bg-background flex h-full items-center justify-center">
<div className="text-center">
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
<p className="mt-2 text-sm text-muted-foreground"> ...</p>
<p className="text-muted-foreground mt-2 text-sm"> ...</p>
</div>
</div>
);
@ -220,12 +220,12 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
if (error) {
return (
<div className="flex h-full items-center justify-center bg-background p-4">
<div className="bg-background flex h-full items-center justify-center p-4">
<div className="text-center">
<p className="text-sm text-destructive"> {error}</p>
<p className="text-destructive text-sm"> {error}</p>
<button
onClick={loadData}
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
className="bg-destructive/10 text-destructive hover:bg-destructive/20 mt-2 rounded px-3 py-1 text-xs"
>
</button>
@ -244,10 +244,10 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
// 설정이 없으면 안내 화면
if (!hasDataSource || !hasConfig) {
return (
<div className="flex h-full items-center justify-center bg-background p-4">
<div className="bg-background flex h-full items-center justify-center p-4">
<div className="max-w-xs space-y-2 text-center">
<h3 className="text-sm font-bold text-foreground"> </h3>
<div className="space-y-1.5 text-xs text-foreground">
<h3 className="text-foreground text-sm font-bold"> </h3>
<div className="text-foreground space-y-1.5 text-xs">
<p className="font-medium">📊 </p>
<ul className="space-y-0.5 text-left">
<li> </li>
@ -256,7 +256,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
<li> COUNT, SUM, AVG, MIN, MAX </li>
</ul>
</div>
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
<div className="bg-primary/10 text-primary mt-2 rounded-lg p-2 text-[10px]">
<p className="font-medium"> </p>
<p>1. </p>
<p>2. ()</p>
@ -274,7 +274,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
// 통계 카드 렌더링 (전체 크기 꽉 차게)
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-lg border bg-card p-6 text-center shadow-sm">
<div className="bg-card flex h-full w-full flex-col items-center justify-center rounded-lg p-6 text-center">
{/* 제목 */}
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
@ -283,11 +283,6 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
</div>
{/* 필터 표시 (디버깅용, 작게) */}
{config?.filters && config.filters.length > 0 && (
<div className="text-muted-foreground mt-2 text-xs">: {config.filters.length} </div>
)}
</div>
);
}

View File

@ -268,23 +268,14 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
// 통계 카드 렌더링
return (
<div className="flex h-full w-full items-center justify-center bg-background p-4">
<div className="flex flex-col items-center justify-center rounded-lg border bg-card p-6 text-center shadow-sm">
{/* 제목 */}
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
<div className="flex h-full w-full flex-col items-center justify-center bg-card p-6 text-center">
{/* 제목 */}
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
{/* 값 */}
<div className="flex items-baseline gap-1">
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
</div>
{/* 필터 표시 (디버깅용, 작게) */}
{config?.filters && config.filters.length > 0 && (
<div className="text-muted-foreground mt-2 text-xs">
: {config.filters.length}
</div>
)}
{/* 값 */}
<div className="flex items-baseline gap-1">
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
</div>
</div>
);

View File

@ -37,8 +37,19 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
// // console.log("🧪 ListTestWidget 렌더링!", element);
const dataSources = useMemo(() => {
return element?.dataSources || element?.chartConfig?.dataSources;
}, [element?.dataSources, element?.chartConfig?.dataSources]);
// 다중 데이터 소스 우선
const multiSources = element?.dataSources || element?.chartConfig?.dataSources;
if (multiSources && multiSources.length > 0) {
return multiSources;
}
// 단일 데이터 소스 fallback (배열로 변환)
if (element?.dataSource) {
return [element.dataSource];
}
return [];
}, [element?.dataSources, element?.chartConfig?.dataSources, element?.dataSource]);
// // console.log("📊 dataSources 확인:", {
// hasDataSources: !!dataSources,
@ -58,6 +69,27 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
cardColumns: 3,
};
// visible 컬럼 설정 객체 배열 (field + label)
const visibleColumnConfigs = useMemo(() => {
if (config.columns && config.columns.length > 0 && typeof config.columns[0] === "object") {
return config.columns.filter((col: any) => col.visible !== false);
}
return [];
}, [config.columns]);
// 표시할 컬럼 필드명 (데이터 접근용)
const displayColumns = useMemo(() => {
if (!data?.columns) return [];
// 컬럼 설정이 있으면 field 사용
if (visibleColumnConfigs.length > 0) {
return visibleColumnConfigs.map((col: any) => col.field);
}
// 자동 모드: 모든 컬럼 표시
return data.columns;
}, [data?.columns, visibleColumnConfigs]);
// 다중 데이터 소스 로딩
const loadMultipleDataSources = useCallback(async () => {
if (!dataSources || dataSources.length === 0) {
@ -313,50 +345,66 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
const paginatedRows = data?.rows.slice(startIndex, endIndex) || [];
// 테이블 뷰
const renderTable = () => (
<div className="overflow-auto">
<Table>
{config.showHeader && (
<TableHeader>
<TableRow>
{data?.columns.map((col) => (
<TableHead key={col} className="whitespace-nowrap">
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
)}
<TableBody>
{paginatedRows.map((row, idx) => (
<TableRow key={idx} className={config.stripedRows && idx % 2 === 0 ? "bg-muted/50" : ""}>
{data?.columns.map((col) => (
<TableCell key={col} className="whitespace-nowrap">
{String(row[col] ?? "")}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
const renderTable = () => {
// 헤더명 가져오기 (label 우선, 없으면 field 그대로)
const getHeaderLabel = (field: string) => {
const colConfig = visibleColumnConfigs.find((col: any) => col.field === field);
return colConfig?.label || field;
};
return (
<div className="overflow-auto">
<Table>
{config.showHeader && (
<TableHeader>
<TableRow>
{displayColumns.map((field) => (
<TableHead key={field} className="whitespace-nowrap">
{getHeaderLabel(field)}
</TableHead>
))}
</TableRow>
</TableHeader>
)}
<TableBody>
{paginatedRows.map((row, idx) => (
<TableRow key={idx} className={config.stripedRows && idx % 2 === 0 ? "bg-muted/50" : ""}>
{displayColumns.map((field) => (
<TableCell key={field} className="whitespace-nowrap">
{String(row[field] ?? "")}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
// 카드 뷰
const renderCards = () => (
<div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}>
{paginatedRows.map((row, idx) => (
<Card key={idx} className="p-4">
{data?.columns.map((col) => (
<div key={col} className="mb-2">
<span className="font-semibold">{col}: </span>
<span>{String(row[col] ?? "")}</span>
</div>
))}
</Card>
))}
</div>
);
const renderCards = () => {
// 헤더명 가져오기 (label 우선, 없으면 field 그대로)
const getLabel = (field: string) => {
const colConfig = visibleColumnConfigs.find((col: any) => col.field === field);
return colConfig?.label || field;
};
return (
<div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}>
{paginatedRows.map((row, idx) => (
<Card key={idx} className="p-4">
{displayColumns.map((field) => (
<div key={field} className="mb-2">
<span className="font-semibold">{getLabel(field)}: </span>
<span>{String(row[field] ?? "")}</span>
</div>
))}
</Card>
))}
</div>
);
};
return (
<div className="flex h-full flex-col bg-card shadow-sm">
@ -396,7 +444,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
<div className="flex h-full items-center justify-center">
<p className="text-sm text-destructive">{error}</p>
</div>
) : !(element?.dataSources || element?.chartConfig?.dataSources) || (element?.dataSources || element?.chartConfig?.dataSources)?.length === 0 ? (
) : !dataSources || dataSources.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">

View File

@ -916,9 +916,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
// 즉시 첫 로드 (마커 데이터)
loadMultipleDataSources();
// 첫 번째 데이터 소스의 새로고침 간격 사용 (초)
const firstDataSource = dataSources[0];
const refreshInterval = firstDataSource?.refreshInterval ?? 5;
// 위젯 레벨의 새로고침 간격 사용 (초)
const refreshInterval = element?.chartConfig?.refreshInterval ?? 5;
// 0이면 자동 새로고침 비활성화
if (refreshInterval === 0) {
@ -933,7 +932,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
clearInterval(intervalId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSources]);
}, [dataSources, element?.chartConfig?.refreshInterval]);
// 타일맵 URL (chartConfig에서 가져오기)
const tileMapUrl =

View File

@ -634,7 +634,26 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
)}
<p className="text-[10px] text-foreground mt-0.5 line-clamp-2">{alert.description}</p>
<div className="mt-1 flex items-center gap-2 text-[9px] text-muted-foreground">
<span>{new Date(alert.timestamp).toLocaleString("ko-KR")}</span>
<span>
{(() => {
const ts = String(alert.timestamp);
// yyyyMMddHHmm 형식 감지 (예: 20251114 1000)
if (/^\d{12}$/.test(ts)) {
const year = ts.substring(0, 4);
const month = ts.substring(4, 6);
const day = ts.substring(6, 8);
const hour = ts.substring(8, 10);
const minute = ts.substring(10, 12);
const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:00`);
return isNaN(date.getTime()) ? ts : date.toLocaleString("ko-KR");
}
// ISO 형식 또는 일반 날짜 형식
const date = new Date(ts);
return isNaN(date.getTime()) ? ts : date.toLocaleString("ko-KR");
})()}
</span>
{alert.source && <span>· {alert.source}</span>}
</div>
</div>