Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/dashboard
This commit is contained in:
@@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardElement, QueryResult } from "./types";
|
||||
import { ChartRenderer } from "./charts/ChartRenderer";
|
||||
import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils";
|
||||
import { GRID_CONFIG } from "./gridUtils";
|
||||
|
||||
// 위젯 동적 임포트
|
||||
const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), {
|
||||
@@ -112,10 +112,23 @@ const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DW
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 작업 이력 위젯
|
||||
const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/WorkHistoryWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 운송 통계 위젯
|
||||
const TransportStatsWidget = dynamic(() => import("@/components/dashboard/widgets/TransportStatsWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
interface CanvasElementProps {
|
||||
element: DashboardElement;
|
||||
isSelected: boolean;
|
||||
cellSize: number;
|
||||
subGridSize: number;
|
||||
canvasWidth?: number;
|
||||
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
||||
onRemove: (id: string) => void;
|
||||
@@ -133,6 +146,7 @@ export function CanvasElement({
|
||||
element,
|
||||
isSelected,
|
||||
cellSize,
|
||||
subGridSize,
|
||||
canvasWidth = 1560,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
@@ -233,7 +247,6 @@ export function CanvasElement({
|
||||
rawX = Math.min(rawX, maxX);
|
||||
|
||||
// 드래그 중 실시간 스냅 (마그네틱 스냅)
|
||||
const subGridSize = Math.floor(cellSize / 3);
|
||||
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
|
||||
const magneticThreshold = 15; // 큰 그리드에 끌리는 거리 (px)
|
||||
|
||||
@@ -291,7 +304,6 @@ export function CanvasElement({
|
||||
newWidth = Math.min(newWidth, maxWidth);
|
||||
|
||||
// 리사이즈 중 실시간 스냅 (마그네틱 스냅)
|
||||
const subGridSize = Math.floor(cellSize / 3);
|
||||
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
|
||||
const magneticThreshold = 15;
|
||||
|
||||
@@ -336,6 +348,7 @@ export function CanvasElement({
|
||||
element.subtype,
|
||||
canvasWidth,
|
||||
cellSize,
|
||||
subGridSize,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -726,10 +739,21 @@ export function CanvasElement({
|
||||
isEditMode={true}
|
||||
config={element.yardConfig}
|
||||
onConfigChange={(newConfig) => {
|
||||
console.log("🏗️ 야드 설정 업데이트:", { elementId: element.id, newConfig });
|
||||
onUpdate(element.id, { yardConfig: newConfig });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "work-history" ? (
|
||||
// 작업 이력 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<WorkHistoryWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "transport-stats" ? (
|
||||
// 운송 통계 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<TransportStatsWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "todo" ? (
|
||||
// To-Do 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
|
||||
@@ -156,8 +156,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
||||
|
||||
// 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드)
|
||||
const subGridSize = Math.floor(cellSize / 3);
|
||||
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
|
||||
const gridSize = cellSize + GRID_CONFIG.GAP; // GAP 포함한 실제 그리드 크기
|
||||
const magneticThreshold = 15;
|
||||
|
||||
// X 좌표 스냅
|
||||
@@ -196,6 +195,9 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
// 동적 그리드 크기 계산
|
||||
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
||||
const gridSize = `${cellWithGap}px ${cellWithGap}px`;
|
||||
|
||||
// 서브그리드 크기 계산 (gridConfig에서 정확하게 계산된 값 사용)
|
||||
const subGridSize = gridConfig.SUB_GRID_SIZE;
|
||||
|
||||
// 12개 컬럼 구분선 위치 계산
|
||||
const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap);
|
||||
@@ -208,12 +210,12 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
backgroundColor,
|
||||
height: `${canvasHeight}px`,
|
||||
minHeight: `${canvasHeight}px`,
|
||||
// 세밀한 그리드 배경
|
||||
// 서브그리드 배경 (세밀한 점선)
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(59, 130, 246, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.08) 1px, transparent 1px)
|
||||
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: gridSize,
|
||||
backgroundSize: `${subGridSize}px ${subGridSize}px`,
|
||||
backgroundPosition: "0 0",
|
||||
backgroundRepeat: "repeat",
|
||||
}}
|
||||
@@ -229,8 +231,9 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
className="pointer-events-none absolute top-0 h-full"
|
||||
style={{
|
||||
left: `${x}px`,
|
||||
width: "2px",
|
||||
zIndex: 1,
|
||||
width: "1px",
|
||||
backgroundColor: i === 0 || i === GRID_CONFIG.COLUMNS ? "rgba(59, 130, 246, 0.3)" : "rgba(59, 130, 246, 0.15)",
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -248,6 +251,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
element={element}
|
||||
isSelected={selectedElement === element.id}
|
||||
cellSize={cellSize}
|
||||
subGridSize={subGridSize}
|
||||
canvasWidth={canvasWidth}
|
||||
onUpdate={handleUpdateWithCollisionDetection}
|
||||
onRemove={onRemoveElement}
|
||||
|
||||
@@ -333,21 +333,31 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||
try {
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
|
||||
const elementsData = elements.map((el) => ({
|
||||
id: el.id,
|
||||
type: el.type,
|
||||
subtype: el.subtype,
|
||||
position: el.position,
|
||||
size: el.size,
|
||||
title: el.title,
|
||||
customTitle: el.customTitle,
|
||||
showHeader: el.showHeader,
|
||||
content: el.content,
|
||||
dataSource: el.dataSource,
|
||||
chartConfig: el.chartConfig,
|
||||
listConfig: el.listConfig,
|
||||
yardConfig: el.yardConfig,
|
||||
}));
|
||||
const elementsData = elements.map((el) => {
|
||||
// 야드 위젯인 경우 설정 로그 출력
|
||||
if (el.subtype === "yard-management-3d") {
|
||||
console.log("💾 야드 위젯 저장:", {
|
||||
id: el.id,
|
||||
yardConfig: el.yardConfig,
|
||||
hasLayoutId: !!el.yardConfig?.layoutId,
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: el.id,
|
||||
type: el.type,
|
||||
subtype: el.subtype,
|
||||
position: el.position,
|
||||
size: el.size,
|
||||
title: el.title,
|
||||
customTitle: el.customTitle,
|
||||
showHeader: el.showHeader,
|
||||
content: el.content,
|
||||
dataSource: el.dataSource,
|
||||
chartConfig: el.chartConfig,
|
||||
listConfig: el.listConfig,
|
||||
yardConfig: el.yardConfig,
|
||||
};
|
||||
});
|
||||
|
||||
let savedDashboard;
|
||||
|
||||
@@ -634,6 +644,10 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
||||
return "문서 위젯";
|
||||
case "yard-management-3d":
|
||||
return "야드 관리 3D";
|
||||
case "work-history":
|
||||
return "작업 이력";
|
||||
case "transport-stats":
|
||||
return "운송 통계";
|
||||
default:
|
||||
return "위젯";
|
||||
}
|
||||
@@ -676,6 +690,10 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
||||
return "list-widget";
|
||||
case "yard-management-3d":
|
||||
return "yard-3d";
|
||||
case "work-history":
|
||||
return "work-history";
|
||||
case "transport-stats":
|
||||
return "transport-stats";
|
||||
default:
|
||||
return "위젯 내용이 여기에 표시됩니다";
|
||||
}
|
||||
|
||||
@@ -183,6 +183,10 @@ export function DashboardSaveModal({
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// 모든 키보드 이벤트를 input 필드 내부에서만 처리
|
||||
e.stopPropagation();
|
||||
}}
|
||||
placeholder="예: 생산 현황 대시보드"
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -195,6 +199,10 @@ export function DashboardSaveModal({
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// 모든 키보드 이벤트를 textarea 내부에서만 처리
|
||||
e.stopPropagation();
|
||||
}}
|
||||
placeholder="대시보드에 대한 간단한 설명을 입력하세요"
|
||||
rows={3}
|
||||
className="w-full resize-none"
|
||||
|
||||
@@ -219,6 +219,18 @@ export function DashboardSidebar() {
|
||||
subtype="list"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
title="작업 이력"
|
||||
type="widget"
|
||||
subtype="work-history"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
title="운송 통계"
|
||||
type="widget"
|
||||
subtype="transport-stats"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -182,6 +182,7 @@ export function DashboardTopMenu({
|
||||
<SelectLabel>데이터 위젯</SelectLabel>
|
||||
<SelectItem value="list">리스트 위젯</SelectItem>
|
||||
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
||||
<SelectItem value="transport-stats">운송 통계</SelectItem>
|
||||
{/* <SelectItem value="map">지도</SelectItem> */}
|
||||
<SelectItem value="map-summary">커스텀 지도 카드</SelectItem>
|
||||
{/* <SelectItem value="list-summary">커스텀 목록 카드</SelectItem> */}
|
||||
|
||||
@@ -36,6 +36,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
|
||||
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
|
||||
const isSimpleWidget =
|
||||
element.subtype === "todo" || // To-Do 위젯
|
||||
element.subtype === "booking-alert" || // 예약 알림 위젯
|
||||
element.subtype === "maintenance" || // 정비 일정 위젯
|
||||
element.subtype === "document" || // 문서 위젯
|
||||
element.subtype === "risk-alert" || // 리스크 알림 위젯
|
||||
element.subtype === "vehicle-status" ||
|
||||
element.subtype === "vehicle-list" ||
|
||||
element.subtype === "status-summary" || // 커스텀 상태 카드
|
||||
@@ -45,7 +50,15 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
element.subtype === "delivery-today-stats" ||
|
||||
element.subtype === "cargo-list" ||
|
||||
element.subtype === "customer-issues" ||
|
||||
element.subtype === "driver-management";
|
||||
element.subtype === "driver-management" ||
|
||||
element.subtype === "work-history" || // 작업 이력 위젯 (쿼리 필요)
|
||||
element.subtype === "transport-stats"; // 운송 통계 위젯 (쿼리 필요)
|
||||
|
||||
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
|
||||
const isSelfContainedWidget =
|
||||
element.subtype === "weather" || // 날씨 위젯 (외부 API)
|
||||
element.subtype === "exchange" || // 환율 위젯 (외부 API)
|
||||
element.subtype === "calculator"; // 계산기 위젯 (자체 기능)
|
||||
|
||||
// 지도 위젯 (위도/경도 매핑 필요)
|
||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary";
|
||||
@@ -59,6 +72,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
setQueryResult(null);
|
||||
setCurrentStep(1);
|
||||
setCustomTitle(element.customTitle || "");
|
||||
setShowHeader(element.showHeader !== false); // showHeader 초기화
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
@@ -135,8 +149,12 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
// 모달이 열려있지 않으면 렌더링하지 않음
|
||||
if (!isOpen) return null;
|
||||
|
||||
// 시계, 달력, To-Do 위젯은 헤더 설정만 가능
|
||||
const isHeaderOnlyWidget = element.type === "widget" && (element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "todo");
|
||||
// 시계, 달력, 날씨, 환율, 계산기 위젯은 헤더 설정만 가능
|
||||
const isHeaderOnlyWidget =
|
||||
element.type === "widget" &&
|
||||
(element.subtype === "clock" ||
|
||||
element.subtype === "calendar" ||
|
||||
isSelfContainedWidget);
|
||||
|
||||
// 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
|
||||
if (element.type === "widget" && element.subtype === "driver-management") {
|
||||
@@ -154,11 +172,15 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
|
||||
// customTitle이 변경되었는지 확인
|
||||
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
|
||||
|
||||
// showHeader가 변경되었는지 확인
|
||||
const isHeaderChanged = showHeader !== (element.showHeader !== false);
|
||||
|
||||
const canSave =
|
||||
isTitleChanged || // 제목만 변경해도 저장 가능
|
||||
isHeaderChanged || // 헤더 표시 여부만 변경해도 저장 가능
|
||||
(isSimpleWidget
|
||||
? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능
|
||||
? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 (차트 설정 불필요)
|
||||
currentStep === 2 && queryResult && queryResult.rows.length > 0
|
||||
: isMapWidget
|
||||
? // 지도 위젯: 위도/경도 매핑 필요
|
||||
@@ -184,7 +206,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div
|
||||
className={`flex flex-col rounded-xl border bg-white shadow-2xl ${
|
||||
currentStep === 1 ? "h-auto max-h-[70vh] w-full max-w-3xl" : "h-[85vh] w-full max-w-5xl"
|
||||
currentStep === 1 && !isSimpleWidget ? "h-auto max-h-[70vh] w-full max-w-3xl" : "h-[85vh] w-full max-w-5xl"
|
||||
}`}
|
||||
>
|
||||
{/* 모달 헤더 */}
|
||||
@@ -336,7 +358,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
저장
|
||||
</Button>
|
||||
) : currentStep === 1 ? (
|
||||
// 1단계: 다음 버튼
|
||||
// 1단계: 다음 버튼 (차트 위젯, 간단한 위젯 모두)
|
||||
<Button onClick={handleNext}>
|
||||
다음
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
@@ -354,3 +376,4 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,10 @@ ORDER BY 하위부서수 DESC`,
|
||||
<Textarea
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// 모든 키보드 이벤트를 textarea 내부에서만 처리
|
||||
e.stopPropagation();
|
||||
}}
|
||||
placeholder="SELECT * FROM your_table WHERE condition = 'value';"
|
||||
className="h-40 resize-none font-mono text-sm"
|
||||
/>
|
||||
|
||||
@@ -36,7 +36,9 @@ export type ElementSubtype =
|
||||
| "maintenance"
|
||||
| "document"
|
||||
| "list"
|
||||
| "yard-management-3d"; // 야드 관리 3D 위젯
|
||||
| "yard-management-3d" // 야드 관리 3D 위젯
|
||||
| "work-history" // 작업 이력 위젯
|
||||
| "transport-stats"; // 운송 통계 위젯
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
|
||||
@@ -57,6 +57,17 @@ export default function YardManagement3DWidget({
|
||||
}
|
||||
}, [isEditMode]);
|
||||
|
||||
// 레이아웃 목록이 로드되었고, 설정이 없으면 첫 번째 레이아웃 자동 선택
|
||||
useEffect(() => {
|
||||
if (isEditMode && layouts.length > 0 && !config?.layoutId && onConfigChange) {
|
||||
console.log("🔧 첫 번째 야드 레이아웃 자동 선택:", layouts[0]);
|
||||
onConfigChange({
|
||||
layoutId: layouts[0].id,
|
||||
layoutName: layouts[0].name,
|
||||
});
|
||||
}
|
||||
}, [isEditMode, layouts, config?.layoutId, onConfigChange]);
|
||||
|
||||
// 레이아웃 선택 (편집 모드에서만)
|
||||
const handleSelectLayout = (layout: YardLayout) => {
|
||||
if (onConfigChange) {
|
||||
@@ -243,12 +254,16 @@ export default function YardManagement3DWidget({
|
||||
|
||||
// 뷰 모드: 선택된 레이아웃의 3D 뷰어 표시
|
||||
if (!config?.layoutId) {
|
||||
console.warn("⚠️ 야드관리 위젯: layoutId가 설정되지 않음", { config, isEditMode });
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">🏗️</div>
|
||||
<div className="text-sm font-medium text-gray-600">야드 레이아웃이 설정되지 않았습니다</div>
|
||||
<div className="mt-1 text-xs text-gray-400">대시보드 편집에서 레이아웃을 선택하세요</div>
|
||||
<div className="mt-2 text-xs text-red-500">
|
||||
디버그: config={JSON.stringify(config)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,6 +43,14 @@ const YardManagement3DWidget = dynamic(() => import("@/components/admin/dashboar
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const WorkHistoryWidget = dynamic(() => import("./widgets/WorkHistoryWidget"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const TransportStatsWidget = dynamic(() => import("./widgets/TransportStatsWidget"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리
|
||||
* ViewerElement에서 사용하기 위해 컴포넌트 외부에 정의
|
||||
@@ -82,8 +90,22 @@ function renderWidget(element: DashboardElement) {
|
||||
return <ListWidget element={element} />;
|
||||
|
||||
case "yard-management-3d":
|
||||
console.log("🏗️ 야드관리 위젯 렌더링:", {
|
||||
elementId: element.id,
|
||||
yardConfig: element.yardConfig,
|
||||
yardConfigType: typeof element.yardConfig,
|
||||
hasLayoutId: !!element.yardConfig?.layoutId,
|
||||
layoutId: element.yardConfig?.layoutId,
|
||||
layoutName: element.yardConfig?.layoutName,
|
||||
});
|
||||
return <YardManagement3DWidget isEditMode={false} config={element.yardConfig} />;
|
||||
|
||||
case "work-history":
|
||||
return <WorkHistoryWidget element={element} />;
|
||||
|
||||
case "transport-stats":
|
||||
return <TransportStatsWidget element={element} />;
|
||||
|
||||
// === 차량 관련 (추가 위젯) ===
|
||||
case "vehicle-status":
|
||||
return <VehicleStatusWidget element={element} />;
|
||||
|
||||
228
frontend/components/dashboard/widgets/TransportStatsWidget.tsx
Normal file
228
frontend/components/dashboard/widgets/TransportStatsWidget.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 운송 통계 위젯
|
||||
* - 총 운송량 (톤)
|
||||
* - 누적 거리 (km)
|
||||
* - 정시 도착률 (%)
|
||||
* - 쿼리 결과 기반 통계 계산
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
interface TransportStatsWidgetProps {
|
||||
element?: DashboardElement;
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
interface StatsData {
|
||||
total_count: number;
|
||||
total_weight: number;
|
||||
total_distance: number;
|
||||
on_time_rate: number;
|
||||
}
|
||||
|
||||
export default function TransportStatsWidget({ element, refreshInterval = 60000 }: TransportStatsWidgetProps) {
|
||||
const [stats, setStats] = useState<StatsData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 데이터 로드
|
||||
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({ total_count: 0, total_weight: 0, total_distance: 0, on_time_rate: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// 자동으로 숫자 컬럼 감지 및 합계 계산
|
||||
const firstRow = data[0];
|
||||
const numericColumns: { [key: string]: number } = {};
|
||||
|
||||
// 모든 컬럼을 순회하며 숫자 컬럼 찾기
|
||||
Object.keys(firstRow).forEach((key) => {
|
||||
const value = firstRow[key];
|
||||
// 숫자로 변환 가능한 컬럼만 선택
|
||||
if (value !== null && !isNaN(parseFloat(value))) {
|
||||
numericColumns[key] = data.reduce((sum: number, item: any) => {
|
||||
return sum + (parseFloat(item[key]) || 0);
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// 특정 키워드를 포함한 컬럼 자동 매핑
|
||||
const weightKeys = ["weight", "cargo_weight", "total_weight", "중량", "무게"];
|
||||
const distanceKeys = ["distance", "total_distance", "거리", "주행거리"];
|
||||
const onTimeKeys = ["is_on_time", "on_time", "onTime", "정시", "정시도착"];
|
||||
|
||||
// 총 운송량 찾기
|
||||
let total_weight = 0;
|
||||
for (const key of Object.keys(numericColumns)) {
|
||||
if (weightKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
|
||||
total_weight = numericColumns[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 누적 거리 찾기
|
||||
let total_distance = 0;
|
||||
for (const key of Object.keys(numericColumns)) {
|
||||
if (distanceKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
|
||||
total_distance = numericColumns[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 정시 도착률 계산
|
||||
let on_time_rate = 0;
|
||||
for (const key of Object.keys(firstRow)) {
|
||||
if (onTimeKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
|
||||
const onTimeItems = data.filter((item: any) => {
|
||||
const onTime = item[key];
|
||||
return onTime !== null && onTime !== undefined;
|
||||
});
|
||||
|
||||
if (onTimeItems.length > 0) {
|
||||
const onTimeCount = onTimeItems.filter((item: any) => {
|
||||
const onTime = item[key];
|
||||
return onTime === true || onTime === "true" || onTime === 1 || onTime === "1";
|
||||
}).length;
|
||||
|
||||
on_time_rate = (onTimeCount / onTimeItems.length) * 100;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const calculatedStats: StatsData = {
|
||||
total_count: data.length, // 총 건수
|
||||
total_weight,
|
||||
total_distance,
|
||||
on_time_rate,
|
||||
};
|
||||
|
||||
setStats(calculatedStats);
|
||||
} 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]);
|
||||
|
||||
if (isLoading && !stats) {
|
||||
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 || !stats) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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="rounded-lg border bg-indigo-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">총 건수</div>
|
||||
<div className="mt-2 text-3xl font-bold text-indigo-600">
|
||||
{stats.total_count.toLocaleString()}
|
||||
<span className="ml-1 text-lg">건</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 운송량 */}
|
||||
<div className="rounded-lg border bg-green-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">총 운송량</div>
|
||||
<div className="mt-2 text-3xl font-bold text-green-600">
|
||||
{stats.total_weight.toFixed(1)}
|
||||
<span className="ml-1 text-lg">톤</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 누적 거리 */}
|
||||
<div className="rounded-lg border bg-blue-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">누적 거리</div>
|
||||
<div className="mt-2 text-3xl font-bold text-blue-600">
|
||||
{stats.total_distance.toFixed(1)}
|
||||
<span className="ml-1 text-lg">km</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정시 도착률 */}
|
||||
<div className="rounded-lg border bg-purple-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">정시 도착률</div>
|
||||
<div className="mt-2 text-3xl font-bold text-purple-600">
|
||||
{stats.on_time_rate.toFixed(1)}
|
||||
<span className="ml-1 text-lg">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
frontend/components/dashboard/widgets/WorkHistoryWidget.tsx
Normal file
222
frontend/components/dashboard/widgets/WorkHistoryWidget.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 작업 이력 위젯
|
||||
* - 작업 이력 목록 표시
|
||||
* - 필터링 기능
|
||||
* - 상태별 색상 구분
|
||||
* - 쿼리 결과 기반 데이터 표시
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
import {
|
||||
WORK_TYPE_LABELS,
|
||||
WORK_STATUS_LABELS,
|
||||
WORK_STATUS_COLORS,
|
||||
WorkType,
|
||||
WorkStatus,
|
||||
} from "@/types/workHistory";
|
||||
|
||||
interface WorkHistoryWidgetProps {
|
||||
element: DashboardElement;
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export default function WorkHistoryWidget({ element, refreshInterval = 60000 }: WorkHistoryWidgetProps) {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedType, setSelectedType] = useState<WorkType | "all">("all");
|
||||
const [selectedStatus, setSelectedStatus] = useState<WorkStatus | "all">("all");
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 쿼리가 설정되어 있으면 쿼리 실행
|
||||
if (element.dataSource?.query) {
|
||||
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",
|
||||
connectionId: element.dataSource.connectionId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("데이터 로딩 실패");
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data?.rows) {
|
||||
setData(result.data.rows);
|
||||
} else {
|
||||
throw new Error(result.message || "데이터 로드 실패");
|
||||
}
|
||||
} else {
|
||||
// 쿼리 미설정 시 안내 메시지
|
||||
setError("쿼리를 설정해주세요");
|
||||
setData([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("작업 이력 로드 실패:", err);
|
||||
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval = setInterval(loadData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedType, selectedStatus, refreshInterval, element.dataSource]);
|
||||
|
||||
if (isLoading && data.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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
{/* 필터 */}
|
||||
<div className="flex gap-2 border-b p-3">
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => setSelectedType(e.target.value as WorkType | "all")}
|
||||
className="rounded border px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="all">전체 유형</option>
|
||||
<option value="inbound">입고</option>
|
||||
<option value="outbound">출고</option>
|
||||
<option value="transfer">이송</option>
|
||||
<option value="maintenance">정비</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value as WorkStatus | "all")}
|
||||
className="rounded border px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="all">전체 상태</option>
|
||||
<option value="pending">대기</option>
|
||||
<option value="in_progress">진행중</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="cancelled">취소</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="ml-auto rounded bg-blue-500 px-3 py-1 text-sm text-white hover:bg-blue-600"
|
||||
>
|
||||
🔄 새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-gray-50 text-left">
|
||||
<tr>
|
||||
<th className="border-b px-3 py-2 font-medium">작업번호</th>
|
||||
<th className="border-b px-3 py-2 font-medium">일시</th>
|
||||
<th className="border-b px-3 py-2 font-medium">유형</th>
|
||||
<th className="border-b px-3 py-2 font-medium">차량</th>
|
||||
<th className="border-b px-3 py-2 font-medium">경로</th>
|
||||
<th className="border-b px-3 py-2 font-medium">화물</th>
|
||||
<th className="border-b px-3 py-2 font-medium">중량</th>
|
||||
<th className="border-b px-3 py-2 font-medium">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-8 text-center text-gray-500">
|
||||
작업 이력이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data
|
||||
.filter((item) => selectedType === "all" || item.work_type === selectedType)
|
||||
.filter((item) => selectedStatus === "all" || item.status === selectedStatus)
|
||||
.map((item, index) => (
|
||||
<tr key={item.id || index} className="border-b hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-mono text-xs">{item.work_number}</td>
|
||||
<td className="px-3 py-2">
|
||||
{item.work_date
|
||||
? new Date(item.work_date).toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
|
||||
{WORK_TYPE_LABELS[item.work_type as WorkType] || item.work_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.vehicle_number || "-"}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{item.origin && item.destination ? `${item.origin} → ${item.destination}` : "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.cargo_name || "-"}</td>
|
||||
<td className="px-3 py-2">
|
||||
{item.cargo_weight ? `${item.cargo_weight} ${item.cargo_unit || "ton"}` : "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${WORK_STATUS_COLORS[item.status as WorkStatus] || "bg-gray-100 text-gray-800"}`}
|
||||
>
|
||||
{WORK_STATUS_LABELS[item.status as WorkStatus] || item.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t bg-gray-50 px-3 py-2 text-xs text-gray-600">전체 {data.length}건</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user