Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs
2025-12-08 15:16:07 +09:00
14 changed files with 3693 additions and 228 deletions

View File

@@ -53,6 +53,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
const [retryCount, setRetryCount] = useState(0);
const [retryDelay, setRetryDelay] = useState(1000);
const [isActive, setIsActive] = useState(true);
const [saveToHistory, setSaveToHistory] = useState(false); // 위치 이력 저장 설정
// UI 상태
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -80,6 +81,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setRetryCount(connection.retry_count || 0);
setRetryDelay(connection.retry_delay || 1000);
setIsActive(connection.is_active === "Y");
setSaveToHistory(connection.save_to_history === "Y");
// 테스트 초기값 설정
setTestEndpoint("");
@@ -100,6 +102,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setRetryCount(0);
setRetryDelay(1000);
setIsActive(true);
setSaveToHistory(false);
// 테스트 초기값 설정
setTestEndpoint("");
@@ -234,6 +237,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
retry_delay: retryDelay,
// company_code는 백엔드에서 로그인 사용자의 company_code로 자동 설정
is_active: isActive ? "Y" : "N",
save_to_history: saveToHistory ? "Y" : "N",
};
console.log("저장하려는 데이터:", {
@@ -376,6 +380,16 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
</Label>
</div>
<div className="flex items-center space-x-2">
<Switch id="save-to-history" checked={saveToHistory} onCheckedChange={setSaveToHistory} />
<Label htmlFor="save-to-history" className="cursor-pointer">
</Label>
<span className="text-xs text-muted-foreground">
( API vehicle_location_history에 )
</span>
</div>
</div>
{/* 헤더 관리 */}

View File

@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
import { getApiUrl } from "@/lib/utils/apiUrl";
@@ -850,6 +851,23 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
</div>
)}
{/* 위치 이력 저장 설정 (지도 위젯용) */}
<div className="flex items-center justify-between rounded-lg border bg-muted/30 p-3">
<div className="space-y-0.5">
<Label htmlFor="save-to-history" className="text-xs font-semibold cursor-pointer">
</Label>
<p className="text-[10px] text-muted-foreground">
REST API에서 vehicle_location_history에
</p>
</div>
<Switch
id="save-to-history"
checked={dataSource.saveToHistory || false}
onCheckedChange={(checked) => onChange({ saveToHistory: checked })}
/>
</div>
{/* 컬럼 매핑 (API 테스트 성공 후에만 표시) */}
{testResult?.success && availableColumns.length > 0 && (
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">

View File

@@ -183,6 +183,9 @@ export interface ChartDataSource {
label: string; // 표시할 한글명 (예: 차량 번호)
format?: "text" | "date" | "datetime" | "number" | "url"; // 표시 포맷
}[];
// REST API 위치 데이터 저장 설정 (MapTestWidgetV2용)
saveToHistory?: boolean; // REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장
}
export interface ChartConfig {

View File

@@ -94,12 +94,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [error, setError] = useState<string | null>(null);
const [geoJsonData, setGeoJsonData] = useState<any>(null);
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
// 이동경로 상태
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [routeLoading, setRouteLoading] = useState(false);
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split('T')[0]); // YYYY-MM-DD 형식
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식
// dataSources를 useMemo로 추출 (circular reference 방지)
const dataSources = useMemo(() => {
@@ -122,62 +122,59 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
}, []);
// 이동경로 로드 함수
const loadRoute = useCallback(async (userId: string, date?: string) => {
if (!userId) {
console.log("🛣️ 이동경로 조회 불가: userId 없음");
return;
}
const loadRoute = useCallback(
async (userId: string, date?: string) => {
if (!userId) {
return;
}
setRouteLoading(true);
setSelectedUserId(userId);
setRouteLoading(true);
setSelectedUserId(userId);
try {
// 선택한 날짜 기준으로 이동경로 조회
const targetDate = date || routeDate;
const startOfDay = `${targetDate}T00:00:00.000Z`;
const endOfDay = `${targetDate}T23:59:59.999Z`;
const query = `SELECT latitude, longitude, recorded_at
try {
// 선택한 날짜 기준으로 이동경로 조회
const targetDate = date || routeDate;
const startOfDay = `${targetDate}T00:00:00.000Z`;
const endOfDay = `${targetDate}T23:59:59.999Z`;
const query = `SELECT latitude, longitude, recorded_at
FROM vehicle_location_history
WHERE user_id = '${userId}'
AND recorded_at >= '${startOfDay}'
AND recorded_at <= '${endOfDay}'
ORDER BY recorded_at ASC`;
console.log("🛣️ 이동경로 쿼리:", query);
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
},
body: JSON.stringify({ query }),
});
const response = await fetch(getApiUrl("/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 points: RoutePoint[] = result.data.rows.map((row: any) => ({
lat: parseFloat(row.latitude),
lng: parseFloat(row.longitude),
recordedAt: row.recorded_at,
}));
if (response.ok) {
const result = await response.json();
if (result.success && result.data.rows.length > 0) {
const points: RoutePoint[] = result.data.rows.map((row: any) => ({
lat: parseFloat(row.latitude),
lng: parseFloat(row.longitude),
recordedAt: row.recorded_at,
}));
console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`);
setRoutePoints(points);
} else {
console.log("🛣️ 이동경로 데이터 없음");
setRoutePoints([]);
setRoutePoints(points);
} else {
setRoutePoints([]);
}
}
} catch {
setRoutePoints([]);
}
} catch (error) {
console.error("이동경로 로드 실패:", error);
setRoutePoints([]);
}
setRouteLoading(false);
}, [routeDate]);
setRouteLoading(false);
},
[routeDate],
);
// 이동경로 숨기기
const clearRoute = useCallback(() => {
@@ -297,6 +294,17 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
});
}
// Request Body 파싱
let requestBody: any = undefined;
if (source.body) {
try {
requestBody = JSON.parse(source.body);
} catch {
// JSON 파싱 실패시 문자열 그대로 사용
requestBody = source.body;
}
}
// 백엔드 프록시를 통해 API 호출
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
@@ -309,6 +317,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
method: source.method || "GET",
headers,
queryParams,
body: requestBody,
externalConnectionId: source.externalConnectionId,
}),
});
@@ -344,14 +354,81 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
}
}
// 데이터가 null/undefined면 빈 결과 반환
if (data === null || data === undefined) {
return { markers: [], polygons: [] };
}
const rows = Array.isArray(data) ? data : [data];
// 컬럼 매핑 적용
const mappedRows = applyColumnMapping(rows, source.columnMapping);
// 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달)
const finalResult = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source);
return finalResult;
const mapData = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source);
// ✅ REST API 데이터를 vehicle_location_history에 자동 저장 (경로 보기용)
// - 모든 REST API 차량 위치 데이터는 자동으로 저장됨
if (mapData.markers.length > 0) {
try {
const authToken = typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : "";
// 마커 데이터를 vehicle_location_history에 저장
for (const marker of mapData.markers) {
// user_id 추출 (마커 description에서 파싱)
let userId = "";
let vehicleId: number | undefined = undefined;
let vehicleName = "";
if (marker.description) {
try {
const parsed = JSON.parse(marker.description);
// 다양한 필드명 지원 (plate_no 우선 - 차량 번호판으로 경로 구분)
userId = parsed.plate_no || parsed.plateNo || parsed.car_number || parsed.carNumber ||
parsed.user_id || parsed.userId || parsed.driver_id || parsed.driverId ||
parsed.car_no || parsed.carNo || parsed.vehicle_no || parsed.vehicleNo ||
parsed.id || parsed.code || "";
vehicleId = parsed.vehicle_id || parsed.vehicleId || parsed.car_id || parsed.carId;
vehicleName = parsed.plate_no || parsed.plateNo || parsed.car_name || parsed.carName ||
parsed.vehicle_name || parsed.vehicleName || parsed.name || parsed.title || "";
} catch {
// 파싱 실패 시 무시
}
}
// user_id가 없으면 마커 이름이나 ID를 사용
if (!userId) {
userId = marker.name || marker.id || `marker_${Date.now()}`;
}
// vehicle_location_history에 저장
await fetch(getApiUrl("/api/dynamic-form/location-history"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
credentials: "include",
body: JSON.stringify({
latitude: marker.lat,
longitude: marker.lng,
userId: userId,
vehicleId: vehicleId,
tripStatus: "api_tracking", // REST API에서 가져온 데이터 표시
departureName: source.name || "REST API",
destinationName: vehicleName || marker.name,
}),
});
console.log("📍 [saveToHistory] 저장 완료:", { userId, lat: marker.lat, lng: marker.lng });
}
} catch (saveError) {
console.error("❌ [saveToHistory] 저장 실패:", saveError);
// 저장 실패해도 마커 표시는 계속
}
}
return mapData;
};
// Database 데이터 로딩
@@ -485,6 +562,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const polygons: PolygonData[] = [];
rows.forEach((row, index) => {
// null/undefined 체크
if (!row) {
return;
}
// 텍스트 데이터 체크 (기상청 API 등)
if (row && typeof row === "object" && row.text && typeof row.text === "string") {
const parsedData = parseTextData(row.text);
@@ -1098,13 +1180,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
}}
className="h-6 rounded border-none bg-transparent px-1 text-xs text-blue-600 focus:outline-none"
/>
<span className="text-xs text-blue-600">
({routePoints.length})
</span>
<button
onClick={clearRoute}
className="ml-1 text-xs text-blue-400 hover:text-blue-600"
>
<span className="text-xs text-blue-600">({routePoints.length})</span>
<button onClick={clearRoute} className="ml-1 text-xs text-blue-400 hover:text-blue-600">
</button>
</div>
@@ -1409,12 +1486,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
// 트럭 마커
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요
const rotation = heading - 90;
// 회전 각도가 90~270도 범위면 차량이 뒤집어짐 (바퀴가 위로)
// 이 경우 scaleY(-1)로 상하 반전하여 바퀴가 아래로 오도록 함
const normalizedRotation = ((rotation % 360) + 360) % 360;
const isFlipped = normalizedRotation > 90 && normalizedRotation < 270;
const transformStyle = isFlipped
const transformStyle = isFlipped
? `translate(-50%, -50%) rotate(${rotation}deg) scaleY(-1)`
: `translate(-50%, -50%) rotate(${rotation}deg)`;
@@ -1645,18 +1722,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
{(() => {
try {
const parsed = JSON.parse(marker.description || "{}");
const userId = parsed.user_id;
if (userId) {
// 다양한 필드명 지원 (plate_no 우선)
const visibleUserId = parsed.plate_no || parsed.plateNo || parsed.car_number || parsed.carNumber ||
parsed.user_id || parsed.userId || parsed.driver_id || parsed.driverId ||
parsed.car_no || parsed.carNo || parsed.vehicle_no || parsed.vehicleNo ||
parsed.id || parsed.code || marker.name;
if (visibleUserId) {
return (
<div className="mt-2 border-t pt-2">
<button
onClick={() => loadRoute(userId)}
onClick={() => loadRoute(visibleUserId)}
disabled={routeLoading}
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
>
{routeLoading && selectedUserId === userId
? "로딩 중..."
: "🛣️ 이동경로 보기"}
{routeLoading && selectedUserId === visibleUserId ? "로딩 중..." : "🛣️ 이동경로 보기"}
</button>
</div>
);