작업 이력 통계 위젯 추가
백스페이스 안먹는 오류 수정 그리드 컴포넌트 수정 등등
This commit is contained in:
227
frontend/components/dashboard/widgets/TransportStatsWidget.tsx
Normal file
227
frontend/components/dashboard/widgets/TransportStatsWidget.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 운송 통계 위젯
|
||||
* - 총 운송량 (톤)
|
||||
* - 누적 거리 (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",
|
||||
connectionId: element.dataSource.connectionId,
|
||||
}),
|
||||
});
|
||||
|
||||
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);
|
||||
}, [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