diff --git a/backend-node/data/todos/todos.json b/backend-node/data/todos/todos.json index 0637a088..653d5636 100644 --- a/backend-node/data/todos/todos.json +++ b/backend-node/data/todos/todos.json @@ -1 +1,54 @@ -[] \ No newline at end of file +[ + { + "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 + } +] \ No newline at end of file diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 66b9d65f..db58207e 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -118,8 +118,8 @@ const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/W loading: () =>
로딩 중...
, }); -// 운송 통계 위젯 -const TransportStatsWidget = dynamic(() => import("@/components/dashboard/widgets/TransportStatsWidget"), { +// 커스텀 통계 카드 위젯 +const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/CustomStatsWidget"), { ssr: false, loading: () =>
로딩 중...
, }); @@ -750,9 +750,9 @@ export function CanvasElement({ ) : element.type === "widget" && element.subtype === "transport-stats" ? ( - // 운송 통계 위젯 렌더링 + // 커스텀 통계 카드 위젯 렌더링
- +
) : element.type === "widget" && element.subtype === "todo" ? ( // To-Do 위젯 렌더링 diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index f943375f..3e01cff1 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -647,7 +647,7 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string { case "work-history": return "작업 이력"; case "transport-stats": - return "운송 통계"; + return "커스텀 통계 카드"; default: return "위젯"; } @@ -693,7 +693,7 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string { case "work-history": return "work-history"; case "transport-stats": - return "transport-stats"; + return "커스텀 통계 카드"; default: return "위젯 내용이 여기에 표시됩니다"; } diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index d11decc1..4a2c239f 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -226,7 +226,7 @@ export function DashboardSidebar() { onDragStart={handleDragStart} /> 데이터 위젯 리스트 위젯 야드 관리 3D - 운송 통계 + 커스텀 통계 카드 {/* 지도 */} 커스텀 지도 카드 {/* 커스텀 목록 카드 */} diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index 93796257..6aba88db 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -52,7 +52,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element element.subtype === "customer-issues" || element.subtype === "driver-management" || element.subtype === "work-history" || // 작업 이력 위젯 (쿼리 필요) - element.subtype === "transport-stats"; // 운송 통계 위젯 (쿼리 필요) + element.subtype === "transport-stats"; // 커스텀 통계 카드 위젯 (쿼리 필요) // 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능) const isSelfContainedWidget = diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index c663e187..139f42c0 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -38,7 +38,7 @@ export type ElementSubtype = | "list" | "yard-management-3d" // 야드 관리 3D 위젯 | "work-history" // 작업 이력 위젯 - | "transport-stats"; // 운송 통계 위젯 + | "transport-stats"; // 커스텀 통계 카드 위젯 export interface Position { x: number; diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 0fb6983a..287286cf 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -47,7 +47,7 @@ const WorkHistoryWidget = dynamic(() => import("./widgets/WorkHistoryWidget"), { ssr: false, }); -const TransportStatsWidget = dynamic(() => import("./widgets/TransportStatsWidget"), { +const CustomStatsWidget = dynamic(() => import("./widgets/CustomStatsWidget"), { ssr: false, }); @@ -104,7 +104,7 @@ function renderWidget(element: DashboardElement) { return ; case "transport-stats": - return ; + return ; // === 차량 관련 (추가 위젯) === case "vehicle-status": diff --git a/frontend/components/dashboard/widgets/CustomStatsWidget.tsx b/frontend/components/dashboard/widgets/CustomStatsWidget.tsx new file mode 100644 index 00000000..8a25bc31 --- /dev/null +++ b/frontend/components/dashboard/widgets/CustomStatsWidget.tsx @@ -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([]); // 모든 통계 + const [stats, setStats] = useState([]); // 표시할 통계 + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [showSettings, setShowSettings] = useState(false); + const [selectedStats, setSelectedStats] = useState([]); // 선택된 통계 라벨 + + // 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 ( +
+
+
+
로딩 중...
+
+
+ ); + } + + if (error) { + return ( +
+
+
⚠️
+
{error}
+ {!element?.dataSource?.query && ( +
톱니바퀴 아이콘을 클릭하여 쿼리를 설정하세요
+ )} + +
+
+ ); + } + + if (stats.length === 0) { + return ( +
+
+
📊
+
데이터 없음
+
쿼리를 실행하여 통계를 확인하세요
+
+
+ ); + } + + 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 ( +
+ {/* 헤더 영역 */} +
+
+ 📊 + 커스텀 통계 + ({stats.length}개 표시 중) +
+ +
+ + {/* 통계 카드 */} +
+
+ {stats.map((stat, index) => { + const colors = getColorClasses(stat.color); + return ( +
+
+ {stat.icon} {stat.label} +
+
+ {stat.value.toFixed(stat.unit === "%" || stat.unit === "분" ? 1 : 0).toLocaleString()} + {stat.unit} +
+
+ ); + })} +
+
+ + {/* 설정 모달 */} + {showSettings && ( +
+
+
+

표시할 통계 선택

+ +
+ +
+ 표시하고 싶은 통계를 선택하세요 (최대 제한 없음) +
+ +
+ {allStats.map((stat, index) => ( + + ))} +
+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/components/dashboard/widgets/TodoWidget.tsx b/frontend/components/dashboard/widgets/TodoWidget.tsx index dd4652d5..6ab85294 100644 --- a/frontend/components/dashboard/widgets/TodoWidget.tsx +++ b/frontend/components/dashboard/widgets/TodoWidget.tsx @@ -38,6 +38,7 @@ export default function TodoWidget({ element }: TodoWidgetProps) { const { selectedDate } = useDashboard(); const [todos, setTodos] = useState([]); + const [internalTodos, setInternalTodos] = useState([]); // 내장 API 투두 const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); 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 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가 설정된 경우) if (element?.dataSource?.query) { // console.log("🔍 TodoWidget - 외부 DB 조회 시작"); @@ -111,8 +127,10 @@ export default function TodoWidget({ element }: TodoWidgetProps) { // console.log("📋 변환된 Todos:", externalTodos); // console.log("📋 변환된 Todos 개수:", externalTodos.length); - setTodos(externalTodos); - setStats(calculateStatsFromTodos(externalTodos)); + // 외부 DB 데이터 + 내장 데이터 합치기 + const mergedTodos = [...externalTodos, ...internalData]; + setTodos(mergedTodos); + setStats(calculateStatsFromTodos(mergedTodos)); // console.log("✅ setTodos, setStats 호출 완료!"); } else { @@ -120,20 +138,10 @@ export default function TodoWidget({ element }: TodoWidgetProps) { // console.error("❌ API 오류:", errorText); } } - // 내장 API 조회 (기본) + // 내장 API만 조회 (기본) else { - const filterParam = filter !== "all" ? `?status=${filter}` : ""; - const response = await fetch(`http://localhost:9771/api/todos${filterParam}`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (response.ok) { - const result = await response.json(); - setTodos(result.data || []); - setStats(result.stats); - } + setTodos(internalData); + setStats(calculateStatsFromTodos(internalData)); } } catch (error) { // console.error("To-Do 로딩 오류:", error); @@ -180,10 +188,6 @@ export default function TodoWidget({ element }: TodoWidgetProps) { const handleAddTodo = async () => { if (!newTodo.title.trim()) return; - if (isExternalData) { - alert("외부 데이터베이스 조회 모드에서는 추가할 수 없습니다."); - return; - } try { const token = localStorage.getItem("authToken"); @@ -325,28 +329,31 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
{/* 제목 - 항상 표시 */}
-

{element?.customTitle || "To-Do / 긴급 지시"}

- {selectedDate && ( -
- - {formatSelectedDate()} 할일 +
+
+

{element?.customTitle || "To-Do / 긴급 지시"}

+ {selectedDate && ( +
+ + {formatSelectedDate()} 할일 +
+ )}
- )} + {/* 추가 버튼 - 항상 표시 */} + +
- {/* 헤더 (추가 버튼, 통계, 필터) - showHeader가 false일 때만 숨김 */} + {/* 헤더 (통계, 필터) - showHeader가 false일 때만 숨김 */} {element?.showHeader !== false && (
-
- -
- {/* 통계 */} {stats && (
@@ -390,19 +397,21 @@ export default function TodoWidget({ element }: TodoWidgetProps) { {/* 추가 폼 */} {showAddForm && ( -
+
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" />