Merge pull request '지도에 마커 기능 추가' (#155) from feat/rest-api into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/155
This commit is contained in:
hyeonsu 2025-10-28 13:39:17 +09:00
commit 63db466504
3 changed files with 443 additions and 127 deletions

View File

@ -1,7 +1,9 @@
'use client';
"use client";
import React, { useState, useCallback } from 'react';
import { ChartConfig, QueryResult } from './types';
import React, { useState, useCallback } from "react";
import { ChartConfig, QueryResult, MarkerColorRule } from "./types";
import { Plus, Trash2 } from "lucide-react";
import { v4 as uuidv4 } from "uuid";
interface VehicleMapConfigPanelProps {
config?: ChartConfig;
@ -18,24 +20,80 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
// 설정 업데이트
const updateConfig = useCallback((updates: Partial<ChartConfig>) => {
const newConfig = { ...currentConfig, ...updates };
setCurrentConfig(newConfig);
onConfigChange(newConfig);
}, [currentConfig, onConfigChange]);
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] || {};
// 마커 색상 모드 변경
const handleMarkerColorModeChange = useCallback(
(mode: "single" | "conditional") => {
if (mode === "single") {
updateConfig({
markerColorMode: "single",
markerColorColumn: undefined,
markerColorRules: undefined,
markerDefaultColor: "#3b82f6", // 파란색
});
} else {
updateConfig({
markerColorMode: "conditional",
markerColorRules: [],
markerDefaultColor: "#6b7280", // 회색
});
}
},
[updateConfig],
);
// 마커 색상 규칙 추가
const addColorRule = useCallback(() => {
const newRule: MarkerColorRule = {
id: uuidv4(),
value: "",
color: "#3b82f6",
label: "",
};
const currentRules = currentConfig.markerColorRules || [];
updateConfig({ markerColorRules: [...currentRules, newRule] });
}, [currentConfig.markerColorRules, updateConfig]);
// 마커 색상 규칙 삭제
const deleteColorRule = useCallback(
(id: string) => {
const currentRules = currentConfig.markerColorRules || [];
updateConfig({ markerColorRules: currentRules.filter((rule) => rule.id !== id) });
},
[currentConfig.markerColorRules, updateConfig],
);
// 마커 색상 규칙 업데이트
const updateColorRule = useCallback(
(id: string, updates: Partial<MarkerColorRule>) => {
const currentRules = currentConfig.markerColorRules || [];
updateConfig({
markerColorRules: currentRules.map((rule) => (rule.id === id ? { ...rule, ...updates } : rule)),
});
},
[currentConfig.markerColorRules, updateConfig],
);
return (
<div className="space-y-3">
<h4 className="text-xs font-semibold text-gray-800">🗺 </h4>
{/* 쿼리 결과가 없을 때 */}
{!queryResult && (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="text-yellow-800 text-xs">
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3">
<div className="text-xs text-yellow-800">
💡 SQL .
</div>
</div>
@ -49,10 +107,10 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
<label className="block text-xs font-medium text-gray-700"> </label>
<input
type="text"
value={currentConfig.title || ''}
value={currentConfig.title || ""}
onChange={(e) => updateConfig({ title: e.target.value })}
placeholder="차량 위치 지도"
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
/>
</div>
@ -60,12 +118,12 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
<div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700">
(Latitude)
<span className="text-red-500 ml-1">*</span>
<span className="ml-1 text-red-500">*</span>
</label>
<select
value={currentConfig.latitudeColumn || ''}
value={currentConfig.latitudeColumn || ""}
onChange={(e) => updateConfig({ latitudeColumn: e.target.value })}
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
>
<option value=""></option>
{availableColumns.map((col) => (
@ -80,12 +138,12 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
<div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700">
(Longitude)
<span className="text-red-500 ml-1">*</span>
<span className="ml-1 text-red-500">*</span>
</label>
<select
value={currentConfig.longitudeColumn || ''}
value={currentConfig.longitudeColumn || ""}
onChange={(e) => updateConfig({ longitudeColumn: e.target.value })}
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
>
<option value=""></option>
{availableColumns.map((col) => (
@ -98,13 +156,11 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
{/* 라벨 컬럼 (선택사항) */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700">
( )
</label>
<label className="block text-xs font-medium text-gray-700"> ( )</label>
<select
value={currentConfig.labelColumn || ''}
value={currentConfig.labelColumn || ""}
onChange={(e) => updateConfig({ labelColumn: e.target.value })}
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
>
<option value=""> ()</option>
{availableColumns.map((col) => (
@ -115,74 +171,265 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
</select>
</div>
{/* 상태 컬럼 (선택사항) */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700">
( )
</label>
<select
value={currentConfig.statusColumn || ''}
onChange={(e) => updateConfig({ statusColumn: e.target.value })}
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
>
<option value=""> ()</option>
{availableColumns.map((col) => (
<option key={col} value={col}>
{col}
</option>
))}
</select>
{/* 마커 색상 설정 */}
<div className="space-y-2 border-t pt-3">
<h5 className="text-xs font-semibold text-gray-800">🎨 </h5>
{/* 색상 모드 선택 */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700"> </label>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleMarkerColorModeChange("single")}
className={`flex-1 rounded-lg border px-3 py-2 text-xs transition-colors ${
(currentConfig.markerColorMode || "single") === "single"
? "border-blue-300 bg-blue-50 font-medium text-blue-700"
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
}`}
>
</button>
<button
type="button"
onClick={() => handleMarkerColorModeChange("conditional")}
className={`flex-1 rounded-lg border px-3 py-2 text-xs transition-colors ${
currentConfig.markerColorMode === "conditional"
? "border-blue-300 bg-blue-50 font-medium text-blue-700"
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
}`}
>
</button>
</div>
</div>
{/* 단일 색상 모드 */}
{(currentConfig.markerColorMode || "single") === "single" && (
<div className="space-y-1.5 rounded-lg bg-gray-50 p-3">
<label className="block text-xs font-medium text-gray-700"> </label>
<div className="flex items-center gap-2">
<input
type="color"
value={currentConfig.markerDefaultColor || "#3b82f6"}
onChange={(e) => updateConfig({ markerDefaultColor: e.target.value })}
className="h-8 w-12 cursor-pointer rounded border border-gray-300"
/>
<input
type="text"
value={currentConfig.markerDefaultColor || "#3b82f6"}
onChange={(e) => updateConfig({ markerDefaultColor: e.target.value })}
placeholder="#3b82f6"
className="flex-1 rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
/>
</div>
<p className="text-xs text-gray-500"> </p>
</div>
)}
{/* 조건부 색상 모드 */}
{currentConfig.markerColorMode === "conditional" && (
<div className="space-y-2 rounded-lg bg-gray-50 p-3">
{/* 색상 조건 컬럼 선택 */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700">
<span className="ml-1 text-red-500">*</span>
</label>
<select
value={currentConfig.markerColorColumn || ""}
onChange={(e) => updateConfig({ markerColorColumn: e.target.value })}
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
>
<option value=""></option>
{availableColumns.map((col) => (
<option key={col} value={col}>
{col}
</option>
))}
</select>
<p className="text-xs text-gray-500"> </p>
</div>
{/* 기본 색상 */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700"> </label>
<div className="flex items-center gap-2">
<input
type="color"
value={currentConfig.markerDefaultColor || "#6b7280"}
onChange={(e) => updateConfig({ markerDefaultColor: e.target.value })}
className="h-8 w-12 cursor-pointer rounded border border-gray-300"
/>
<input
type="text"
value={currentConfig.markerDefaultColor || "#6b7280"}
onChange={(e) => updateConfig({ markerDefaultColor: e.target.value })}
placeholder="#6b7280"
className="flex-1 rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
/>
</div>
<p className="text-xs text-gray-500"> </p>
</div>
{/* 색상 규칙 목록 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="block text-xs font-medium text-gray-700"> </label>
<button
type="button"
onClick={addColorRule}
className="flex items-center gap-1 rounded-lg bg-blue-500 px-2 py-1 text-xs text-white transition-colors hover:bg-blue-600"
>
<Plus className="h-3 w-3" />
</button>
</div>
{/* 규칙 리스트 */}
{(currentConfig.markerColorRules || []).length === 0 ? (
<div className="rounded-lg border border-gray-200 bg-white p-3 text-center">
<p className="text-xs text-gray-500"> </p>
</div>
) : (
<div className="space-y-2">
{(currentConfig.markerColorRules || []).map((rule) => (
<div key={rule.id} className="space-y-2 rounded-lg border border-gray-200 bg-white p-2">
{/* 규칙 헤더 */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-700"></span>
<button
type="button"
onClick={() => deleteColorRule(rule.id)}
className="text-red-500 transition-colors hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{/* 조건 값 */}
<div className="space-y-1">
<label className="block text-xs font-medium text-gray-600"> ()</label>
<input
type="text"
value={rule.value}
onChange={(e) => updateColorRule(rule.id, { value: e.target.value })}
placeholder="예: active, inactive"
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
/>
</div>
{/* 색상 */}
<div className="space-y-1">
<label className="block text-xs font-medium text-gray-600"></label>
<div className="flex items-center gap-2">
<input
type="color"
value={rule.color}
onChange={(e) => updateColorRule(rule.id, { color: e.target.value })}
className="h-8 w-12 cursor-pointer rounded border border-gray-300"
/>
<input
type="text"
value={rule.color}
onChange={(e) => updateColorRule(rule.id, { color: e.target.value })}
placeholder="#3b82f6"
className="flex-1 rounded border border-gray-300 px-2 py-1 text-xs"
/>
</div>
</div>
{/* 라벨 (선택사항) */}
<div className="space-y-1">
<label className="block text-xs font-medium text-gray-600"> ()</label>
<input
type="text"
value={rule.label || ""}
onChange={(e) => updateColorRule(rule.id, { label: e.target.value })}
placeholder="예: 활성, 비활성"
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
{/* 날씨 정보 표시 옵션 */}
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-xs font-medium text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={currentConfig.showWeather || false}
onChange={(e) => updateConfig({ showWeather: e.target.checked })}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary"
/>
<span> </span>
</label>
<p className="text-xs text-gray-500 ml-6">
</p>
</div>
<div className="space-y-1.5">
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-gray-700">
<input
type="checkbox"
checked={currentConfig.showWeather || false}
onChange={(e) => updateConfig({ showWeather: e.target.checked })}
className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300 focus:ring-2"
/>
<span> </span>
</label>
<p className="ml-6 text-xs text-gray-500"> </p>
</div>
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-xs font-medium text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={currentConfig.showWeatherAlerts || false}
onChange={(e) => updateConfig({ showWeatherAlerts: e.target.checked })}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary"
/>
<span> </span>
</label>
<p className="text-xs text-gray-500 ml-6">
(/)
</p>
</div>
<div className="space-y-1.5">
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-gray-700">
<input
type="checkbox"
checked={currentConfig.showWeatherAlerts || false}
onChange={(e) => updateConfig({ showWeatherAlerts: e.target.checked })}
className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300 focus:ring-2"
/>
<span> </span>
</label>
<p className="ml-6 text-xs text-gray-500">
(/)
</p>
</div>
{/* 설정 미리보기 */}
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-xs 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> {currentConfig.showWeather ? '활성화' : '비활성화'}</div>
<div><strong> :</strong> {currentConfig.showWeatherAlerts ? '활성화' : '비활성화'}</div>
<div><strong> :</strong> {queryResult.rows.length}</div>
<div className="rounded-lg bg-gray-50 p-3">
<div className="mb-2 text-xs font-medium text-gray-700">📋 </div>
<div className="text-muted-foreground space-y-1 text-xs">
<div>
<strong>:</strong> {currentConfig.latitudeColumn || "미설정"}
</div>
<div>
<strong>:</strong> {currentConfig.longitudeColumn || "미설정"}
</div>
<div>
<strong>:</strong> {currentConfig.labelColumn || "없음"}
</div>
<div>
<strong> :</strong> {currentConfig.markerColorMode === "conditional" ? "조건부" : "단일"}
</div>
{currentConfig.markerColorMode === "conditional" && (
<>
<div>
<strong> :</strong> {currentConfig.markerColorColumn || "미설정"}
</div>
<div>
<strong> :</strong> {(currentConfig.markerColorRules || []).length}
</div>
</>
)}
<div>
<strong> :</strong> {currentConfig.showWeather ? "활성화" : "비활성화"}
</div>
<div>
<strong> :</strong> {currentConfig.showWeatherAlerts ? "활성화" : "비활성화"}
</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-xs">
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
<div className="text-xs text-red-800">
.
</div>
</div>
@ -192,4 +439,3 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
</div>
);
}

View File

@ -205,6 +205,20 @@ export interface ChartConfig {
statusColumn?: string; // 상태 컬럼
showWeather?: boolean; // 날씨 정보 표시 여부
showWeatherAlerts?: boolean; // 기상특보 영역 표시 여부
// 마커 색상 설정
markerColorMode?: "single" | "conditional"; // 마커 색상 모드 (단일/조건부)
markerColorColumn?: string; // 색상 조건 컬럼
markerColorRules?: MarkerColorRule[]; // 색상 규칙 배열
markerDefaultColor?: string; // 기본 마커 색상
}
// 마커 색상 규칙
export interface MarkerColorRule {
id: string; // 고유 ID
value: string; // 컬럼 값 (예: "active", "inactive")
color: string; // 마커 색상 (hex)
label?: string; // 라벨 (선택사항)
}
export interface QueryResult {

View File

@ -42,6 +42,7 @@ interface MarkerData {
name: string;
info: any;
weather?: WeatherData | null;
markerColor?: string; // 마커 색상
}
// 테이블명 한글 번역
@ -472,6 +473,33 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
const latCol = element.chartConfig?.latitudeColumn || "latitude";
const lngCol = element.chartConfig?.longitudeColumn || "longitude";
// 마커 색상 결정 함수
const getMarkerColor = (row: any): string => {
const colorMode = element.chartConfig?.markerColorMode || "single";
if (colorMode === "single") {
// 단일 색상 모드
return element.chartConfig?.markerDefaultColor || "#3b82f6";
} else {
// 조건부 색상 모드
const colorColumn = element.chartConfig?.markerColorColumn;
const colorRules = element.chartConfig?.markerColorRules || [];
const defaultColor = element.chartConfig?.markerDefaultColor || "#6b7280";
if (!colorColumn || colorRules.length === 0) {
return defaultColor;
}
// 컬럼 값 가져오기
const columnValue = String(row[colorColumn] || "");
// 색상 규칙 매칭
const matchedRule = colorRules.find((rule) => String(rule.value) === columnValue);
return matchedRule ? matchedRule.color : defaultColor;
}
};
// 유효한 좌표 필터링 및 마커 데이터 생성
const markerData = rows
.filter((row: any) => row[latCol] && row[lngCol])
@ -481,6 +509,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
name: row.name || row.vehicle_number || row.warehouse_name || row.customer_name || "알 수 없음",
info: row,
weather: null,
markerColor: getMarkerColor(row), // 마커 색상 추가
}));
setMarkers(markerData);
@ -693,54 +722,81 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
})}
{/* 마커 표시 */}
{markers.map((marker, idx) => (
<Marker key={idx} position={[marker.lat, marker.lng]}>
<Popup>
<div className="min-w-[200px] text-xs">
{/* 마커 정보 */}
<div className="mb-2 border-b pb-2">
<div className="mb-1 text-sm font-bold">{marker.name}</div>
{Object.entries(marker.info)
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
.map(([key, value]) => (
<div key={key} className="text-xs">
<strong>{key}:</strong> {String(value)}
</div>
))}
</div>
{markers.map((marker, idx) => {
// Leaflet 커스텀 아이콘 생성 (클라이언트 사이드에서만)
let customIcon;
if (typeof window !== "undefined") {
const L = require("leaflet");
customIcon = L.divIcon({
className: "custom-marker",
html: `
<div style="
width: 30px;
height: 30px;
background-color: ${marker.markerColor || "#3b82f6"};
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
transform: translate(-50%, -50%);
"></div>
`,
iconSize: [30, 30],
iconAnchor: [15, 15],
});
}
{/* 날씨 정보 */}
{marker.weather && (
<div className="space-y-1">
<div className="mb-1 flex items-center gap-2">
{getWeatherIcon(marker.weather.weatherMain)}
<span className="text-xs font-semibold"> </span>
</div>
<div className="text-xs text-gray-600">{marker.weather.weatherDescription}</div>
<div className="mt-2 space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.temperature}°C</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.feelsLike}°C</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.humidity}%</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.windSpeed} m/s</span>
</div>
</div>
return (
<Marker key={idx} position={[marker.lat, marker.lng]} icon={customIcon}>
<Popup>
<div className="min-w-[200px] text-xs">
{/* 마커 정보 */}
<div className="mb-2 border-b pb-2">
<div className="mb-1 text-sm font-bold">{marker.name}</div>
{Object.entries(marker.info)
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
.map(([key, value]) => (
<div key={key} className="text-xs">
<strong>{key}:</strong> {String(value)}
</div>
))}
</div>
)}
</div>
</Popup>
</Marker>
))}
{/* 날씨 정보 */}
{marker.weather && (
<div className="space-y-1">
<div className="mb-1 flex items-center gap-2">
{getWeatherIcon(marker.weather.weatherMain)}
<span className="text-xs font-semibold"> </span>
</div>
<div className="text-xs text-gray-600">{marker.weather.weatherDescription}</div>
<div className="mt-2 space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.temperature}°C</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.feelsLike}°C</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.humidity}%</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.windSpeed} m/s</span>
</div>
</div>
</div>
)}
</div>
</Popup>
</Marker>
);
})}
</MapContainer>
{/* 범례 (특보가 있을 때만 표시) */}