Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/dashboard
This commit is contained in:
commit
3ecdf73bc5
|
|
@ -54,6 +54,7 @@ import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리
|
|||
import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리
|
||||
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -202,6 +203,7 @@ app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리
|
|||
app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
|
||||
app.use("/api/todos", todoRoutes); // To-Do 관리
|
||||
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
||||
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
import { Request, Response } from "express";
|
||||
import { MapDataService } from "../services/mapDataService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 지도 데이터 조회 컨트롤러
|
||||
* 외부 DB 연결에서 위도/경도 데이터를 가져와 지도에 표시할 수 있도록 변환
|
||||
*/
|
||||
export class MapDataController {
|
||||
private mapDataService: MapDataService;
|
||||
|
||||
constructor() {
|
||||
this.mapDataService = new MapDataService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB에서 지도 데이터 조회
|
||||
*/
|
||||
getMapData = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { connectionId } = req.params;
|
||||
const {
|
||||
tableName,
|
||||
latColumn,
|
||||
lngColumn,
|
||||
labelColumn,
|
||||
statusColumn,
|
||||
additionalColumns,
|
||||
whereClause,
|
||||
} = req.query;
|
||||
|
||||
logger.info("🗺️ 지도 데이터 조회 요청:", {
|
||||
connectionId,
|
||||
tableName,
|
||||
latColumn,
|
||||
lngColumn,
|
||||
});
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!tableName || !latColumn || !lngColumn) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, latColumn, lngColumn은 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const markers = await this.mapDataService.getMapData({
|
||||
connectionId: parseInt(connectionId as string),
|
||||
tableName: tableName as string,
|
||||
latColumn: latColumn as string,
|
||||
lngColumn: lngColumn as string,
|
||||
labelColumn: labelColumn as string,
|
||||
statusColumn: statusColumn as string,
|
||||
additionalColumns: additionalColumns
|
||||
? (additionalColumns as string).split(",")
|
||||
: [],
|
||||
whereClause: whereClause as string,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
markers,
|
||||
count: markers.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("❌ 지도 데이터 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "지도 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 내부 DB에서 지도 데이터 조회
|
||||
*/
|
||||
getInternalMapData = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
tableName,
|
||||
latColumn,
|
||||
lngColumn,
|
||||
labelColumn,
|
||||
statusColumn,
|
||||
additionalColumns,
|
||||
whereClause,
|
||||
} = req.query;
|
||||
|
||||
logger.info("🗺️ 내부 DB 지도 데이터 조회 요청:", {
|
||||
tableName,
|
||||
latColumn,
|
||||
lngColumn,
|
||||
});
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!tableName || !latColumn || !lngColumn) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, latColumn, lngColumn은 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const markers = await this.mapDataService.getInternalMapData({
|
||||
tableName: tableName as string,
|
||||
latColumn: latColumn as string,
|
||||
lngColumn: lngColumn as string,
|
||||
labelColumn: labelColumn as string,
|
||||
statusColumn: statusColumn as string,
|
||||
additionalColumns: additionalColumns
|
||||
? (additionalColumns as string).split(",")
|
||||
: [],
|
||||
whereClause: whereClause as string,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
markers,
|
||||
count: markers.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("❌ 내부 DB 지도 데이터 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "지도 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Router } from "express";
|
||||
import { MapDataController } from "../controllers/mapDataController";
|
||||
|
||||
const router = Router();
|
||||
const mapDataController = new MapDataController();
|
||||
|
||||
/**
|
||||
* 지도 데이터 라우트
|
||||
*/
|
||||
|
||||
// 외부 DB 지도 데이터 조회
|
||||
router.get("/external/:connectionId", mapDataController.getMapData);
|
||||
|
||||
// 내부 DB 지도 데이터 조회
|
||||
router.get("/internal", mapDataController.getInternalMapData);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
import { logger } from "../utils/logger";
|
||||
import { query } from "../database/db";
|
||||
import { ExternalDbConnectionService } from "./externalDbConnectionService";
|
||||
|
||||
interface MapDataQuery {
|
||||
connectionId?: number;
|
||||
tableName: string;
|
||||
latColumn: string;
|
||||
lngColumn: string;
|
||||
labelColumn?: string;
|
||||
statusColumn?: string;
|
||||
additionalColumns?: string[];
|
||||
whereClause?: string;
|
||||
}
|
||||
|
||||
export interface MapMarker {
|
||||
id: string | number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
label?: string;
|
||||
status?: string;
|
||||
additionalInfo?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지도 데이터 서비스
|
||||
* 외부/내부 DB에서 위도/경도 데이터를 조회하여 지도 마커로 변환
|
||||
*/
|
||||
export class MapDataService {
|
||||
constructor() {
|
||||
// ExternalDbConnectionService는 static 메서드를 사용
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB에서 지도 데이터 조회
|
||||
*/
|
||||
async getMapData(params: MapDataQuery): Promise<MapMarker[]> {
|
||||
try {
|
||||
logger.info("🗺️ 외부 DB 지도 데이터 조회 시작:", params);
|
||||
|
||||
// SELECT할 컬럼 목록 구성
|
||||
const selectColumns = [
|
||||
params.latColumn,
|
||||
params.lngColumn,
|
||||
params.labelColumn,
|
||||
params.statusColumn,
|
||||
...(params.additionalColumns || []),
|
||||
].filter(Boolean);
|
||||
|
||||
// 중복 제거
|
||||
const uniqueColumns = Array.from(new Set(selectColumns));
|
||||
|
||||
// SQL 쿼리 구성
|
||||
let sql = `SELECT ${uniqueColumns.map((col) => `"${col}"`).join(", ")} FROM "${params.tableName}"`;
|
||||
|
||||
if (params.whereClause) {
|
||||
sql += ` WHERE ${params.whereClause}`;
|
||||
}
|
||||
|
||||
logger.info("📝 실행할 SQL:", sql);
|
||||
|
||||
// 외부 DB 쿼리 실행 (static 메서드 사용)
|
||||
const result = await ExternalDbConnectionService.executeQuery(
|
||||
params.connectionId!,
|
||||
sql
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error("외부 DB 쿼리 실패");
|
||||
}
|
||||
|
||||
// 데이터를 MapMarker 형식으로 변환
|
||||
const markers = this.convertToMarkers(
|
||||
result.data,
|
||||
params.latColumn,
|
||||
params.lngColumn,
|
||||
params.labelColumn,
|
||||
params.statusColumn,
|
||||
params.additionalColumns
|
||||
);
|
||||
|
||||
logger.info(`✅ ${markers.length}개의 마커 데이터 변환 완료`);
|
||||
|
||||
return markers;
|
||||
} catch (error) {
|
||||
logger.error("❌ 외부 DB 지도 데이터 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 내부 DB에서 지도 데이터 조회
|
||||
*/
|
||||
async getInternalMapData(
|
||||
params: Omit<MapDataQuery, "connectionId">
|
||||
): Promise<MapMarker[]> {
|
||||
try {
|
||||
logger.info("🗺️ 내부 DB 지도 데이터 조회 시작:", params);
|
||||
|
||||
// SELECT할 컬럼 목록 구성
|
||||
const selectColumns = [
|
||||
params.latColumn,
|
||||
params.lngColumn,
|
||||
params.labelColumn,
|
||||
params.statusColumn,
|
||||
...(params.additionalColumns || []),
|
||||
].filter(Boolean);
|
||||
|
||||
// 중복 제거
|
||||
const uniqueColumns = Array.from(new Set(selectColumns));
|
||||
|
||||
// SQL 쿼리 구성
|
||||
let sql = `SELECT ${uniqueColumns.map((col) => `"${col}"`).join(", ")} FROM "${params.tableName}"`;
|
||||
|
||||
if (params.whereClause) {
|
||||
sql += ` WHERE ${params.whereClause}`;
|
||||
}
|
||||
|
||||
logger.info("📝 실행할 SQL:", sql);
|
||||
|
||||
// 내부 DB 쿼리 실행
|
||||
const rows = await query(sql);
|
||||
|
||||
// 데이터를 MapMarker 형식으로 변환
|
||||
const markers = this.convertToMarkers(
|
||||
rows,
|
||||
params.latColumn,
|
||||
params.lngColumn,
|
||||
params.labelColumn,
|
||||
params.statusColumn,
|
||||
params.additionalColumns
|
||||
);
|
||||
|
||||
logger.info(`✅ ${markers.length}개의 마커 데이터 변환 완료`);
|
||||
|
||||
return markers;
|
||||
} catch (error) {
|
||||
logger.error("❌ 내부 DB 지도 데이터 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 결과를 MapMarker 배열로 변환
|
||||
*/
|
||||
private convertToMarkers(
|
||||
data: any[],
|
||||
latColumn: string,
|
||||
lngColumn: string,
|
||||
labelColumn?: string,
|
||||
statusColumn?: string,
|
||||
additionalColumns?: string[]
|
||||
): MapMarker[] {
|
||||
const markers: MapMarker[] = [];
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
|
||||
// 위도/경도 추출 (다양한 컬럼명 지원)
|
||||
const lat = this.extractCoordinate(row, latColumn);
|
||||
const lng = this.extractCoordinate(row, lngColumn);
|
||||
|
||||
// 유효한 좌표인지 확인
|
||||
if (lat === null || lng === null || isNaN(lat) || isNaN(lng)) {
|
||||
logger.warn(`⚠️ 유효하지 않은 좌표 스킵: row ${i}`, { lat, lng });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 위도 범위 체크 (-90 ~ 90)
|
||||
if (lat < -90 || lat > 90) {
|
||||
logger.warn(`⚠️ 위도 범위 초과: ${lat}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 경도 범위 체크 (-180 ~ 180)
|
||||
if (lng < -180 || lng > 180) {
|
||||
logger.warn(`⚠️ 경도 범위 초과: ${lng}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 추가 정보 수집
|
||||
const additionalInfo: Record<string, any> = {};
|
||||
if (additionalColumns) {
|
||||
for (const col of additionalColumns) {
|
||||
if (col && row[col] !== undefined) {
|
||||
additionalInfo[col] = row[col];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 마커 생성
|
||||
markers.push({
|
||||
id: row.id || row.ID || `marker-${i}`,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
label: labelColumn ? row[labelColumn] : undefined,
|
||||
status: statusColumn ? row[statusColumn] : undefined,
|
||||
additionalInfo: Object.keys(additionalInfo).length > 0 ? additionalInfo : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다양한 형식의 좌표 추출
|
||||
*/
|
||||
private extractCoordinate(row: any, columnName: string): number | null {
|
||||
const value = row[columnName];
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 이미 숫자인 경우
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
|
||||
// 문자열인 경우 파싱
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseFloat(value);
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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>,
|
||||
});
|
||||
|
|
@ -476,15 +486,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" ? (
|
||||
// 리스크/알림 위젯 렌더링
|
||||
|
|
|
|||
|
|
@ -128,7 +128,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"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ 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("쿼리를 입력해주세요.");
|
||||
return;
|
||||
|
|
@ -42,11 +46,13 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
|
|||
// 외부 DB인 경우 커넥션 ID 확인
|
||||
if (dataSource?.connectionType === "external" && !dataSource?.externalConnectionId) {
|
||||
setError("외부 DB 커넥션을 선택해주세요.");
|
||||
console.log("❌ 쿼리가 비어있음!");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExecuting(true);
|
||||
setError(null);
|
||||
console.log("🔄 쿼리 실행 시작...");
|
||||
|
||||
try {
|
||||
let apiResult: { columns: string[]; rows: any[]; rowCount: number };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -18,6 +18,8 @@ export type ElementSubtype =
|
|||
| "clock"
|
||||
| "calendar"
|
||||
| "calculator"
|
||||
| "vehicle-status"
|
||||
| "vehicle-list"
|
||||
| "vehicle-map"
|
||||
| "delivery-status"
|
||||
| "risk-alert"
|
||||
|
|
@ -122,6 +124,12 @@ export interface ChartConfig {
|
|||
areaOpacity?: number; // 영역 투명도
|
||||
pieInnerRadius?: number; // 도넛 차트 내부 반지름 (0-1)
|
||||
stackMode?: "normal" | "percent"; // 누적 모드
|
||||
|
||||
// 지도 관련 설정
|
||||
latitudeColumn?: string; // 위도 컬럼
|
||||
longitudeColumn?: string; // 경도 컬럼
|
||||
labelColumn?: string; // 라벨 컬럼
|
||||
statusColumn?: string; // 상태 컬럼
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
|
|
|
|||
|
|
@ -27,40 +27,62 @@ interface CustomerIssue {
|
|||
}
|
||||
|
||||
interface DeliveryStatusWidgetProps {
|
||||
element?: any; // 대시보드 요소 (dataSource 포함)
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export default function DeliveryStatusWidget({ refreshInterval = 60000 }: DeliveryStatusWidgetProps) {
|
||||
export default function DeliveryStatusWidget({ element, refreshInterval = 60000 }: DeliveryStatusWidgetProps) {
|
||||
const [deliveries, setDeliveries] = useState<DeliveryItem[]>([]);
|
||||
const [issues, setIssues] = useState<CustomerIssue[]>([]);
|
||||
const [todayStats, setTodayStats] = useState({
|
||||
shipped: 0,
|
||||
delivered: 0,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>("all"); // 필터 상태 추가
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// TODO: 실제 API 연동 시 아래 주석 해제
|
||||
// try {
|
||||
// const response = await fetch('/api/delivery/status', {
|
||||
// headers: {
|
||||
// 'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
|
||||
// },
|
||||
// });
|
||||
// const data = await response.json();
|
||||
// setDeliveries(data.deliveries);
|
||||
// setIssues(data.issues);
|
||||
// setTodayStats(data.todayStats);
|
||||
// setLastUpdate(new Date());
|
||||
// } catch (error) {
|
||||
// console.error('배송 데이터 로드 실패:', error);
|
||||
// } finally {
|
||||
// setIsLoading(false);
|
||||
// }
|
||||
// 설정된 쿼리가 없으면 로딩 중단 (더미 데이터 사용 안 함)
|
||||
if (!element?.dataSource?.query) {
|
||||
setIsLoading(false);
|
||||
setDeliveries([]);
|
||||
setIssues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/dashboards/execute-query", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||
},
|
||||
body: JSON.stringify({ query: element.dataSource.query }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data.rows.length > 0) {
|
||||
// TODO: DB 데이터를 DeliveryItem 형식으로 변환
|
||||
setDeliveries(result.data.rows);
|
||||
setLastUpdate(new Date());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배송 데이터 로드 실패:", error);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// 데이터 로드 및 자동 새로고침
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval = setInterval(loadData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [element?.dataSource?.query, refreshInterval]);
|
||||
|
||||
// 더미 데이터 완전히 제거 (아래 코드 삭제)
|
||||
/*
|
||||
// 가상 배송 데이터 (개발용 - 실제 DB 연동 시 삭제)
|
||||
const dummyDeliveries: DeliveryItem[] = [
|
||||
{
|
||||
|
|
@ -148,23 +170,7 @@ export default function DeliveryStatusWidget({ refreshInterval = 60000 }: Delive
|
|||
},
|
||||
];
|
||||
|
||||
setTimeout(() => {
|
||||
setDeliveries(dummyDeliveries);
|
||||
setIssues(dummyIssues);
|
||||
setTodayStats({
|
||||
shipped: 24,
|
||||
delivered: 18,
|
||||
});
|
||||
setLastUpdate(new Date());
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval = setInterval(loadData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshInterval]);
|
||||
*/
|
||||
|
||||
const getStatusColor = (status: DeliveryItem["status"]) => {
|
||||
switch (status) {
|
||||
|
|
@ -259,7 +265,32 @@ export default function DeliveryStatusWidget({ refreshInterval = 60000 }: Delive
|
|||
pickup_waiting: deliveries.filter((d) => d.status === "pickup_waiting").length,
|
||||
};
|
||||
|
||||
const delayedDeliveries = deliveries.filter((d) => d.status === "delayed");
|
||||
// 필터링된 배송 목록
|
||||
const filteredDeliveries = selectedStatus === "all"
|
||||
? deliveries
|
||||
: deliveries.filter((d) => d.status === selectedStatus);
|
||||
|
||||
// 오늘 통계 계산
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayStats = {
|
||||
// 오늘 발송 건수 (created_at이 오늘인 것)
|
||||
shipped: deliveries.filter((d: any) => {
|
||||
if (!d.created_at) return false;
|
||||
const createdDate = new Date(d.created_at);
|
||||
createdDate.setHours(0, 0, 0, 0);
|
||||
return createdDate.getTime() === today.getTime();
|
||||
}).length,
|
||||
// 오늘 도착 건수 (status가 delivered이고 estimated_delivery가 오늘인 것)
|
||||
delivered: deliveries.filter((d: any) => {
|
||||
if (d.status !== "delivered" && d.status !== "delivered") return false;
|
||||
if (!d.estimated_delivery && !d.estimatedDelivery) return false;
|
||||
const deliveredDate = new Date(d.estimated_delivery || d.estimatedDelivery);
|
||||
deliveredDate.setHours(0, 0, 0, 0);
|
||||
return deliveredDate.getTime() === today.getTime();
|
||||
}).length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-gradient-to-br from-slate-50 to-blue-50 p-4 overflow-auto">
|
||||
|
|
@ -284,24 +315,63 @@ export default function DeliveryStatusWidget({ refreshInterval = 60000 }: Delive
|
|||
|
||||
{/* 배송 상태 요약 */}
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">배송 상태 요약</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-blue-500">
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">배송 상태 요약 (클릭하여 필터링)</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedStatus("all")}
|
||||
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
||||
selectedStatus === "all"
|
||||
? "border-gray-900 bg-gray-100 ring-2 ring-gray-900"
|
||||
: "border-gray-500 bg-white hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-600 mb-0.5">전체</div>
|
||||
<div className="text-lg font-bold text-gray-900">{deliveries.length}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("in_transit")}
|
||||
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
||||
selectedStatus === "in_transit"
|
||||
? "border-blue-900 bg-blue-100 ring-2 ring-blue-900"
|
||||
: "border-blue-500 bg-white hover:bg-blue-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-600 mb-0.5">배송중</div>
|
||||
<div className="text-lg font-bold text-blue-600">{statusStats.in_transit}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-green-500">
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("delivered")}
|
||||
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
||||
selectedStatus === "delivered"
|
||||
? "border-green-900 bg-green-100 ring-2 ring-green-900"
|
||||
: "border-green-500 bg-white hover:bg-green-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-600 mb-0.5">완료</div>
|
||||
<div className="text-lg font-bold text-green-600">{statusStats.delivered}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-red-500">
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("delayed")}
|
||||
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
||||
selectedStatus === "delayed"
|
||||
? "border-red-900 bg-red-100 ring-2 ring-red-900"
|
||||
: "border-red-500 bg-white hover:bg-red-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-600 mb-0.5">지연</div>
|
||||
<div className="text-lg font-bold text-red-600">{statusStats.delayed}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-yellow-500">
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("pickup_waiting")}
|
||||
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
||||
selectedStatus === "pickup_waiting"
|
||||
? "border-yellow-900 bg-yellow-100 ring-2 ring-yellow-900"
|
||||
: "border-yellow-500 bg-white hover:bg-yellow-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-600 mb-0.5">픽업 대기</div>
|
||||
<div className="text-lg font-bold text-yellow-600">{statusStats.pickup_waiting}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -322,20 +392,24 @@ export default function DeliveryStatusWidget({ refreshInterval = 60000 }: Delive
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지연 중인 화물 리스트 */}
|
||||
{/* 필터링된 화물 리스트 */}
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||
지연 중인 화물 ({delayedDeliveries.length})
|
||||
<Package className="h-4 w-4 text-gray-600" />
|
||||
{selectedStatus === "all" && `전체 화물 (${filteredDeliveries.length})`}
|
||||
{selectedStatus === "in_transit" && `배송 중인 화물 (${filteredDeliveries.length})`}
|
||||
{selectedStatus === "delivered" && `배송 완료 (${filteredDeliveries.length})`}
|
||||
{selectedStatus === "delayed" && `지연 중인 화물 (${filteredDeliveries.length})`}
|
||||
{selectedStatus === "pickup_waiting" && `픽업 대기 (${filteredDeliveries.length})`}
|
||||
</h4>
|
||||
<div className="rounded-lg bg-white shadow-sm border border-gray-200 overflow-hidden">
|
||||
{delayedDeliveries.length === 0 ? (
|
||||
{filteredDeliveries.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-gray-500">
|
||||
지연 중인 화물이 없습니다
|
||||
{selectedStatus === "all" ? "화물이 없습니다" : "해당 상태의 화물이 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[200px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
|
||||
{delayedDeliveries.map((delivery) => (
|
||||
{filteredDeliveries.map((delivery) => (
|
||||
<div
|
||||
key={delivery.id}
|
||||
className="p-3 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { RefreshCw, Truck, Navigation, Gauge } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Vehicle {
|
||||
id: string;
|
||||
vehicle_number: string;
|
||||
vehicle_name: string;
|
||||
driver_name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
status: string;
|
||||
speed: number;
|
||||
destination: string;
|
||||
}
|
||||
|
||||
interface VehicleListWidgetProps {
|
||||
element?: any; // 대시보드 요소 (dataSource 포함)
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export default function VehicleListWidget({ element, refreshInterval = 30000 }: VehicleListWidgetProps) {
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>("all");
|
||||
|
||||
const loadVehicles = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// 설정된 쿼리가 없으면 로딩 중단 (기본 쿼리 사용 안 함)
|
||||
if (!element?.dataSource?.query) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = element.dataSource.query;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/dashboards/execute-query", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data.rows.length > 0) {
|
||||
setVehicles(result.data.rows);
|
||||
setLastUpdate(new Date());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("차량 목록 로드 실패:", error);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// 데이터 로드 및 자동 새로고침
|
||||
useEffect(() => {
|
||||
loadVehicles();
|
||||
const interval = setInterval(loadVehicles, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [element?.dataSource?.query, refreshInterval]);
|
||||
|
||||
// 설정되지 않았을 때도 빈 상태로 표시 (안내 메시지 제거)
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const s = status?.toLowerCase() || "";
|
||||
if (s === "active" || s === "running") return "bg-green-500";
|
||||
if (s === "inactive" || s === "idle") return "bg-yellow-500";
|
||||
if (s === "maintenance") return "bg-orange-500";
|
||||
if (s === "warning" || s === "breakdown") return "bg-red-500";
|
||||
return "bg-gray-500";
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const s = status?.toLowerCase() || "";
|
||||
if (s === "active" || s === "running") return "운행 중";
|
||||
if (s === "inactive" || s === "idle") return "대기";
|
||||
if (s === "maintenance") return "정비";
|
||||
if (s === "warning" || s === "breakdown") return "고장";
|
||||
return "알 수 없음";
|
||||
};
|
||||
|
||||
const filteredVehicles =
|
||||
selectedStatus === "all" ? vehicles : vehicles.filter((v) => v.status?.toLowerCase() === selectedStatus);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-gradient-to-br from-slate-50 to-blue-50 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">📋 차량 목록</h3>
|
||||
<p className="text-xs text-gray-500">마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadVehicles} disabled={isLoading} className="h-8 w-8 p-0">
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필터 버튼 */}
|
||||
<div className="mb-3 flex gap-2 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setSelectedStatus("all")}
|
||||
className={`whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
||||
selectedStatus === "all" ? "bg-gray-900 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
전체 ({vehicles.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("active")}
|
||||
className={`whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
||||
selectedStatus === "active" ? "bg-green-500 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
운행 중 ({vehicles.filter((v) => v.status?.toLowerCase() === "active").length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("inactive")}
|
||||
className={`whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
||||
selectedStatus === "inactive" ? "bg-yellow-500 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
대기 ({vehicles.filter((v) => v.status?.toLowerCase() === "inactive").length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("maintenance")}
|
||||
className={`whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
||||
selectedStatus === "maintenance" ? "bg-orange-500 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
정비 ({vehicles.filter((v) => v.status?.toLowerCase() === "maintenance").length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("warning")}
|
||||
className={`whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
||||
selectedStatus === "warning" ? "bg-red-500 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
고장 ({vehicles.filter((v) => v.status?.toLowerCase() === "warning").length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 차량 목록 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredVehicles.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white">
|
||||
<div className="text-center">
|
||||
<Truck className="mx-auto h-12 w-12 text-gray-300" />
|
||||
<p className="mt-2 text-sm text-gray-500">차량이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredVehicles.map((vehicle) => (
|
||||
<div
|
||||
key={vehicle.id}
|
||||
className="rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Truck className="h-4 w-4 text-gray-600" />
|
||||
<span className="font-semibold text-gray-900">{vehicle.vehicle_name}</span>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold text-white ${getStatusColor(vehicle.status)}`}>
|
||||
{getStatusText(vehicle.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">차량번호</span>
|
||||
<span className="font-mono font-medium">{vehicle.vehicle_number}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">기사</span>
|
||||
<span className="font-medium">{vehicle.driver_name || "미배정"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Navigation className="h-3 w-3 text-gray-400" />
|
||||
<span className="flex-1 truncate text-gray-700">{vehicle.destination || "대기 중"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Gauge className="h-3 w-3 text-gray-400" />
|
||||
<span className="text-gray-700">{vehicle.speed || 0} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||
if (typeof window !== "undefined") {
|
||||
const L = require("leaflet");
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
});
|
||||
}
|
||||
|
||||
// Leaflet 동적 import (SSR 방지)
|
||||
const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false });
|
||||
const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false });
|
||||
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
|
||||
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
||||
const Circle = dynamic(() => import("react-leaflet").then((mod) => mod.Circle), { ssr: false });
|
||||
|
||||
// 브이월드 API 키
|
||||
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
||||
|
||||
interface Vehicle {
|
||||
id: string;
|
||||
name: string;
|
||||
driver: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
status: "active" | "inactive" | "maintenance" | "warning";
|
||||
speed: number;
|
||||
destination: string;
|
||||
}
|
||||
|
||||
interface VehicleMapOnlyWidgetProps {
|
||||
element?: any; // 대시보드 요소 (dataSource, chartConfig 포함)
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000 }: VehicleMapOnlyWidgetProps) {
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
|
||||
const loadVehicles = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// 설정된 쿼리가 없으면 로딩 중단
|
||||
if (!element?.dataSource?.query) {
|
||||
setIsLoading(false);
|
||||
setVehicles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 설정된 컬럼 매핑 확인
|
||||
if (!element?.chartConfig?.latitudeColumn || !element?.chartConfig?.longitudeColumn) {
|
||||
setIsLoading(false);
|
||||
setVehicles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/dashboards/execute-query", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: element.dataSource.query,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data.rows.length > 0) {
|
||||
// 설정된 컬럼 매핑 가져오기
|
||||
const latCol = element.chartConfig.latitudeColumn;
|
||||
const lngCol = element.chartConfig.longitudeColumn;
|
||||
const labelCol = element.chartConfig.labelColumn || "name";
|
||||
const statusCol = element.chartConfig.statusColumn || "status";
|
||||
|
||||
// DB 데이터를 Vehicle 형식으로 변환
|
||||
const vehiclesFromDB: Vehicle[] = result.data.rows.map((row: any, index: number) => ({
|
||||
id: row.id || row.vehicle_number || `V${index + 1}`,
|
||||
name: row[labelCol] || `차량 ${index + 1}`,
|
||||
driver: row.driver_name || row.driver || "미배정",
|
||||
lat: parseFloat(row[latCol]),
|
||||
lng: parseFloat(row[lngCol]),
|
||||
status:
|
||||
row[statusCol] === "warning"
|
||||
? "warning"
|
||||
: row[statusCol] === "active"
|
||||
? "active"
|
||||
: row[statusCol] === "maintenance"
|
||||
? "maintenance"
|
||||
: "inactive",
|
||||
speed: parseFloat(row.speed) || 0,
|
||||
destination: row.destination || "대기 중",
|
||||
}));
|
||||
|
||||
setVehicles(vehiclesFromDB);
|
||||
setLastUpdate(new Date());
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("차량 데이터 로드 실패:", error);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// useEffect는 항상 같은 순서로 호출되어야 함 (early return 전에 배치)
|
||||
useEffect(() => {
|
||||
loadVehicles();
|
||||
const interval = setInterval(loadVehicles, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [element?.dataSource?.query, element?.chartConfig?.latitudeColumn, element?.chartConfig?.longitudeColumn, refreshInterval]);
|
||||
|
||||
// 쿼리 없으면 빈 지도만 표시 (안내 메시지 제거)
|
||||
|
||||
const getStatusColor = (status: Vehicle["status"]) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "#22c55e"; // 운행 중 - 초록
|
||||
case "inactive":
|
||||
return "#eab308"; // 대기 - 노랑
|
||||
case "maintenance":
|
||||
return "#f97316"; // 정비 - 주황
|
||||
case "warning":
|
||||
return "#ef4444"; // 고장 - 빨강
|
||||
default:
|
||||
return "#6b7280"; // 기타 - 회색
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: Vehicle["status"]) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "운행 중";
|
||||
case "inactive":
|
||||
return "대기";
|
||||
case "maintenance":
|
||||
return "정비";
|
||||
case "warning":
|
||||
return "고장";
|
||||
default:
|
||||
return "알 수 없음";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-gradient-to-br from-slate-50 to-blue-50 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">🗺️ 차량 위치 지도</h3>
|
||||
<p className="text-xs text-gray-500">마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadVehicles} disabled={isLoading} className="h-8 w-8 p-0">
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 지도 영역 - 브이월드 타일맵 */}
|
||||
<div className="h-[calc(100%-60px)]">
|
||||
<div className="relative h-full overflow-hidden rounded-lg border-2 border-gray-300 bg-white">
|
||||
<MapContainer
|
||||
center={[36.5, 127.5]}
|
||||
zoom={7}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
zoomControl={true}
|
||||
preferCanvas={true}
|
||||
>
|
||||
{/* 브이월드 타일맵 (HTTPS, 캐싱 적용) */}
|
||||
<TileLayer
|
||||
url={`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`}
|
||||
attribution='© <a href="https://www.vworld.kr">VWorld (국토교통부)</a>'
|
||||
maxZoom={19}
|
||||
minZoom={7}
|
||||
updateWhenIdle={true}
|
||||
updateWhenZooming={false}
|
||||
keepBuffer={2}
|
||||
/>
|
||||
|
||||
{/* 차량 마커 */}
|
||||
{vehicles.map((vehicle) => (
|
||||
<React.Fragment key={vehicle.id}>
|
||||
<Circle
|
||||
center={[vehicle.lat, vehicle.lng]}
|
||||
radius={150}
|
||||
pathOptions={{
|
||||
color: getStatusColor(vehicle.status),
|
||||
fillColor: getStatusColor(vehicle.status),
|
||||
fillOpacity: 0.3,
|
||||
}}
|
||||
/>
|
||||
<Marker position={[vehicle.lat, vehicle.lng]}>
|
||||
<Popup>
|
||||
<div className="text-xs">
|
||||
<div className="mb-1 text-sm font-bold">{vehicle.name}</div>
|
||||
<div>
|
||||
<strong>기사:</strong> {vehicle.driver}
|
||||
</div>
|
||||
<div>
|
||||
<strong>상태:</strong> {getStatusText(vehicle.status)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>속도:</strong> {vehicle.speed} km/h
|
||||
</div>
|
||||
<div>
|
||||
<strong>목적지:</strong> {vehicle.destination}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</MapContainer>
|
||||
|
||||
{/* 지도 정보 */}
|
||||
<div className="absolute right-2 top-2 z-[1000] rounded-lg bg-white/90 p-2 shadow-lg backdrop-blur-sm">
|
||||
<div className="text-xs text-gray-600">
|
||||
<div className="mb-1 font-semibold">🗺️ 브이월드 (VWorld)</div>
|
||||
<div className="text-xs">국토교통부 공식 지도</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차량 수 표시 또는 설정 안내 */}
|
||||
<div className="absolute bottom-2 left-2 z-[1000] rounded-lg bg-white/90 p-2 shadow-lg backdrop-blur-sm">
|
||||
{vehicles.length > 0 ? (
|
||||
<div className="text-xs font-semibold text-gray-900">총 {vehicles.length}대 모니터링 중</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-600">
|
||||
⚙️ 톱니바퀴 클릭하여 데이터 연결
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,546 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, Truck, Navigation } from "lucide-react";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||
if (typeof window !== "undefined") {
|
||||
const L = require("leaflet");
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
});
|
||||
}
|
||||
|
||||
// Leaflet 동적 import (SSR 방지)
|
||||
const MapContainer = dynamic(
|
||||
() => import("react-leaflet").then((mod) => mod.MapContainer),
|
||||
{ ssr: false }
|
||||
);
|
||||
const TileLayer = dynamic(
|
||||
() => import("react-leaflet").then((mod) => mod.TileLayer),
|
||||
{ ssr: false }
|
||||
);
|
||||
const Marker = dynamic(
|
||||
() => import("react-leaflet").then((mod) => mod.Marker),
|
||||
{ ssr: false }
|
||||
);
|
||||
const Popup = dynamic(
|
||||
() => import("react-leaflet").then((mod) => mod.Popup),
|
||||
{ ssr: false }
|
||||
);
|
||||
const Circle = dynamic(
|
||||
() => import("react-leaflet").then((mod) => mod.Circle),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
// 브이월드 API 키
|
||||
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
||||
|
||||
interface Vehicle {
|
||||
id: string;
|
||||
name: string;
|
||||
driver: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
status: "running" | "idle" | "maintenance" | "breakdown";
|
||||
speed: number;
|
||||
destination: string;
|
||||
distance: number;
|
||||
fuel: number;
|
||||
avgSpeed: number;
|
||||
temperature?: number;
|
||||
isRefrigerated: boolean;
|
||||
}
|
||||
|
||||
interface VehicleMapWidgetProps {
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export default function VehicleMapWidget({ refreshInterval = 30000 }: VehicleMapWidgetProps) {
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
|
||||
const loadVehicles = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const dummyVehicles: Vehicle[] = [
|
||||
{
|
||||
id: "V001",
|
||||
name: "냉동차 1호",
|
||||
driver: "김기사",
|
||||
lat: 35.1796 + (Math.random() - 0.5) * 0.05, // 부산
|
||||
lng: 129.0756 + (Math.random() - 0.5) * 0.05,
|
||||
status: "running",
|
||||
speed: 55 + Math.floor(Math.random() * 20),
|
||||
destination: "부산 → 울산",
|
||||
distance: 45 + Math.floor(Math.random() * 20),
|
||||
fuel: 85 + Math.floor(Math.random() * 15),
|
||||
avgSpeed: 62,
|
||||
temperature: -18 + Math.floor(Math.random() * 3),
|
||||
isRefrigerated: true,
|
||||
},
|
||||
{
|
||||
id: "V002",
|
||||
name: "일반 화물차 2호",
|
||||
driver: "이기사",
|
||||
lat: 37.4563, // 인천
|
||||
lng: 126.7052,
|
||||
status: "idle",
|
||||
speed: 0,
|
||||
destination: "대기 중",
|
||||
distance: 0,
|
||||
fuel: 5,
|
||||
avgSpeed: 0,
|
||||
isRefrigerated: false,
|
||||
},
|
||||
{
|
||||
id: "V003",
|
||||
name: "냉장차 3호",
|
||||
driver: "박기사",
|
||||
lat: 36.3504 + (Math.random() - 0.5) * 0.05, // 대전
|
||||
lng: 127.3845 + (Math.random() - 0.5) * 0.05,
|
||||
status: "running",
|
||||
speed: 40 + Math.floor(Math.random() * 15),
|
||||
destination: "대전 → 세종",
|
||||
distance: 22 + Math.floor(Math.random() * 10),
|
||||
fuel: 42 + Math.floor(Math.random() * 10),
|
||||
avgSpeed: 58,
|
||||
temperature: 2 + Math.floor(Math.random() * 4),
|
||||
isRefrigerated: true,
|
||||
},
|
||||
{
|
||||
id: "V004",
|
||||
name: "일반 화물차 4호",
|
||||
driver: "최기사",
|
||||
lat: 35.8714, // 대구
|
||||
lng: 128.6014,
|
||||
status: "maintenance",
|
||||
speed: 0,
|
||||
destination: "정비소",
|
||||
distance: 0,
|
||||
fuel: 0,
|
||||
avgSpeed: 0,
|
||||
isRefrigerated: false,
|
||||
},
|
||||
{
|
||||
id: "V005",
|
||||
name: "냉동차 5호",
|
||||
driver: "정기사",
|
||||
lat: 33.4996 + (Math.random() - 0.5) * 0.05, // 제주
|
||||
lng: 126.5312 + (Math.random() - 0.5) * 0.05,
|
||||
status: "running",
|
||||
speed: 45 + Math.floor(Math.random() * 15),
|
||||
destination: "제주 → 서귀포",
|
||||
distance: 28 + Math.floor(Math.random() * 10),
|
||||
fuel: 52 + Math.floor(Math.random() * 10),
|
||||
avgSpeed: 54,
|
||||
temperature: -20 + Math.floor(Math.random() * 2),
|
||||
isRefrigerated: true,
|
||||
},
|
||||
{
|
||||
id: "V006",
|
||||
name: "일반 화물차 6호",
|
||||
driver: "강기사",
|
||||
lat: 35.1595, // 광주
|
||||
lng: 126.8526,
|
||||
status: "breakdown",
|
||||
speed: 0,
|
||||
destination: "고장 (견인 대기)",
|
||||
distance: 65,
|
||||
fuel: 18,
|
||||
avgSpeed: 0,
|
||||
isRefrigerated: false,
|
||||
},
|
||||
{
|
||||
id: "V007",
|
||||
name: "냉장차 7호",
|
||||
driver: "윤기사",
|
||||
lat: 37.5665 + (Math.random() - 0.5) * 0.05, // 서울
|
||||
lng: 126.9780 + (Math.random() - 0.5) * 0.05,
|
||||
status: "running",
|
||||
speed: 60 + Math.floor(Math.random() * 15),
|
||||
destination: "서울 → 수원",
|
||||
distance: 35 + Math.floor(Math.random() * 10),
|
||||
fuel: 68 + Math.floor(Math.random() * 10),
|
||||
avgSpeed: 65,
|
||||
temperature: 3 + Math.floor(Math.random() * 3),
|
||||
isRefrigerated: true,
|
||||
},
|
||||
{
|
||||
id: "V008",
|
||||
name: "일반 화물차 8호",
|
||||
driver: "한기사",
|
||||
lat: 37.8813 + (Math.random() - 0.5) * 0.05, // 춘천
|
||||
lng: 127.7300 + (Math.random() - 0.5) * 0.05,
|
||||
status: "running",
|
||||
speed: 50 + Math.floor(Math.random() * 15),
|
||||
destination: "춘천 → 강릉",
|
||||
distance: 95 + Math.floor(Math.random() * 20),
|
||||
fuel: 75 + Math.floor(Math.random() * 15),
|
||||
avgSpeed: 58,
|
||||
isRefrigerated: false,
|
||||
},
|
||||
];
|
||||
|
||||
setTimeout(() => {
|
||||
setVehicles(dummyVehicles);
|
||||
setLastUpdate(new Date());
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadVehicles();
|
||||
const interval = setInterval(loadVehicles, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshInterval]);
|
||||
|
||||
const getStatusColor = (status: Vehicle["status"]) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "#22c55e";
|
||||
case "idle":
|
||||
return "#eab308";
|
||||
case "maintenance":
|
||||
return "#f97316";
|
||||
case "breakdown":
|
||||
return "#ef4444";
|
||||
default:
|
||||
return "#6b7280";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: Vehicle["status"]) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "운행 중";
|
||||
case "idle":
|
||||
return "대기";
|
||||
case "maintenance":
|
||||
return "정비";
|
||||
case "breakdown":
|
||||
return "고장";
|
||||
default:
|
||||
return "알 수 없음";
|
||||
}
|
||||
};
|
||||
|
||||
const statusStats = {
|
||||
running: vehicles.filter((v) => v.status === "running").length,
|
||||
idle: vehicles.filter((v) => v.status === "idle").length,
|
||||
maintenance: vehicles.filter((v) => v.status === "maintenance").length,
|
||||
breakdown: vehicles.filter((v) => v.status === "breakdown").length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-gradient-to-br from-slate-50 to-blue-50 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">🚚 실시간 차량 위치</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadVehicles}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 차량 상태 요약 */}
|
||||
<div className="mb-3 grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-green-500">
|
||||
<div className="text-xs text-gray-600 mb-0.5">운행 중</div>
|
||||
<div className="text-lg font-bold text-green-600">{statusStats.running}대</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-yellow-500">
|
||||
<div className="text-xs text-gray-600 mb-0.5">대기</div>
|
||||
<div className="text-lg font-bold text-yellow-600">{statusStats.idle}대</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-orange-500">
|
||||
<div className="text-xs text-gray-600 mb-0.5">정비</div>
|
||||
<div className="text-lg font-bold text-orange-600">{statusStats.maintenance}대</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-red-500">
|
||||
<div className="text-xs text-gray-600 mb-0.5">고장</div>
|
||||
<div className="text-lg font-bold text-red-600">{statusStats.breakdown}대</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[calc(100%-120px)] gap-3">
|
||||
{/* 지도 영역 - 브이월드 타일맵 */}
|
||||
<div className="flex-1 min-w-0 overflow-auto">
|
||||
<div className="relative h-full min-h-[400px] min-w-[600px] rounded-lg overflow-hidden border-2 border-gray-300 bg-white">
|
||||
{typeof window !== "undefined" && (
|
||||
<MapContainer
|
||||
center={[36.5, 127.5]}
|
||||
zoom={7}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
zoomControl={true}
|
||||
preferCanvas={true}
|
||||
>
|
||||
{/* 브이월드 타일맵 (HTTPS, 캐싱 적용) */}
|
||||
<TileLayer
|
||||
url={`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`}
|
||||
attribution='© <a href="https://www.vworld.kr">VWorld (국토교통부)</a>'
|
||||
maxZoom={19}
|
||||
minZoom={7}
|
||||
updateWhenIdle={true}
|
||||
updateWhenZooming={false}
|
||||
keepBuffer={2}
|
||||
/>
|
||||
|
||||
{/* 차량 마커 */}
|
||||
{vehicles.map((vehicle) => (
|
||||
<React.Fragment key={vehicle.id}>
|
||||
<Circle
|
||||
center={[vehicle.lat, vehicle.lng]}
|
||||
radius={150}
|
||||
pathOptions={{
|
||||
color: getStatusColor(vehicle.status),
|
||||
fillColor: getStatusColor(vehicle.status),
|
||||
fillOpacity: 0.3,
|
||||
}}
|
||||
/>
|
||||
<Marker
|
||||
position={[vehicle.lat, vehicle.lng]}
|
||||
eventHandlers={{
|
||||
click: () => setSelectedVehicle(vehicle),
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-xs">
|
||||
<div className="font-bold text-sm mb-1">{vehicle.name}</div>
|
||||
<div><strong>기사:</strong> {vehicle.driver}</div>
|
||||
<div><strong>상태:</strong> {getStatusText(vehicle.status)}</div>
|
||||
<div><strong>속도:</strong> {vehicle.speed} km/h</div>
|
||||
<div><strong>거리:</strong> {vehicle.distance} km</div>
|
||||
<div><strong>연료:</strong> {vehicle.fuel} L</div>
|
||||
{vehicle.isRefrigerated && vehicle.temperature !== undefined && (
|
||||
<div><strong>온도:</strong> {vehicle.temperature}°C</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</MapContainer>
|
||||
)}
|
||||
|
||||
{/* 지도 정보 */}
|
||||
<div className="absolute top-2 right-2 bg-white/90 backdrop-blur-sm rounded-lg p-2 shadow-lg z-[1000]">
|
||||
<div className="text-xs text-gray-600">
|
||||
<div className="font-semibold mb-1">🗺️ 브이월드 (VWorld)</div>
|
||||
<div className="text-xs">국토교통부 공식 지도</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차량 수 표시 */}
|
||||
<div className="absolute bottom-2 left-2 bg-white/90 backdrop-blur-sm rounded-lg p-2 shadow-lg z-[1000]">
|
||||
<div className="text-xs font-semibold text-gray-900">
|
||||
총 {vehicles.length}대 모니터링 중
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 사이드 패널 */}
|
||||
<div className="w-80 flex flex-col gap-3 overflow-y-auto max-h-full">
|
||||
{/* 차량 목록 */}
|
||||
<div className="rounded-lg bg-white shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 border-b border-gray-200 p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Truck className="h-4 w-4 text-gray-600" />
|
||||
차량 목록 ({vehicles.length}대)
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="p-2 max-h-[320px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
|
||||
{vehicles.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-gray-500">
|
||||
차량이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{vehicles.map((vehicle) => (
|
||||
<div
|
||||
key={vehicle.id}
|
||||
onClick={() => setSelectedVehicle(vehicle)}
|
||||
className={`cursor-pointer rounded-lg border p-2 transition-all hover:shadow-sm ${
|
||||
selectedVehicle?.id === vehicle.id
|
||||
? "border-gray-900 bg-gray-50 ring-1 ring-gray-900"
|
||||
: "border-gray-200 bg-white hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-semibold text-sm text-gray-900">
|
||||
{vehicle.name}
|
||||
</span>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: getStatusColor(vehicle.status) }}
|
||||
>
|
||||
{getStatusText(vehicle.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 flex items-center gap-1">
|
||||
<Navigation className="h-3 w-3" />
|
||||
<span className="truncate">{vehicle.destination}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 차량 상세 정보 */}
|
||||
{selectedVehicle ? (
|
||||
<div className="rounded-lg bg-white shadow-sm border border-gray-200 overflow-hidden max-h-[400px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-900 p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<Truck className="h-5 w-5" />
|
||||
{selectedVehicle.name}
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setSelectedVehicle(null)}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="rounded-full px-3 py-1 text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: getStatusColor(selectedVehicle.status) }}
|
||||
>
|
||||
{getStatusText(selectedVehicle.status)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-400">{selectedVehicle.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기사 정보 */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h5 className="text-xs font-semibold text-gray-500 mb-2">👤 기사 정보</h5>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">이름</span>
|
||||
<span className="font-semibold text-gray-900">{selectedVehicle.driver}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">GPS 좌표</span>
|
||||
<span className="font-mono text-xs text-gray-700">
|
||||
{selectedVehicle.lat.toFixed(4)}, {selectedVehicle.lng.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 운행 정보 */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h5 className="text-xs font-semibold text-gray-500 mb-2">📍 운행 정보</h5>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">목적지</span>
|
||||
<span className="font-semibold text-gray-900">{selectedVehicle.destination}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 실시간 데이터 */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h5 className="text-xs font-semibold text-gray-500 mb-2">📊 실시간 데이터</h5>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-gray-50 rounded-lg p-2 border border-gray-200">
|
||||
<div className="text-xs text-gray-600 mb-0.5">현재 속도</div>
|
||||
<div className="text-lg font-bold text-gray-900">{selectedVehicle.speed}</div>
|
||||
<div className="text-xs text-gray-500">km/h</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-2 border border-gray-200">
|
||||
<div className="text-xs text-gray-600 mb-0.5">평균 속도</div>
|
||||
<div className="text-lg font-bold text-gray-900">{selectedVehicle.avgSpeed}</div>
|
||||
<div className="text-xs text-gray-500">km/h</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-2 border border-gray-200">
|
||||
<div className="text-xs text-gray-600 mb-0.5">운행 거리</div>
|
||||
<div className="text-lg font-bold text-gray-900">{selectedVehicle.distance}</div>
|
||||
<div className="text-xs text-gray-500">km</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-2 border border-gray-200">
|
||||
<div className="text-xs text-gray-600 mb-0.5">소모 연료</div>
|
||||
<div className="text-lg font-bold text-gray-900">{selectedVehicle.fuel}</div>
|
||||
<div className="text-xs text-gray-500">L</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 냉동/냉장 상태 */}
|
||||
{selectedVehicle.isRefrigerated && selectedVehicle.temperature !== undefined && (
|
||||
<div className="p-4">
|
||||
<h5 className="text-xs font-semibold text-gray-500 mb-3">❄️ 냉동/냉장 상태</h5>
|
||||
<div className="rounded-lg p-4 border border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-gray-600">현재 온도</span>
|
||||
<span className="text-3xl font-bold text-gray-900">
|
||||
{selectedVehicle.temperature}°C
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">타입</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{selectedVehicle.temperature < -10 ? "냉동" : "냉장"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">적정 범위</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{selectedVehicle.temperature < -10 ? "-18°C ~ -15°C" : "0°C ~ 5°C"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">상태</span>
|
||||
<span className={`px-3 py-1 rounded-md text-xs font-semibold border ${
|
||||
Math.abs(selectedVehicle.temperature - (selectedVehicle.temperature < -10 ? -18 : 2)) < 5
|
||||
? "bg-gray-900 text-white border-gray-900"
|
||||
: "bg-white text-gray-900 border-gray-300"
|
||||
}`}>
|
||||
{Math.abs(selectedVehicle.temperature - (selectedVehicle.temperature < -10 ? -18 : 2)) < 5
|
||||
? "✓ 정상"
|
||||
: "⚠ 주의"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-white shadow-lg border border-gray-200 p-8 text-center">
|
||||
<Truck className="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500">차량을 선택하면</p>
|
||||
<p className="text-sm text-gray-500">상세 정보가 표시됩니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { RefreshCw, TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface VehicleStatusWidgetProps {
|
||||
element?: any; // 대시보드 요소 (dataSource 포함)
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
interface StatusData {
|
||||
active: number; // 운행 중
|
||||
inactive: number; // 대기
|
||||
maintenance: number; // 정비
|
||||
warning: number; // 고장
|
||||
total: number;
|
||||
}
|
||||
|
||||
export default function VehicleStatusWidget({ element, refreshInterval = 30000 }: VehicleStatusWidgetProps) {
|
||||
const [statusData, setStatusData] = useState<StatusData>({
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
maintenance: 0,
|
||||
warning: 0,
|
||||
total: 0,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
|
||||
const loadStatusData = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// 설정된 쿼리가 없으면 로딩 중단 (기본 쿼리 사용 안 함)
|
||||
if (!element?.dataSource?.query) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = element.dataSource.query;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/dashboards/execute-query", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data.rows.length > 0) {
|
||||
const newStatus: StatusData = {
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
maintenance: 0,
|
||||
warning: 0,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
// 쿼리 결과가 GROUP BY 형식인지 확인
|
||||
const isGroupedData = result.data.rows[0].count !== undefined;
|
||||
|
||||
if (isGroupedData) {
|
||||
// GROUP BY 형식: SELECT status, COUNT(*) as count
|
||||
result.data.rows.forEach((row: any) => {
|
||||
const count = parseInt(row.count) || 0;
|
||||
const status = row.status?.toLowerCase() || "";
|
||||
|
||||
if (status === "active" || status === "running") {
|
||||
newStatus.active = count;
|
||||
} else if (status === "inactive" || status === "idle") {
|
||||
newStatus.inactive = count;
|
||||
} else if (status === "maintenance") {
|
||||
newStatus.maintenance = count;
|
||||
} else if (status === "warning" || status === "breakdown") {
|
||||
newStatus.warning = count;
|
||||
}
|
||||
|
||||
newStatus.total += count;
|
||||
});
|
||||
} else {
|
||||
// SELECT * 형식: 전체 데이터를 가져와서 카운트
|
||||
result.data.rows.forEach((row: any) => {
|
||||
const status = row.status?.toLowerCase() || "";
|
||||
|
||||
if (status === "active" || status === "running") {
|
||||
newStatus.active++;
|
||||
} else if (status === "inactive" || status === "idle") {
|
||||
newStatus.inactive++;
|
||||
} else if (status === "maintenance") {
|
||||
newStatus.maintenance++;
|
||||
} else if (status === "warning" || status === "breakdown") {
|
||||
newStatus.warning++;
|
||||
}
|
||||
|
||||
newStatus.total++;
|
||||
});
|
||||
}
|
||||
|
||||
setStatusData(newStatus);
|
||||
setLastUpdate(new Date());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("차량 상태 데이터 로드 실패:", error);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// 데이터 로드 및 자동 새로고침
|
||||
useEffect(() => {
|
||||
loadStatusData();
|
||||
const interval = setInterval(loadStatusData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [element?.dataSource?.query, refreshInterval]);
|
||||
|
||||
// 설정되지 않았을 때도 빈 상태로 표시 (안내 메시지 제거)
|
||||
|
||||
const activeRate = statusData.total > 0 ? ((statusData.active / statusData.total) * 100).toFixed(1) : "0";
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-green-50 p-2">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-gray-900">📊 차량 상태 현황</h3>
|
||||
{statusData.total > 0 ? (
|
||||
<p className="text-xs text-gray-500">{lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||||
) : (
|
||||
<p className="text-xs text-orange-500">⚙️ 데이터 연결 필요</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadStatusData} disabled={isLoading} className="h-7 w-7 p-0">
|
||||
<RefreshCw className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 스크롤 가능한 콘텐츠 영역 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 총 차량 수 */}
|
||||
<div className="mb-1 rounded border border-gray-200 bg-white p-1.5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">총 차량</div>
|
||||
<div className="text-base font-bold text-gray-900">{statusData.total}대</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-gray-600">가동률</div>
|
||||
<div className="flex items-center gap-0.5 text-sm font-bold text-green-600">
|
||||
{activeRate}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태별 카드 */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 운행 중 */}
|
||||
<div className="rounded border-l-2 border-green-500 bg-white p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500"></div>
|
||||
<div className="text-xs font-medium text-gray-600">운행</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-green-600">{statusData.active}</div>
|
||||
</div>
|
||||
|
||||
{/* 대기 */}
|
||||
<div className="rounded border-l-2 border-yellow-500 bg-white p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-yellow-500"></div>
|
||||
<div className="text-xs font-medium text-gray-600">대기</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-yellow-600">{statusData.inactive}</div>
|
||||
</div>
|
||||
|
||||
{/* 정비 */}
|
||||
<div className="rounded border-l-2 border-orange-500 bg-white p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-orange-500"></div>
|
||||
<div className="text-xs font-medium text-gray-600">정비</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-orange-600">{statusData.maintenance}</div>
|
||||
</div>
|
||||
|
||||
{/* 고장 */}
|
||||
<div className="rounded border-l-2 border-red-500 bg-white p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-red-500"></div>
|
||||
<div className="text-xs font-medium text-gray-600">고장</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-red-600">{statusData.warning}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +37,7 @@ import "./divider-line/DividerLineRenderer";
|
|||
import "./accordion-basic/AccordionBasicRenderer";
|
||||
import "./table-list/TableListRenderer";
|
||||
import "./card-display/CardDisplayRenderer";
|
||||
import "./map/MapRenderer";
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -0,0 +1,285 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { RefreshCw, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||
if (typeof window !== "undefined") {
|
||||
const L = require("leaflet");
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
});
|
||||
}
|
||||
|
||||
// Leaflet 동적 import (SSR 방지)
|
||||
const MapContainer = dynamic(
|
||||
() => import("react-leaflet").then((mod) => mod.MapContainer),
|
||||
{ ssr: false }
|
||||
);
|
||||
const TileLayer = dynamic(
|
||||
() => import("react-leaflet").then((mod) => mod.TileLayer),
|
||||
{ ssr: false }
|
||||
);
|
||||
const Marker = dynamic(
|
||||
() => import("react-leaflet").then((mod) => mod.Marker),
|
||||
{ ssr: false }
|
||||
);
|
||||
const Popup = dynamic(
|
||||
() => import("react-leaflet").then((mod) => mod.Popup),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
interface MapMarker {
|
||||
id: string | number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
label?: string;
|
||||
status?: string;
|
||||
additionalInfo?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface MapComponentProps {
|
||||
component: {
|
||||
id: string;
|
||||
config?: {
|
||||
dataSource?: {
|
||||
type?: "internal" | "external";
|
||||
connectionId?: number | null;
|
||||
tableName?: string;
|
||||
latColumn?: string;
|
||||
lngColumn?: string;
|
||||
labelColumn?: string;
|
||||
statusColumn?: string;
|
||||
additionalColumns?: string[];
|
||||
whereClause?: string;
|
||||
};
|
||||
mapConfig?: {
|
||||
center?: { lat: number; lng: number };
|
||||
zoom?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
};
|
||||
markerConfig?: {
|
||||
showLabel?: boolean;
|
||||
showPopup?: boolean;
|
||||
statusColors?: Record<string, string>;
|
||||
};
|
||||
refreshInterval?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default function MapComponent({ component }: MapComponentProps) {
|
||||
const [markers, setMarkers] = useState<MapMarker[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
|
||||
const dataSource = component.config?.dataSource;
|
||||
const mapConfig = component.config?.mapConfig;
|
||||
const markerConfig = component.config?.markerConfig;
|
||||
const refreshInterval = component.config?.refreshInterval || 0;
|
||||
|
||||
// 데이터 로드
|
||||
const loadMapData = async () => {
|
||||
if (!dataSource?.tableName || !dataSource?.latColumn || !dataSource?.lngColumn) {
|
||||
setError("테이블명, 위도 컬럼, 경도 컬럼을 설정해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// API URL 구성
|
||||
const isExternal = dataSource.type === "external" && dataSource.connectionId;
|
||||
const baseUrl = isExternal
|
||||
? `/api/map-data/external/${dataSource.connectionId}`
|
||||
: `/api/map-data/internal`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
tableName: dataSource.tableName,
|
||||
latColumn: dataSource.latColumn,
|
||||
lngColumn: dataSource.lngColumn,
|
||||
});
|
||||
|
||||
if (dataSource.labelColumn) {
|
||||
params.append("labelColumn", dataSource.labelColumn);
|
||||
}
|
||||
if (dataSource.statusColumn) {
|
||||
params.append("statusColumn", dataSource.statusColumn);
|
||||
}
|
||||
if (dataSource.additionalColumns && dataSource.additionalColumns.length > 0) {
|
||||
params.append("additionalColumns", dataSource.additionalColumns.join(","));
|
||||
}
|
||||
if (dataSource.whereClause) {
|
||||
params.append("whereClause", dataSource.whereClause);
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "데이터 조회 실패");
|
||||
}
|
||||
|
||||
setMarkers(result.data.markers || []);
|
||||
setLastUpdate(new Date());
|
||||
} catch (err: any) {
|
||||
console.error("지도 데이터 로드 오류:", err);
|
||||
setError(err.message || "데이터를 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 로드 및 자동 새로고침
|
||||
useEffect(() => {
|
||||
loadMapData();
|
||||
|
||||
if (refreshInterval > 0) {
|
||||
const interval = setInterval(loadMapData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [
|
||||
dataSource?.type,
|
||||
dataSource?.connectionId,
|
||||
dataSource?.tableName,
|
||||
dataSource?.latColumn,
|
||||
dataSource?.lngColumn,
|
||||
dataSource?.whereClause,
|
||||
refreshInterval,
|
||||
]);
|
||||
|
||||
// 마커 색상 가져오기
|
||||
const getMarkerColor = (status?: string): string => {
|
||||
if (!status || !markerConfig?.statusColors) {
|
||||
return markerConfig?.statusColors?.default || "#3b82f6";
|
||||
}
|
||||
return markerConfig.statusColors[status] || markerConfig.statusColors.default || "#3b82f6";
|
||||
};
|
||||
|
||||
// 커스텀 마커 아이콘 생성
|
||||
const createMarkerIcon = (status?: string) => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
const L = require("leaflet");
|
||||
const color = getMarkerColor(status);
|
||||
|
||||
return new L.Icon({
|
||||
iconUrl: `data:image/svg+xml;base64,${btoa(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="41" viewBox="0 0 25 41">
|
||||
<path d="M12.5 0C5.6 0 0 5.6 0 12.5c0 8.4 12.5 28.5 12.5 28.5S25 20.9 25 12.5C25 5.6 19.4 0 12.5 0z" fill="${color}"/>
|
||||
<circle cx="12.5" cy="12.5" r="6" fill="white"/>
|
||||
</svg>
|
||||
`)}`,
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [0, -41],
|
||||
});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="mx-auto h-12 w-12 text-red-500" />
|
||||
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||
<Button onClick={loadMapData} className="mt-4" size="sm">
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* 지도 */}
|
||||
{typeof window !== "undefined" && (
|
||||
<MapContainer
|
||||
center={[
|
||||
mapConfig?.center?.lat || 36.5,
|
||||
mapConfig?.center?.lng || 127.5,
|
||||
]}
|
||||
zoom={mapConfig?.zoom || 7}
|
||||
minZoom={mapConfig?.minZoom || 5}
|
||||
maxZoom={mapConfig?.maxZoom || 18}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
/>
|
||||
|
||||
{/* 마커 렌더링 */}
|
||||
{markers.map((marker) => (
|
||||
<Marker
|
||||
key={marker.id}
|
||||
position={[marker.latitude, marker.longitude]}
|
||||
icon={createMarkerIcon(marker.status)}
|
||||
>
|
||||
{markerConfig?.showPopup !== false && (
|
||||
<Popup>
|
||||
<div className="text-sm">
|
||||
{marker.label && (
|
||||
<div className="mb-2 font-bold text-base">{marker.label}</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<strong>위도:</strong> {marker.latitude.toFixed(6)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>경도:</strong> {marker.longitude.toFixed(6)}
|
||||
</div>
|
||||
{marker.status && (
|
||||
<div>
|
||||
<strong>상태:</strong> {marker.status}
|
||||
</div>
|
||||
)}
|
||||
{marker.additionalInfo &&
|
||||
Object.entries(marker.additionalInfo).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
)}
|
||||
|
||||
{/* 상단 정보 바 */}
|
||||
<div className="absolute top-2 right-2 z-[1000] flex items-center gap-2 rounded-lg bg-white/90 backdrop-blur-sm px-3 py-2 shadow-lg">
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
마커: {markers.length}개
|
||||
</span>
|
||||
{lastUpdate && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{lastUpdate.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={loadMapData}
|
||||
disabled={isLoading}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,439 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface MapConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (config: any) => void;
|
||||
}
|
||||
|
||||
interface DbConnection {
|
||||
id: number;
|
||||
name: string;
|
||||
db_type: string;
|
||||
}
|
||||
|
||||
interface TableInfo {
|
||||
table_name: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
}
|
||||
|
||||
export default function MapConfigPanel({ config, onChange }: MapConfigPanelProps) {
|
||||
const [connections, setConnections] = useState<DbConnection[]>([]);
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [isLoadingConnections, setIsLoadingConnections] = useState(false);
|
||||
const [isLoadingTables, setIsLoadingTables] = useState(false);
|
||||
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
|
||||
|
||||
// DB 연결 목록 로드
|
||||
useEffect(() => {
|
||||
loadConnections();
|
||||
}, []);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
if (config.dataSource?.type === "external" && config.dataSource?.connectionId) {
|
||||
loadTables(config.dataSource.connectionId);
|
||||
} else if (config.dataSource?.type === "internal") {
|
||||
loadInternalTables();
|
||||
}
|
||||
}, [config.dataSource?.type, config.dataSource?.connectionId]);
|
||||
|
||||
// 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (config.dataSource?.tableName) {
|
||||
if (config.dataSource.type === "external" && config.dataSource.connectionId) {
|
||||
loadColumns(config.dataSource.connectionId, config.dataSource.tableName);
|
||||
} else if (config.dataSource.type === "internal") {
|
||||
loadInternalColumns(config.dataSource.tableName);
|
||||
}
|
||||
}
|
||||
}, [config.dataSource?.tableName]);
|
||||
|
||||
const loadConnections = async () => {
|
||||
setIsLoadingConnections(true);
|
||||
try {
|
||||
const response = await fetch("/api/external-db-connections");
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setConnections(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("DB 연결 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoadingConnections(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTables = async (connectionId: number) => {
|
||||
setIsLoadingTables(true);
|
||||
try {
|
||||
const response = await fetch(`/api/external-db-connections/${connectionId}/tables`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setTables(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoadingTables(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadInternalTables = async () => {
|
||||
setIsLoadingTables(true);
|
||||
try {
|
||||
const response = await fetch("/api/table-management/tables");
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setTables(data.data.map((t: any) => ({ table_name: t.tableName })) || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("내부 테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoadingTables(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadColumns = async (connectionId: number, tableName: string) => {
|
||||
setIsLoadingColumns(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/external-db-connections/${connectionId}/tables/${encodeURIComponent(tableName)}/columns`
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setColumns(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadInternalColumns = async (tableName: string) => {
|
||||
setIsLoadingColumns(true);
|
||||
try {
|
||||
const response = await fetch(`/api/table-management/tables/${encodeURIComponent(tableName)}/columns`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setColumns(data.data.map((c: any) => ({ column_name: c.columnName, data_type: c.dataType })) || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("내부 컬럼 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateConfig = (path: string, value: any) => {
|
||||
const keys = path.split(".");
|
||||
const newConfig = { ...config };
|
||||
let current: any = newConfig;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) {
|
||||
current[keys[i]] = {};
|
||||
}
|
||||
current = current[keys[i]];
|
||||
}
|
||||
|
||||
current[keys[keys.length - 1]] = value;
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">📊 데이터 소스</h3>
|
||||
|
||||
{/* DB 타입 선택 */}
|
||||
<div className="space-y-2 mb-3">
|
||||
<Label>DB 타입</Label>
|
||||
<Select
|
||||
value={config.dataSource?.type || "internal"}
|
||||
onValueChange={(value) => {
|
||||
updateConfig("dataSource.type", value);
|
||||
updateConfig("dataSource.tableName", "");
|
||||
updateConfig("dataSource.connectionId", null);
|
||||
setTables([]);
|
||||
setColumns([]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="DB 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="internal">내부 DB (PostgreSQL)</SelectItem>
|
||||
<SelectItem value="external">외부 DB 연결</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 외부 DB 연결 선택 */}
|
||||
{config.dataSource?.type === "external" && (
|
||||
<div className="space-y-2 mb-3">
|
||||
<Label>외부 DB 연결</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={config.dataSource?.connectionId?.toString() || ""}
|
||||
onValueChange={(value) => {
|
||||
updateConfig("dataSource.connectionId", parseInt(value));
|
||||
updateConfig("dataSource.tableName", "");
|
||||
setTables([]);
|
||||
setColumns([]);
|
||||
}}
|
||||
disabled={isLoadingConnections}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="DB 연결 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||
{conn.name} ({conn.db_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={loadConnections}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={isLoadingConnections}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingConnections ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
<div className="space-y-2 mb-3">
|
||||
<Label>테이블</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={config.dataSource?.tableName || ""}
|
||||
onValueChange={(value) => {
|
||||
updateConfig("dataSource.tableName", value);
|
||||
setColumns([]);
|
||||
}}
|
||||
disabled={
|
||||
isLoadingTables ||
|
||||
(config.dataSource?.type === "external" && !config.dataSource?.connectionId)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
{table.table_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (config.dataSource?.type === "external" && config.dataSource?.connectionId) {
|
||||
loadTables(config.dataSource.connectionId);
|
||||
} else if (config.dataSource?.type === "internal") {
|
||||
loadInternalTables();
|
||||
}
|
||||
}}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={isLoadingTables}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingTables ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 위도 컬럼 */}
|
||||
<div className="space-y-2 mb-3">
|
||||
<Label>위도 컬럼 *</Label>
|
||||
<Select
|
||||
value={config.dataSource?.latColumn || ""}
|
||||
onValueChange={(value) => updateConfig("dataSource.latColumn", value)}
|
||||
disabled={isLoadingColumns || !config.dataSource?.tableName}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="위도 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_name} ({col.data_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 경도 컬럼 */}
|
||||
<div className="space-y-2 mb-3">
|
||||
<Label>경도 컬럼 *</Label>
|
||||
<Select
|
||||
value={config.dataSource?.lngColumn || ""}
|
||||
onValueChange={(value) => updateConfig("dataSource.lngColumn", value)}
|
||||
disabled={isLoadingColumns || !config.dataSource?.tableName}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="경도 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_name} ({col.data_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 라벨 컬럼 (선택) */}
|
||||
<div className="space-y-2 mb-3">
|
||||
<Label>라벨 컬럼 (선택사항)</Label>
|
||||
<Select
|
||||
value={config.dataSource?.labelColumn || ""}
|
||||
onValueChange={(value) => updateConfig("dataSource.labelColumn", value)}
|
||||
disabled={isLoadingColumns || !config.dataSource?.tableName}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="라벨 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">선택 안 함</SelectItem>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_name} ({col.data_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 상태 컬럼 (선택) */}
|
||||
<div className="space-y-2 mb-3">
|
||||
<Label>상태 컬럼 (선택사항)</Label>
|
||||
<Select
|
||||
value={config.dataSource?.statusColumn || ""}
|
||||
onValueChange={(value) => updateConfig("dataSource.statusColumn", value)}
|
||||
disabled={isLoadingColumns || !config.dataSource?.tableName}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">선택 안 함</SelectItem>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_name} ({col.data_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* WHERE 조건 (선택) */}
|
||||
<div className="space-y-2 mb-3">
|
||||
<Label>WHERE 조건 (선택사항)</Label>
|
||||
<Textarea
|
||||
value={config.dataSource?.whereClause || ""}
|
||||
onChange={(e) => updateConfig("dataSource.whereClause", e.target.value)}
|
||||
placeholder="예: status = 'active' AND city = 'Seoul'"
|
||||
rows={2}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">SQL WHERE 절 (WHERE 키워드 제외)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">🗺️ 지도 설정</h3>
|
||||
|
||||
{/* 중심 좌표 */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<div className="space-y-2">
|
||||
<Label>중심 위도</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={config.mapConfig?.center?.lat || 36.5}
|
||||
onChange={(e) =>
|
||||
updateConfig("mapConfig.center.lat", parseFloat(e.target.value) || 36.5)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>중심 경도</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={config.mapConfig?.center?.lng || 127.5}
|
||||
onChange={(e) =>
|
||||
updateConfig("mapConfig.center.lng", parseFloat(e.target.value) || 127.5)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 줌 레벨 */}
|
||||
<div className="space-y-2 mb-3">
|
||||
<Label>기본 줌 레벨</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="18"
|
||||
value={config.mapConfig?.zoom || 7}
|
||||
onChange={(e) => updateConfig("mapConfig.zoom", parseInt(e.target.value) || 7)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">🔄 새로고침 설정</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>자동 새로고침 (초)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={(config.refreshInterval || 0) / 1000}
|
||||
onChange={(e) =>
|
||||
updateConfig("refreshInterval", parseInt(e.target.value) * 1000 || 0)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">0이면 자동 새로고침 없음</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Map } from "lucide-react";
|
||||
|
||||
interface MapPreviewComponentProps {
|
||||
component: {
|
||||
config?: {
|
||||
dataSource?: {
|
||||
tableName?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default function MapPreviewComponent({ component }: MapPreviewComponentProps) {
|
||||
const tableName = component.config?.dataSource?.tableName;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-dashed border-blue-300">
|
||||
<div className="text-center">
|
||||
<Map className="mx-auto h-12 w-12 text-blue-600" />
|
||||
<p className="mt-2 text-sm font-medium text-blue-900">지도 컴포넌트</p>
|
||||
{tableName && (
|
||||
<p className="mt-1 text-xs text-blue-600">테이블: {tableName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import MapComponent from "./MapComponent";
|
||||
import MapPreviewComponent from "./MapPreviewComponent";
|
||||
import MapConfigPanel from "./MapConfigPanel";
|
||||
|
||||
/**
|
||||
* Map 컴포넌트 렌더러 (자동 등록)
|
||||
*/
|
||||
export class MapRenderer extends AutoRegisteringComponentRenderer {
|
||||
static definition = {
|
||||
id: "map",
|
||||
name: "지도",
|
||||
nameEng: "Map Component",
|
||||
description: "외부/내부 DB 데이터를 지도에 마커로 표시합니다 (위도/경도 필요)",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text" as const,
|
||||
component: MapComponent,
|
||||
previewComponent: MapPreviewComponent,
|
||||
defaultConfig: {
|
||||
// 데이터 소스 설정
|
||||
dataSource: {
|
||||
type: "internal", // "internal" | "external"
|
||||
connectionId: null, // 외부 DB 연결 ID
|
||||
tableName: "",
|
||||
latColumn: "latitude", // 위도 컬럼명
|
||||
lngColumn: "longitude", // 경도 컬럼명
|
||||
labelColumn: "", // 마커 라벨 컬럼명
|
||||
statusColumn: "", // 상태 컬럼명 (마커 색상용)
|
||||
additionalColumns: [], // 추가 표시할 컬럼들
|
||||
whereClause: "", // WHERE 조건절
|
||||
},
|
||||
|
||||
// 지도 설정
|
||||
mapConfig: {
|
||||
center: {
|
||||
lat: 36.5,
|
||||
lng: 127.5,
|
||||
},
|
||||
zoom: 7,
|
||||
minZoom: 5,
|
||||
maxZoom: 18,
|
||||
},
|
||||
|
||||
// 마커 설정
|
||||
markerConfig: {
|
||||
showLabel: true,
|
||||
showPopup: true,
|
||||
clusterMarkers: false, // 마커 클러스터링
|
||||
statusColors: {
|
||||
default: "#3b82f6", // 기본 파란색
|
||||
active: "#22c55e", // 활성 녹색
|
||||
inactive: "#94a3b8", // 비활성 회색
|
||||
warning: "#f59e0b", // 경고 주황색
|
||||
danger: "#ef4444", // 위험 빨간색
|
||||
},
|
||||
},
|
||||
|
||||
// 새로고침 설정
|
||||
refreshInterval: 30000, // 30초 (0이면 자동 새로고침 없음)
|
||||
},
|
||||
defaultSize: { width: 800, height: 600 },
|
||||
configPanel: MapConfigPanel,
|
||||
icon: "Map",
|
||||
tags: ["map", "location", "gps", "marker", "leaflet"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation:
|
||||
"외부/내부 DB 데이터를 지도에 표시하는 컴포넌트입니다. 위도/경도 컬럼이 있는 테이블이면 어떤 데이터든 지도에 마커로 표시할 수 있습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
new MapRenderer();
|
||||
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { ComponentConfig } from "../../types";
|
||||
|
||||
export const mapComponent: ComponentConfig = {
|
||||
type: "map",
|
||||
name: "지도",
|
||||
icon: "Map",
|
||||
category: "data-display",
|
||||
description: "DB 데이터를 지도에 마커로 표시합니다 (위도/경도 필요)",
|
||||
defaultSize: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
},
|
||||
defaultConfig: {
|
||||
// 데이터 소스 설정
|
||||
dataSource: {
|
||||
type: "internal", // "internal" | "external"
|
||||
connectionId: null, // 외부 DB 연결 ID
|
||||
tableName: "",
|
||||
latColumn: "latitude", // 위도 컬럼명
|
||||
lngColumn: "longitude", // 경도 컬럼명
|
||||
labelColumn: "", // 마커 라벨 컬럼명
|
||||
statusColumn: "", // 상태 컬럼명 (마커 색상용)
|
||||
additionalColumns: [], // 추가 표시할 컬럼들
|
||||
whereClause: "", // WHERE 조건절
|
||||
},
|
||||
|
||||
// 지도 설정
|
||||
mapConfig: {
|
||||
center: {
|
||||
lat: 36.5,
|
||||
lng: 127.5,
|
||||
},
|
||||
zoom: 7,
|
||||
minZoom: 5,
|
||||
maxZoom: 18,
|
||||
},
|
||||
|
||||
// 마커 설정
|
||||
markerConfig: {
|
||||
showLabel: true,
|
||||
showPopup: true,
|
||||
clusterMarkers: false, // 마커 클러스터링
|
||||
statusColors: {
|
||||
default: "#3b82f6", // 기본 파란색
|
||||
active: "#22c55e", // 활성 녹색
|
||||
inactive: "#94a3b8", // 비활성 회색
|
||||
warning: "#f59e0b", // 경고 주황색
|
||||
danger: "#ef4444", // 위험 빨간색
|
||||
},
|
||||
},
|
||||
|
||||
// 새로고침 설정
|
||||
refreshInterval: 30000, // 30초 (0이면 자동 새로고침 없음)
|
||||
},
|
||||
component: () => import("./MapComponent"),
|
||||
previewComponent: () => import("./MapPreviewComponent"),
|
||||
configPanel: () => import("./MapConfigPanel"),
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue