Merge lhj branch: 차량 위젯 3개 분리 및 배송/화물 현황 필터 기능 추가
This commit is contained in:
@@ -22,7 +22,17 @@ const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/Ca
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const VehicleMapWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapWidget"), {
|
||||
const VehicleStatusWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleStatusWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const VehicleListWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleListWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapOnlyWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
@@ -448,15 +458,25 @@ export function CanvasElement({
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<CalculatorWidget />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "vehicle-status" ? (
|
||||
// 차량 상태 현황 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<VehicleStatusWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "vehicle-list" ? (
|
||||
// 차량 목록 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<VehicleListWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "vehicle-map" ? (
|
||||
// 차량 위치 지도 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<VehicleMapWidget />
|
||||
<VehicleMapOnlyWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "delivery-status" ? (
|
||||
// 배송/화물 현황 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<DeliveryStatusWidget />
|
||||
<DeliveryStatusWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "risk-alert" ? (
|
||||
// 리스크/알림 위젯 렌더링
|
||||
|
||||
@@ -120,7 +120,23 @@ export function DashboardSidebar() {
|
||||
className="border-l-4 border-teal-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🚚"
|
||||
icon="📊"
|
||||
title="차량 상태 현황"
|
||||
type="widget"
|
||||
subtype="vehicle-status"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-green-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📋"
|
||||
title="차량 목록"
|
||||
type="widget"
|
||||
subtype="vehicle-list"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🗺️"
|
||||
title="차량 위치 지도"
|
||||
type="widget"
|
||||
subtype="vehicle-map"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types";
|
||||
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult, ClockConfig } from "./types";
|
||||
import { QueryEditor } from "./QueryEditor";
|
||||
import { ChartConfigPanel } from "./ChartConfigPanel";
|
||||
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
||||
import { ClockConfigModal } from "./widgets/ClockConfigModal";
|
||||
|
||||
interface ElementConfigModalProps {
|
||||
element: DashboardElement;
|
||||
@@ -26,6 +28,12 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"query" | "chart">("query");
|
||||
|
||||
// 차트 설정이 필요 없는 위젯 (쿼리만 필요)
|
||||
const isQueryOnlyWidget =
|
||||
element.subtype === "vehicle-status" ||
|
||||
element.subtype === "vehicle-list" ||
|
||||
element.subtype === "delivery-status";
|
||||
|
||||
// 데이터 소스 변경 처리
|
||||
const handleDataSourceChange = useCallback((newDataSource: ChartDataSource) => {
|
||||
setDataSource(newDataSource);
|
||||
@@ -39,11 +47,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
// 쿼리 테스트 결과 처리
|
||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
setQueryResult(result);
|
||||
// 쿼리 결과가 나오면 자동으로 차트 설정 탭으로 이동
|
||||
if (result.rows.length > 0) {
|
||||
// 쿼리만 필요한 위젯은 자동 이동 안 함
|
||||
if (result.rows.length > 0 && !isQueryOnlyWidget) {
|
||||
setActiveTab("chart");
|
||||
}
|
||||
}, []);
|
||||
}, [isQueryOnlyWidget]);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = useCallback(() => {
|
||||
@@ -56,22 +64,51 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
onClose();
|
||||
}, [element, dataSource, chartConfig, onSave, onClose]);
|
||||
|
||||
// 시계 위젯 설정 저장
|
||||
const handleClockConfigSave = useCallback(
|
||||
(clockConfig: ClockConfig) => {
|
||||
const updatedElement: DashboardElement = {
|
||||
...element,
|
||||
clockConfig,
|
||||
};
|
||||
onSave(updatedElement);
|
||||
},
|
||||
[element, onSave],
|
||||
);
|
||||
|
||||
// 모달이 열려있지 않으면 렌더링하지 않음
|
||||
if (!isOpen) return null;
|
||||
|
||||
// 시계, 달력, 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
|
||||
if (
|
||||
element.type === "widget" &&
|
||||
(element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
|
||||
) {
|
||||
// 시계 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
|
||||
if (element.type === "widget" && element.subtype === "clock") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 이전 코드 호환성 유지 (아래 주석 처리된 코드는 제거 예정)
|
||||
if (false && element.type === "widget" && element.subtype === "clock") {
|
||||
return (
|
||||
<ClockConfigModal
|
||||
config={
|
||||
element.clockConfig || {
|
||||
style: "digital",
|
||||
timezone: "Asia/Seoul",
|
||||
showDate: true,
|
||||
showSeconds: true,
|
||||
format24h: true,
|
||||
theme: "light",
|
||||
}
|
||||
}
|
||||
onSave={handleClockConfigSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-lg bg-white shadow-xl">
|
||||
<div className="bg-opacity-50 fixed inset-0 z-[9999] flex items-center justify-center bg-black">
|
||||
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-lg bg-white shadow-xl overflow-hidden">
|
||||
{/* 모달 헤더 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-6 flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">{element.title} 설정</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">데이터 소스와 차트 설정을 구성하세요</p>
|
||||
@@ -82,7 +119,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
</div>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<div className="flex border-b border-gray-200">
|
||||
<div className="flex border-b border-gray-200 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveTab("query")}
|
||||
className={`border-b-2 px-6 py-3 text-sm font-medium transition-colors ${
|
||||
@@ -93,35 +130,45 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
>
|
||||
📝 쿼리 & 데이터
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("chart")}
|
||||
className={`border-b-2 px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === "chart"
|
||||
? "border-primary text-primary bg-accent"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
} `}
|
||||
>
|
||||
📊 차트 설정
|
||||
{queryResult && (
|
||||
<span className="ml-2 rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800">
|
||||
{queryResult.rows.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{!isQueryOnlyWidget && (
|
||||
<button
|
||||
onClick={() => setActiveTab("chart")}
|
||||
className={`border-b-2 px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === "chart"
|
||||
? "border-primary text-primary bg-accent"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
} `}
|
||||
>
|
||||
{element.subtype === "vehicle-map" ? "🗺️ 지도 설정" : "📊 차트 설정"}
|
||||
{queryResult && (
|
||||
<span className="ml-2 rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800">
|
||||
{queryResult.rows.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 탭 내용 */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{/* 탭 내용 - 스크롤 가능하도록 수정 */}
|
||||
<div className="flex-1 overflow-y-auto p-6 relative">
|
||||
{activeTab === "query" && (
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceChange}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
<div className="relative z-10">
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceChange}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "chart" && (
|
||||
<ChartConfigPanel config={chartConfig} queryResult={queryResult} onConfigChange={handleChartConfigChange} />
|
||||
<div className="relative z-10">
|
||||
{element.subtype === "vehicle-map" ? (
|
||||
<VehicleMapConfigPanel config={chartConfig} queryResult={queryResult} onConfigChange={handleChartConfigChange} />
|
||||
) : (
|
||||
<ChartConfigPanel config={chartConfig} queryResult={queryResult} onConfigChange={handleChartConfigChange} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -144,7 +191,15 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dataSource.query || !chartConfig.xAxis || !chartConfig.yAxis}
|
||||
disabled={
|
||||
!dataSource.query ||
|
||||
(!isQueryOnlyWidget &&
|
||||
(element.subtype === "vehicle-map"
|
||||
? (!chartConfig.latitudeColumn || !chartConfig.longitudeColumn)
|
||||
: (!chartConfig.xAxis || !chartConfig.yAxis)
|
||||
)
|
||||
)
|
||||
}
|
||||
className="bg-accent0 rounded-lg px-4 py-2 text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||
>
|
||||
저장
|
||||
|
||||
@@ -23,21 +23,27 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
|
||||
|
||||
// 쿼리 실행
|
||||
const executeQuery = useCallback(async () => {
|
||||
console.log('🚀 executeQuery 호출됨!');
|
||||
console.log('📝 현재 쿼리:', query);
|
||||
console.log('✅ query.trim():', query.trim());
|
||||
|
||||
if (!query.trim()) {
|
||||
setError('쿼리를 입력해주세요.');
|
||||
console.log('❌ 쿼리가 비어있음!');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExecuting(true);
|
||||
setError(null);
|
||||
console.log('🔄 쿼리 실행 시작...');
|
||||
|
||||
try {
|
||||
// 실제 API 호출
|
||||
const response = await fetch('http://localhost:8080/api/dashboards/execute-query', {
|
||||
const response = await fetch('/api/dashboards/execute-query', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token') || 'test-token'}` // JWT 토큰 사용
|
||||
'Authorization': `Bearer ${localStorage.getItem('authToken') || localStorage.getItem('token') || 'test-token'}` // JWT 토큰 사용
|
||||
},
|
||||
body: JSON.stringify({ query: query.trim() })
|
||||
});
|
||||
@@ -150,7 +156,12 @@ ORDER BY Q4 DESC;`
|
||||
<h4 className="text-lg font-semibold text-gray-800">📝 SQL 쿼리 에디터</h4>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={executeQuery}
|
||||
onClick={() => {
|
||||
console.log('🖱️ 실행 버튼 클릭됨!');
|
||||
console.log('📝 query:', query);
|
||||
console.log('🔒 disabled:', isExecuting || !query.trim());
|
||||
executeQuery();
|
||||
}}
|
||||
disabled={isExecuting || !query.trim()}
|
||||
className="
|
||||
px-3 py-1 bg-accent0 text-white rounded text-sm
|
||||
|
||||
162
frontend/components/admin/dashboard/VehicleMapConfigPanel.tsx
Normal file
162
frontend/components/admin/dashboard/VehicleMapConfigPanel.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { ChartConfig, QueryResult } from './types';
|
||||
|
||||
interface VehicleMapConfigPanelProps {
|
||||
config?: ChartConfig;
|
||||
queryResult?: QueryResult;
|
||||
onConfigChange: (config: ChartConfig) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 차량 위치 지도 설정 패널
|
||||
* - 위도/경도 컬럼 매핑
|
||||
* - 라벨/상태 컬럼 설정
|
||||
*/
|
||||
export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: VehicleMapConfigPanelProps) {
|
||||
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
||||
|
||||
// 설정 업데이트
|
||||
const updateConfig = useCallback((updates: Partial<ChartConfig>) => {
|
||||
const newConfig = { ...currentConfig, ...updates };
|
||||
setCurrentConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}, [currentConfig, onConfigChange]);
|
||||
|
||||
// 사용 가능한 컬럼 목록
|
||||
const availableColumns = queryResult?.columns || [];
|
||||
const sampleData = queryResult?.rows?.[0] || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold text-gray-800">🗺️ 지도 설정</h4>
|
||||
|
||||
{/* 쿼리 결과가 없을 때 */}
|
||||
{!queryResult && (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-yellow-800 text-sm">
|
||||
💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 지도를 설정할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 필드 매핑 */}
|
||||
{queryResult && (
|
||||
<>
|
||||
{/* 지도 제목 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">지도 제목</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentConfig.title || ''}
|
||||
onChange={(e) => updateConfig({ title: e.target.value })}
|
||||
placeholder="차량 위치 지도"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 위도 컬럼 설정 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
위도 컬럼 (Latitude)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.latitudeColumn || ''}
|
||||
onChange={(e) => updateConfig({ latitudeColumn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 경도 컬럼 설정 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
경도 컬럼 (Longitude)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.longitudeColumn || ''}
|
||||
onChange={(e) => updateConfig({ longitudeColumn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 라벨 컬럼 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
라벨 컬럼 (마커 표시명)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.labelColumn || ''}
|
||||
onChange={(e) => updateConfig({ labelColumn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 상태 컬럼 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
상태 컬럼 (마커 색상)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.statusColumn || ''}
|
||||
onChange={(e) => updateConfig({ statusColumn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 설정 미리보기 */}
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">📋 설정 미리보기</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div><strong>위도:</strong> {currentConfig.latitudeColumn || '미설정'}</div>
|
||||
<div><strong>경도:</strong> {currentConfig.longitudeColumn || '미설정'}</div>
|
||||
<div><strong>라벨:</strong> {currentConfig.labelColumn || '없음'}</div>
|
||||
<div><strong>상태:</strong> {currentConfig.statusColumn || '없음'}</div>
|
||||
<div><strong>데이터 개수:</strong> {queryResult.rows.length}개</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필수 필드 확인 */}
|
||||
{(!currentConfig.latitudeColumn || !currentConfig.longitudeColumn) && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="text-red-800 text-sm">
|
||||
⚠️ 위도와 경도 컬럼을 반드시 선택해야 지도에 표시할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export type ElementSubtype =
|
||||
| "clock"
|
||||
| "calendar"
|
||||
| "calculator"
|
||||
| "vehicle-status"
|
||||
| "vehicle-list"
|
||||
| "vehicle-map"
|
||||
| "delivery-status"
|
||||
| "risk-alert"
|
||||
@@ -78,6 +80,12 @@ export interface ChartConfig {
|
||||
colors?: string[]; // 차트 색상
|
||||
title?: string; // 차트 제목
|
||||
showLegend?: boolean; // 범례 표시 여부
|
||||
|
||||
// 지도 관련 설정
|
||||
latitudeColumn?: string; // 위도 컬럼
|
||||
longitudeColumn?: string; // 경도 컬럼
|
||||
labelColumn?: string; // 라벨 컬럼
|
||||
statusColumn?: string; // 상태 컬럼
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
|
||||
Reference in New Issue
Block a user