2025-10-20 14:07:08 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 작업 이력 위젯
|
|
|
|
|
|
* - 작업 이력 목록 표시
|
|
|
|
|
|
* - 필터링 기능
|
|
|
|
|
|
* - 상태별 색상 구분
|
|
|
|
|
|
* - 쿼리 결과 기반 데이터 표시
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect } from "react";
|
|
|
|
|
|
import { DashboardElement } from "@/components/admin/dashboard/types";
|
2025-10-24 16:08:57 +09:00
|
|
|
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
2025-10-22 13:40:15 +09:00
|
|
|
|
import { WORK_TYPE_LABELS, WORK_STATUS_LABELS, WORK_STATUS_COLORS, WorkType, WorkStatus } from "@/types/workHistory";
|
2025-10-20 14:07:08 +09:00
|
|
|
|
|
|
|
|
|
|
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");
|
2025-10-24 16:08:57 +09:00
|
|
|
|
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
2025-10-20 14:07:08 +09:00
|
|
|
|
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>
|
2025-10-22 13:40:15 +09:00
|
|
|
|
{!element.dataSource?.query && <div className="mt-2 text-xs text-gray-500">쿼리를 설정하세요</div>}
|
2025-10-20 14:07:08 +09:00
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|