Merge pull request 'lhj' (#110) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/110
This commit is contained in:
commit
8eb2f21583
|
|
@ -1 +1,54 @@
|
||||||
[]
|
[
|
||||||
|
{
|
||||||
|
"id": "e5bb334c-d58a-4068-ad77-2607a41f4675",
|
||||||
|
"title": "ㅁㄴㅇㄹ",
|
||||||
|
"description": "ㅁㄴㅇㄹ",
|
||||||
|
"priority": "normal",
|
||||||
|
"status": "pending",
|
||||||
|
"assignedTo": "",
|
||||||
|
"dueDate": "2025-10-20T18:17",
|
||||||
|
"createdAt": "2025-10-20T06:15:49.610Z",
|
||||||
|
"updatedAt": "2025-10-20T06:15:49.610Z",
|
||||||
|
"isUrgent": false,
|
||||||
|
"order": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "334be17c-7776-47e8-89ec-4b57c4a34bcd",
|
||||||
|
"title": "연동되어주겠니?",
|
||||||
|
"description": "",
|
||||||
|
"priority": "normal",
|
||||||
|
"status": "pending",
|
||||||
|
"assignedTo": "",
|
||||||
|
"dueDate": "",
|
||||||
|
"createdAt": "2025-10-20T06:20:06.343Z",
|
||||||
|
"updatedAt": "2025-10-20T06:20:06.343Z",
|
||||||
|
"isUrgent": false,
|
||||||
|
"order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "f85b81de-fcbd-4858-8973-247d9d6e70ed",
|
||||||
|
"title": "연동되어주겠니?11",
|
||||||
|
"description": "ㄴㅇㄹ",
|
||||||
|
"priority": "normal",
|
||||||
|
"status": "pending",
|
||||||
|
"assignedTo": "",
|
||||||
|
"dueDate": "2025-10-20T17:22",
|
||||||
|
"createdAt": "2025-10-20T06:20:53.818Z",
|
||||||
|
"updatedAt": "2025-10-20T06:20:53.818Z",
|
||||||
|
"isUrgent": false,
|
||||||
|
"order": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "58d2b26f-5197-4df1-b5d4-724a72ee1d05",
|
||||||
|
"title": "연동되어주려무니",
|
||||||
|
"description": "ㅁㄴㅇㄹ",
|
||||||
|
"priority": "normal",
|
||||||
|
"status": "pending",
|
||||||
|
"assignedTo": "",
|
||||||
|
"dueDate": "2025-10-21T15:21",
|
||||||
|
"createdAt": "2025-10-20T06:21:19.817Z",
|
||||||
|
"updatedAt": "2025-10-20T06:21:19.817Z",
|
||||||
|
"isUrgent": false,
|
||||||
|
"order": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -118,8 +118,8 @@ const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/W
|
||||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 운송 통계 위젯
|
// 커스텀 통계 카드 위젯
|
||||||
const TransportStatsWidget = dynamic(() => import("@/components/dashboard/widgets/TransportStatsWidget"), {
|
const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/CustomStatsWidget"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||||
});
|
});
|
||||||
|
|
@ -750,9 +750,9 @@ export function CanvasElement({
|
||||||
<WorkHistoryWidget element={element} />
|
<WorkHistoryWidget element={element} />
|
||||||
</div>
|
</div>
|
||||||
) : element.type === "widget" && element.subtype === "transport-stats" ? (
|
) : element.type === "widget" && element.subtype === "transport-stats" ? (
|
||||||
// 운송 통계 위젯 렌더링
|
// 커스텀 통계 카드 위젯 렌더링
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<TransportStatsWidget element={element} />
|
<CustomStatsWidget element={element} />
|
||||||
</div>
|
</div>
|
||||||
) : element.type === "widget" && element.subtype === "todo" ? (
|
) : element.type === "widget" && element.subtype === "todo" ? (
|
||||||
// To-Do 위젯 렌더링
|
// To-Do 위젯 렌더링
|
||||||
|
|
|
||||||
|
|
@ -647,7 +647,7 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
||||||
case "work-history":
|
case "work-history":
|
||||||
return "작업 이력";
|
return "작업 이력";
|
||||||
case "transport-stats":
|
case "transport-stats":
|
||||||
return "운송 통계";
|
return "커스텀 통계 카드";
|
||||||
default:
|
default:
|
||||||
return "위젯";
|
return "위젯";
|
||||||
}
|
}
|
||||||
|
|
@ -693,7 +693,7 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
||||||
case "work-history":
|
case "work-history":
|
||||||
return "work-history";
|
return "work-history";
|
||||||
case "transport-stats":
|
case "transport-stats":
|
||||||
return "transport-stats";
|
return "커스텀 통계 카드";
|
||||||
default:
|
default:
|
||||||
return "위젯 내용이 여기에 표시됩니다";
|
return "위젯 내용이 여기에 표시됩니다";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ export function DashboardSidebar() {
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
/>
|
/>
|
||||||
<DraggableItem
|
<DraggableItem
|
||||||
title="운송 통계"
|
title="커스텀 통계 카드"
|
||||||
type="widget"
|
type="widget"
|
||||||
subtype="transport-stats"
|
subtype="transport-stats"
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@ export function DashboardTopMenu({
|
||||||
<SelectLabel>데이터 위젯</SelectLabel>
|
<SelectLabel>데이터 위젯</SelectLabel>
|
||||||
<SelectItem value="list">리스트 위젯</SelectItem>
|
<SelectItem value="list">리스트 위젯</SelectItem>
|
||||||
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
||||||
<SelectItem value="transport-stats">운송 통계</SelectItem>
|
<SelectItem value="transport-stats">커스텀 통계 카드</SelectItem>
|
||||||
{/* <SelectItem value="map">지도</SelectItem> */}
|
{/* <SelectItem value="map">지도</SelectItem> */}
|
||||||
<SelectItem value="map-summary">커스텀 지도 카드</SelectItem>
|
<SelectItem value="map-summary">커스텀 지도 카드</SelectItem>
|
||||||
{/* <SelectItem value="list-summary">커스텀 목록 카드</SelectItem> */}
|
{/* <SelectItem value="list-summary">커스텀 목록 카드</SelectItem> */}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
element.subtype === "customer-issues" ||
|
element.subtype === "customer-issues" ||
|
||||||
element.subtype === "driver-management" ||
|
element.subtype === "driver-management" ||
|
||||||
element.subtype === "work-history" || // 작업 이력 위젯 (쿼리 필요)
|
element.subtype === "work-history" || // 작업 이력 위젯 (쿼리 필요)
|
||||||
element.subtype === "transport-stats"; // 운송 통계 위젯 (쿼리 필요)
|
element.subtype === "transport-stats"; // 커스텀 통계 카드 위젯 (쿼리 필요)
|
||||||
|
|
||||||
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
|
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
|
||||||
const isSelfContainedWidget =
|
const isSelfContainedWidget =
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export type ElementSubtype =
|
||||||
| "list"
|
| "list"
|
||||||
| "yard-management-3d" // 야드 관리 3D 위젯
|
| "yard-management-3d" // 야드 관리 3D 위젯
|
||||||
| "work-history" // 작업 이력 위젯
|
| "work-history" // 작업 이력 위젯
|
||||||
| "transport-stats"; // 운송 통계 위젯
|
| "transport-stats"; // 커스텀 통계 카드 위젯
|
||||||
|
|
||||||
export interface Position {
|
export interface Position {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ const WorkHistoryWidget = dynamic(() => import("./widgets/WorkHistoryWidget"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const TransportStatsWidget = dynamic(() => import("./widgets/TransportStatsWidget"), {
|
const CustomStatsWidget = dynamic(() => import("./widgets/CustomStatsWidget"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -104,7 +104,7 @@ function renderWidget(element: DashboardElement) {
|
||||||
return <WorkHistoryWidget element={element} />;
|
return <WorkHistoryWidget element={element} />;
|
||||||
|
|
||||||
case "transport-stats":
|
case "transport-stats":
|
||||||
return <TransportStatsWidget element={element} />;
|
return <CustomStatsWidget element={element} />;
|
||||||
|
|
||||||
// === 차량 관련 (추가 위젯) ===
|
// === 차량 관련 (추가 위젯) ===
|
||||||
case "vehicle-status":
|
case "vehicle-status":
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,505 @@
|
||||||
|
/**
|
||||||
|
* 커스텀 통계 카드 위젯
|
||||||
|
* - 쿼리 결과에서 숫자 데이터를 자동으로 감지하여 통계 표시
|
||||||
|
* - 합계, 평균, 비율 등 자동 계산
|
||||||
|
* - 처리량, 효율, 가동설비, 재고점유율 등 다양한 통계 지원
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||||
|
|
||||||
|
interface CustomStatsWidgetProps {
|
||||||
|
element?: DashboardElement;
|
||||||
|
refreshInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatItem {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomStatsWidget({ element, refreshInterval = 60000 }: CustomStatsWidgetProps) {
|
||||||
|
const [allStats, setAllStats] = useState<StatItem[]>([]); // 모든 통계
|
||||||
|
const [stats, setStats] = useState<StatItem[]>([]); // 표시할 통계
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [selectedStats, setSelectedStats] = useState<string[]>([]); // 선택된 통계 라벨
|
||||||
|
|
||||||
|
// localStorage 키 생성 (위젯별로 고유하게)
|
||||||
|
const storageKey = `custom-stats-widget-${element?.id || "default"}`;
|
||||||
|
|
||||||
|
// 초기 로드 시 저장된 설정 불러오기
|
||||||
|
React.useEffect(() => {
|
||||||
|
const saved = localStorage.getItem(storageKey);
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
setSelectedStats(parsed);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("설정 로드 실패:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// 쿼리가 설정되어 있지 않으면 안내 메시지만 표시
|
||||||
|
if (!element?.dataSource?.query) {
|
||||||
|
setError("쿼리를 설정해주세요");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 쿼리 실행하여 통계 계산
|
||||||
|
const token = localStorage.getItem("authToken");
|
||||||
|
const response = await fetch("/api/dashboards/execute-query", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: element.dataSource.query,
|
||||||
|
connectionType: element.dataSource.connectionType || "current",
|
||||||
|
externalConnectionId: element.dataSource.externalConnectionId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("데이터 로딩 실패");
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!result.success || !result.data?.rows) {
|
||||||
|
throw new Error(result.message || "데이터 로드 실패");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.data.rows || [];
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
setStats([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstRow = data[0];
|
||||||
|
const statsItems: StatItem[] = [];
|
||||||
|
|
||||||
|
// 1. 총 건수 (항상 표시)
|
||||||
|
statsItems.push({
|
||||||
|
label: "총 건수",
|
||||||
|
value: data.length,
|
||||||
|
unit: "건",
|
||||||
|
color: "indigo",
|
||||||
|
icon: "📊",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 모든 숫자 컬럼 자동 감지
|
||||||
|
const numericColumns: { [key: string]: { sum: number; avg: number; count: number } } = {};
|
||||||
|
|
||||||
|
Object.keys(firstRow).forEach((key) => {
|
||||||
|
const value = firstRow[key];
|
||||||
|
// 숫자로 변환 가능한 컬럼만 선택 (id, order 같은 식별자 제외)
|
||||||
|
if (
|
||||||
|
value !== null &&
|
||||||
|
!isNaN(parseFloat(value)) &&
|
||||||
|
!key.toLowerCase().includes("id") &&
|
||||||
|
!key.toLowerCase().includes("order") &&
|
||||||
|
key.toLowerCase() !== "id"
|
||||||
|
) {
|
||||||
|
const validValues = data
|
||||||
|
.map((item: any) => parseFloat(item[key]))
|
||||||
|
.filter((v: number) => !isNaN(v) && v !== 0);
|
||||||
|
|
||||||
|
if (validValues.length > 0) {
|
||||||
|
const sum = validValues.reduce((acc: number, v: number) => acc + v, 0);
|
||||||
|
numericColumns[key] = {
|
||||||
|
sum,
|
||||||
|
avg: sum / validValues.length,
|
||||||
|
count: validValues.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 키워드 기반 자동 라벨링 및 단위 설정
|
||||||
|
const columnConfig: {
|
||||||
|
[key: string]: {
|
||||||
|
keywords: string[];
|
||||||
|
unit: string;
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
useAvg?: boolean;
|
||||||
|
koreanLabel?: string; // 한글 라벨
|
||||||
|
};
|
||||||
|
} = {
|
||||||
|
// 무게/중량
|
||||||
|
weight: {
|
||||||
|
keywords: ["weight", "cargo_weight", "total_weight", "tonnage"],
|
||||||
|
unit: "톤",
|
||||||
|
color: "green",
|
||||||
|
icon: "⚖️",
|
||||||
|
koreanLabel: "총 운송량"
|
||||||
|
},
|
||||||
|
// 거리
|
||||||
|
distance: {
|
||||||
|
keywords: ["distance", "total_distance"],
|
||||||
|
unit: "km",
|
||||||
|
color: "blue",
|
||||||
|
icon: "🛣️",
|
||||||
|
koreanLabel: "누적 거리"
|
||||||
|
},
|
||||||
|
// 시간/기간
|
||||||
|
time: {
|
||||||
|
keywords: ["time", "duration", "delivery_time", "delivery_duration"],
|
||||||
|
unit: "분",
|
||||||
|
color: "orange",
|
||||||
|
icon: "⏱️",
|
||||||
|
useAvg: true,
|
||||||
|
koreanLabel: "평균 배송시간"
|
||||||
|
},
|
||||||
|
// 수량/개수
|
||||||
|
quantity: {
|
||||||
|
keywords: ["quantity", "qty", "amount"],
|
||||||
|
unit: "개",
|
||||||
|
color: "purple",
|
||||||
|
icon: "📦",
|
||||||
|
koreanLabel: "총 수량"
|
||||||
|
},
|
||||||
|
// 금액/가격
|
||||||
|
price: {
|
||||||
|
keywords: ["price", "cost", "fee"],
|
||||||
|
unit: "원",
|
||||||
|
color: "yellow",
|
||||||
|
icon: "💰",
|
||||||
|
koreanLabel: "총 금액"
|
||||||
|
},
|
||||||
|
// 비율/퍼센트
|
||||||
|
rate: {
|
||||||
|
keywords: ["rate", "ratio", "percent", "efficiency"],
|
||||||
|
unit: "%",
|
||||||
|
color: "cyan",
|
||||||
|
icon: "📈",
|
||||||
|
useAvg: true,
|
||||||
|
koreanLabel: "평균 비율"
|
||||||
|
},
|
||||||
|
// 처리량
|
||||||
|
throughput: {
|
||||||
|
keywords: ["throughput", "output", "production"],
|
||||||
|
unit: "개",
|
||||||
|
color: "pink",
|
||||||
|
icon: "⚡",
|
||||||
|
koreanLabel: "총 처리량"
|
||||||
|
},
|
||||||
|
// 재고
|
||||||
|
stock: {
|
||||||
|
keywords: ["stock", "inventory"],
|
||||||
|
unit: "개",
|
||||||
|
color: "teal",
|
||||||
|
icon: "📦",
|
||||||
|
koreanLabel: "재고 수량"
|
||||||
|
},
|
||||||
|
// 설비/장비
|
||||||
|
equipment: {
|
||||||
|
keywords: ["equipment", "facility", "machine"],
|
||||||
|
unit: "대",
|
||||||
|
color: "gray",
|
||||||
|
icon: "🏭",
|
||||||
|
koreanLabel: "가동 설비"
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. 각 숫자 컬럼을 통계 카드로 변환
|
||||||
|
Object.entries(numericColumns).forEach(([key, stats]) => {
|
||||||
|
let label = key;
|
||||||
|
let unit = "";
|
||||||
|
let color = "gray";
|
||||||
|
let icon = "📊";
|
||||||
|
let useAvg = false;
|
||||||
|
let matchedConfig = null;
|
||||||
|
|
||||||
|
// 키워드 매칭으로 라벨, 단위, 색상 자동 설정
|
||||||
|
for (const [configKey, config] of Object.entries(columnConfig)) {
|
||||||
|
if (config.keywords.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
|
||||||
|
unit = config.unit;
|
||||||
|
color = config.color;
|
||||||
|
icon = config.icon;
|
||||||
|
useAvg = config.useAvg || false;
|
||||||
|
matchedConfig = config;
|
||||||
|
|
||||||
|
// 한글 라벨 사용 또는 자동 변환
|
||||||
|
label = config.koreanLabel || key
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/([A-Z])/g, " $1")
|
||||||
|
.trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매칭되지 않은 경우 기본 라벨 생성
|
||||||
|
if (!matchedConfig) {
|
||||||
|
label = key
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/([A-Z])/g, " $1")
|
||||||
|
.trim()
|
||||||
|
.split(" ")
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 합계 또는 평균 선택
|
||||||
|
const value = useAvg ? stats.avg : stats.sum;
|
||||||
|
|
||||||
|
statsItems.push({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Boolean 컬럼 비율 계산 (정시도착률, 성공률 등)
|
||||||
|
const booleanMapping: { [key: string]: string } = {
|
||||||
|
is_on_time: "정시 도착률",
|
||||||
|
on_time: "정시 도착률",
|
||||||
|
success: "성공률",
|
||||||
|
completed: "완료율",
|
||||||
|
delivered: "배송 완료율",
|
||||||
|
approved: "승인률",
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(firstRow).forEach((key) => {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
const matchedKey = Object.keys(booleanMapping).find((k) => lowerKey.includes(k));
|
||||||
|
|
||||||
|
if (matchedKey) {
|
||||||
|
const validItems = data.filter((item: any) => item[key] !== null && item[key] !== undefined);
|
||||||
|
|
||||||
|
if (validItems.length > 0) {
|
||||||
|
const trueCount = validItems.filter((item: any) => {
|
||||||
|
const val = item[key];
|
||||||
|
return val === true || val === "true" || val === 1 || val === "1" || val === "Y";
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const rate = (trueCount / validItems.length) * 100;
|
||||||
|
|
||||||
|
statsItems.push({
|
||||||
|
label: booleanMapping[matchedKey],
|
||||||
|
value: rate,
|
||||||
|
unit: "%",
|
||||||
|
color: "purple",
|
||||||
|
icon: "✅",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setAllStats(statsItems);
|
||||||
|
|
||||||
|
// 초기에는 모든 통계 표시 (최대 6개)
|
||||||
|
if (selectedStats.length === 0) {
|
||||||
|
setStats(statsItems.slice(0, 6));
|
||||||
|
setSelectedStats(statsItems.slice(0, 6).map((s) => s.label));
|
||||||
|
} else {
|
||||||
|
// 선택된 통계만 표시
|
||||||
|
const filtered = statsItems.filter((s) => selectedStats.includes(s.label));
|
||||||
|
setStats(filtered);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("통계 로드 실패:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
const interval = setInterval(loadData, refreshInterval);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [refreshInterval, element?.dataSource]);
|
||||||
|
|
||||||
|
// 색상 매핑
|
||||||
|
const getColorClasses = (color: string) => {
|
||||||
|
const colorMap: { [key: string]: { bg: string; text: string } } = {
|
||||||
|
indigo: { bg: "bg-indigo-50", text: "text-indigo-600" },
|
||||||
|
green: { bg: "bg-green-50", text: "text-green-600" },
|
||||||
|
blue: { bg: "bg-blue-50", text: "text-blue-600" },
|
||||||
|
purple: { bg: "bg-purple-50", text: "text-purple-600" },
|
||||||
|
orange: { bg: "bg-orange-50", text: "text-orange-600" },
|
||||||
|
yellow: { bg: "bg-yellow-50", text: "text-yellow-600" },
|
||||||
|
cyan: { bg: "bg-cyan-50", text: "text-cyan-600" },
|
||||||
|
pink: { bg: "bg-pink-50", text: "text-pink-600" },
|
||||||
|
teal: { bg: "bg-teal-50", text: "text-teal-600" },
|
||||||
|
gray: { bg: "bg-gray-50", text: "text-gray-600" },
|
||||||
|
};
|
||||||
|
return colorMap[color] || colorMap.gray;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading && stats.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||||
|
<div className="mt-2 text-sm text-gray-600">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center bg-gray-50 p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2 text-4xl">⚠️</div>
|
||||||
|
<div className="text-sm font-medium text-gray-600">{error}</div>
|
||||||
|
{!element?.dataSource?.query && (
|
||||||
|
<div className="mt-2 text-xs text-gray-500">톱니바퀴 아이콘을 클릭하여 쿼리를 설정하세요</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center bg-gray-50 p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2 text-4xl">📊</div>
|
||||||
|
<div className="text-sm font-medium text-gray-600">데이터 없음</div>
|
||||||
|
<div className="mt-2 text-xs text-gray-500">쿼리를 실행하여 통계를 확인하세요</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleStat = (label: string) => {
|
||||||
|
setSelectedStats((prev) => {
|
||||||
|
if (prev.includes(label)) {
|
||||||
|
return prev.filter((l) => l !== label);
|
||||||
|
} else {
|
||||||
|
return [...prev, label];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplySettings = () => {
|
||||||
|
const filtered = allStats.filter((s) => selectedStats.includes(s.label));
|
||||||
|
setStats(filtered);
|
||||||
|
setShowSettings(false);
|
||||||
|
|
||||||
|
// localStorage에 설정 저장
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(selectedStats));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-full flex-col bg-white">
|
||||||
|
{/* 헤더 영역 */}
|
||||||
|
<div className="flex items-center justify-between border-b bg-gray-50 px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">📊</span>
|
||||||
|
<span className="text-sm font-medium text-gray-700">커스텀 통계</span>
|
||||||
|
<span className="text-xs text-gray-500">({stats.length}개 표시 중)</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
className="flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100"
|
||||||
|
title="표시할 통계 선택"
|
||||||
|
>
|
||||||
|
<span>⚙️</span>
|
||||||
|
<span>설정</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
<div className="flex flex-1 items-center justify-center p-6">
|
||||||
|
<div className="grid w-full gap-4" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}>
|
||||||
|
{stats.map((stat, index) => {
|
||||||
|
const colors = getColorClasses(stat.color);
|
||||||
|
return (
|
||||||
|
<div key={index} className={`rounded-lg border ${colors.bg} p-4 text-center`}>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{stat.icon} {stat.label}
|
||||||
|
</div>
|
||||||
|
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>
|
||||||
|
{stat.value.toFixed(stat.unit === "%" || stat.unit === "분" ? 1 : 0).toLocaleString()}
|
||||||
|
<span className="ml-1 text-lg">{stat.unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설정 모달 */}
|
||||||
|
{showSettings && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="max-h-[80%] w-[90%] max-w-md overflow-auto rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-bold">표시할 통계 선택</h3>
|
||||||
|
<button onClick={() => setShowSettings(false)} className="text-2xl text-gray-500 hover:text-gray-700">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 text-sm text-gray-600">
|
||||||
|
표시하고 싶은 통계를 선택하세요 (최대 제한 없음)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{allStats.map((stat, index) => (
|
||||||
|
<label
|
||||||
|
key={index}
|
||||||
|
className="flex cursor-pointer items-center gap-3 rounded-lg border p-3 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedStats.includes(stat.label)}
|
||||||
|
onChange={() => handleToggleStat(stat.label)}
|
||||||
|
className="h-5 w-5"
|
||||||
|
/>
|
||||||
|
<span className="text-xl">{stat.icon}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{stat.label}</div>
|
||||||
|
<div className="text-sm text-gray-500">단위: {stat.unit}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleApplySettings}
|
||||||
|
className="flex-1 rounded-lg bg-blue-500 py-2 text-white hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
적용 ({selectedStats.length}개 선택)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(false)}
|
||||||
|
className="rounded-lg border px-4 py-2 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,7 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
||||||
const { selectedDate } = useDashboard();
|
const { selectedDate } = useDashboard();
|
||||||
|
|
||||||
const [todos, setTodos] = useState<TodoItem[]>([]);
|
const [todos, setTodos] = useState<TodoItem[]>([]);
|
||||||
|
const [internalTodos, setInternalTodos] = useState<TodoItem[]>([]); // 내장 API 투두
|
||||||
const [stats, setStats] = useState<TodoStats | null>(null);
|
const [stats, setStats] = useState<TodoStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState<"all" | "pending" | "in_progress" | "completed">("all");
|
const [filter, setFilter] = useState<"all" | "pending" | "in_progress" | "completed">("all");
|
||||||
|
|
@ -62,6 +63,21 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
||||||
const token = localStorage.getItem("authToken");
|
const token = localStorage.getItem("authToken");
|
||||||
const userLang = localStorage.getItem("userLang") || "KR";
|
const userLang = localStorage.getItem("userLang") || "KR";
|
||||||
|
|
||||||
|
// 내장 API 투두 항상 조회 (외부 DB 모드에서도)
|
||||||
|
const filterParam = filter !== "all" ? `?status=${filter}` : "";
|
||||||
|
const internalResponse = await fetch(`http://localhost:9771/api/todos${filterParam}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let internalData: TodoItem[] = [];
|
||||||
|
if (internalResponse.ok) {
|
||||||
|
const result = await internalResponse.json();
|
||||||
|
internalData = result.data || [];
|
||||||
|
setInternalTodos(internalData);
|
||||||
|
}
|
||||||
|
|
||||||
// 외부 DB 조회 (dataSource가 설정된 경우)
|
// 외부 DB 조회 (dataSource가 설정된 경우)
|
||||||
if (element?.dataSource?.query) {
|
if (element?.dataSource?.query) {
|
||||||
// console.log("🔍 TodoWidget - 외부 DB 조회 시작");
|
// console.log("🔍 TodoWidget - 외부 DB 조회 시작");
|
||||||
|
|
@ -111,8 +127,10 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
||||||
// console.log("📋 변환된 Todos:", externalTodos);
|
// console.log("📋 변환된 Todos:", externalTodos);
|
||||||
// console.log("📋 변환된 Todos 개수:", externalTodos.length);
|
// console.log("📋 변환된 Todos 개수:", externalTodos.length);
|
||||||
|
|
||||||
setTodos(externalTodos);
|
// 외부 DB 데이터 + 내장 데이터 합치기
|
||||||
setStats(calculateStatsFromTodos(externalTodos));
|
const mergedTodos = [...externalTodos, ...internalData];
|
||||||
|
setTodos(mergedTodos);
|
||||||
|
setStats(calculateStatsFromTodos(mergedTodos));
|
||||||
|
|
||||||
// console.log("✅ setTodos, setStats 호출 완료!");
|
// console.log("✅ setTodos, setStats 호출 완료!");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -120,20 +138,10 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
||||||
// console.error("❌ API 오류:", errorText);
|
// console.error("❌ API 오류:", errorText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 내장 API 조회 (기본)
|
// 내장 API만 조회 (기본)
|
||||||
else {
|
else {
|
||||||
const filterParam = filter !== "all" ? `?status=${filter}` : "";
|
setTodos(internalData);
|
||||||
const response = await fetch(`http://localhost:9771/api/todos${filterParam}`, {
|
setStats(calculateStatsFromTodos(internalData));
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const result = await response.json();
|
|
||||||
setTodos(result.data || []);
|
|
||||||
setStats(result.stats);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("To-Do 로딩 오류:", error);
|
// console.error("To-Do 로딩 오류:", error);
|
||||||
|
|
@ -180,10 +188,6 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
||||||
|
|
||||||
const handleAddTodo = async () => {
|
const handleAddTodo = async () => {
|
||||||
if (!newTodo.title.trim()) return;
|
if (!newTodo.title.trim()) return;
|
||||||
if (isExternalData) {
|
|
||||||
alert("외부 데이터베이스 조회 모드에서는 추가할 수 없습니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("authToken");
|
const token = localStorage.getItem("authToken");
|
||||||
|
|
@ -325,28 +329,31 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
||||||
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50">
|
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50">
|
||||||
{/* 제목 - 항상 표시 */}
|
{/* 제목 - 항상 표시 */}
|
||||||
<div className="border-b border-gray-200 bg-white px-4 py-2">
|
<div className="border-b border-gray-200 bg-white px-4 py-2">
|
||||||
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || "To-Do / 긴급 지시"}</h3>
|
<div className="flex items-center justify-between">
|
||||||
{selectedDate && (
|
<div>
|
||||||
<div className="mt-1 flex items-center gap-1 text-xs text-green-600">
|
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || "To-Do / 긴급 지시"}</h3>
|
||||||
<CalendarIcon className="h-3 w-3" />
|
{selectedDate && (
|
||||||
<span className="font-semibold">{formatSelectedDate()} 할일</span>
|
<div className="mt-1 flex items-center gap-1 text-xs text-green-600">
|
||||||
|
<CalendarIcon className="h-3 w-3" />
|
||||||
|
<span className="font-semibold">{formatSelectedDate()} 할일</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* 추가 버튼 - 항상 표시 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
|
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"
|
||||||
|
title="할 일 추가"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 헤더 (추가 버튼, 통계, 필터) - showHeader가 false일 때만 숨김 */}
|
{/* 헤더 (통계, 필터) - showHeader가 false일 때만 숨김 */}
|
||||||
{element?.showHeader !== false && (
|
{element?.showHeader !== false && (
|
||||||
<div className="border-b border-gray-200 bg-white px-4 py-3">
|
<div className="border-b border-gray-200 bg-white px-4 py-3">
|
||||||
<div className="mb-3 flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddForm(!showAddForm)}
|
|
||||||
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 통계 */}
|
{/* 통계 */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-4 gap-2 text-xs mb-3">
|
<div className="grid grid-cols-4 gap-2 text-xs mb-3">
|
||||||
|
|
@ -390,19 +397,21 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
||||||
|
|
||||||
{/* 추가 폼 */}
|
{/* 추가 폼 */}
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<div className="border-b border-gray-200 bg-white p-4">
|
<div className="max-h-[400px] overflow-y-auto border-b border-gray-200 bg-white p-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="할 일 제목*"
|
placeholder="할 일 제목*"
|
||||||
value={newTodo.title}
|
value={newTodo.title}
|
||||||
onChange={(e) => setNewTodo({ ...newTodo, title: e.target.value })}
|
onChange={(e) => setNewTodo({ ...newTodo, title: e.target.value })}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="상세 설명 (선택)"
|
placeholder="상세 설명 (선택)"
|
||||||
value={newTodo.description}
|
value={newTodo.description}
|
||||||
onChange={(e) => setNewTodo({ ...newTodo, description: e.target.value })}
|
onChange={(e) => setNewTodo({ ...newTodo, description: e.target.value })}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
|
|
@ -454,7 +463,7 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* To-Do 리스트 */}
|
{/* To-Do 리스트 */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4 min-h-0">
|
||||||
{filteredTodos.length === 0 ? (
|
{filteredTodos.length === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center text-gray-400">
|
<div className="flex h-full items-center justify-center text-gray-400">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,25 @@ interface TransportStatsWidgetProps {
|
||||||
refreshInterval?: number;
|
refreshInterval?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StatItem {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
color: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface StatsData {
|
interface StatsData {
|
||||||
total_count: number;
|
total_count: number;
|
||||||
total_weight: number;
|
total_weight: number;
|
||||||
total_distance: number;
|
total_distance: number;
|
||||||
on_time_rate: number;
|
on_time_rate: number;
|
||||||
|
avg_delivery_time: number;
|
||||||
|
[key: string]: number; // 동적 속성 추가
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TransportStatsWidget({ element, refreshInterval = 60000 }: TransportStatsWidgetProps) {
|
export default function TransportStatsWidget({ element, refreshInterval = 60000 }: TransportStatsWidgetProps) {
|
||||||
const [stats, setStats] = useState<StatsData | null>(null);
|
const [stats, setStats] = useState<StatItem[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -67,7 +77,7 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
||||||
const data = result.data.rows || [];
|
const data = result.data.rows || [];
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
setStats({ total_count: 0, total_weight: 0, total_distance: 0, on_time_rate: 0 });
|
setStats({ total_count: 0, total_weight: 0, total_distance: 0, on_time_rate: 0, avg_delivery_time: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,6 +100,7 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
||||||
const weightKeys = ["weight", "cargo_weight", "total_weight", "중량", "무게"];
|
const weightKeys = ["weight", "cargo_weight", "total_weight", "중량", "무게"];
|
||||||
const distanceKeys = ["distance", "total_distance", "거리", "주행거리"];
|
const distanceKeys = ["distance", "total_distance", "거리", "주행거리"];
|
||||||
const onTimeKeys = ["is_on_time", "on_time", "onTime", "정시", "정시도착"];
|
const onTimeKeys = ["is_on_time", "on_time", "onTime", "정시", "정시도착"];
|
||||||
|
const deliveryTimeKeys = ["delivery_duration", "delivery_time", "duration", "배송시간", "소요시간", "배송소요시간"];
|
||||||
|
|
||||||
// 총 운송량 찾기
|
// 총 운송량 찾기
|
||||||
let total_weight = 0;
|
let total_weight = 0;
|
||||||
|
|
@ -130,11 +141,77 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 평균 배송시간 계산
|
||||||
|
let avg_delivery_time = 0;
|
||||||
|
|
||||||
|
// 1. 먼저 배송시간 컬럼이 있는지 확인
|
||||||
|
let foundTimeColumn = false;
|
||||||
|
for (const key of Object.keys(numericColumns)) {
|
||||||
|
if (deliveryTimeKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
|
||||||
|
const validItems = data.filter((item: any) => {
|
||||||
|
const time = parseFloat(item[key]);
|
||||||
|
return !isNaN(time) && time > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validItems.length > 0) {
|
||||||
|
const totalTime = validItems.reduce((sum: number, item: any) => {
|
||||||
|
return sum + parseFloat(item[key]);
|
||||||
|
}, 0);
|
||||||
|
avg_delivery_time = totalTime / validItems.length;
|
||||||
|
foundTimeColumn = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 배송시간 컬럼이 없으면 날짜 컬럼에서 자동 계산
|
||||||
|
if (!foundTimeColumn) {
|
||||||
|
const startTimeKeys = ["created_at", "start_time", "departure_time", "출발시간", "시작시간"];
|
||||||
|
const endTimeKeys = ["actual_delivery", "end_time", "arrival_time", "도착시간", "완료시간", "estimated_delivery"];
|
||||||
|
|
||||||
|
let startKey = null;
|
||||||
|
let endKey = null;
|
||||||
|
|
||||||
|
// 시작 시간 컬럼 찾기
|
||||||
|
for (const key of Object.keys(firstRow)) {
|
||||||
|
if (startTimeKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
|
||||||
|
startKey = key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 종료 시간 컬럼 찾기
|
||||||
|
for (const key of Object.keys(firstRow)) {
|
||||||
|
if (endTimeKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
|
||||||
|
endKey = key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 두 컬럼이 모두 있으면 시간 차이 계산
|
||||||
|
if (startKey && endKey) {
|
||||||
|
const validItems = data.filter((item: any) => {
|
||||||
|
return item[startKey] && item[endKey];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validItems.length > 0) {
|
||||||
|
const totalMinutes = validItems.reduce((sum: number, item: any) => {
|
||||||
|
const start = new Date(item[startKey]).getTime();
|
||||||
|
const end = new Date(item[endKey]).getTime();
|
||||||
|
const diffMinutes = (end - start) / (1000 * 60); // 밀리초 -> 분
|
||||||
|
return sum + (diffMinutes > 0 ? diffMinutes : 0);
|
||||||
|
}, 0);
|
||||||
|
avg_delivery_time = totalMinutes / validItems.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const calculatedStats: StatsData = {
|
const calculatedStats: StatsData = {
|
||||||
total_count: data.length, // 총 건수
|
total_count: data.length, // 총 건수
|
||||||
total_weight,
|
total_weight,
|
||||||
total_distance,
|
total_distance,
|
||||||
on_time_rate,
|
on_time_rate,
|
||||||
|
avg_delivery_time,
|
||||||
};
|
};
|
||||||
|
|
||||||
setStats(calculatedStats);
|
setStats(calculatedStats);
|
||||||
|
|
@ -186,7 +263,7 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center bg-white p-6">
|
<div className="flex h-full items-center justify-center bg-white p-6">
|
||||||
<div className="grid w-full grid-cols-2 gap-4">
|
<div className="grid w-full gap-4" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}>
|
||||||
{/* 총 건수 */}
|
{/* 총 건수 */}
|
||||||
<div className="rounded-lg border bg-indigo-50 p-4 text-center">
|
<div className="rounded-lg border bg-indigo-50 p-4 text-center">
|
||||||
<div className="text-sm text-gray-600">총 건수</div>
|
<div className="text-sm text-gray-600">총 건수</div>
|
||||||
|
|
@ -222,6 +299,15 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
||||||
<span className="ml-1 text-lg">%</span>
|
<span className="ml-1 text-lg">%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 평균 배송시간 */}
|
||||||
|
<div className="rounded-lg border bg-orange-50 p-4 text-center">
|
||||||
|
<div className="text-sm text-gray-600">평균 배송시간</div>
|
||||||
|
<div className="mt-2 text-3xl font-bold text-orange-600">
|
||||||
|
{stats.avg_delivery_time.toFixed(1)}
|
||||||
|
<span className="ml-1 text-lg">분</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue