ERP-node/frontend/components/dashboard/widgets/StatusSummaryWidget.tsx

401 lines
13 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useState, useEffect } from "react";
import { DashboardElement } from "@/components/admin/dashboard/types";
interface StatusSummaryWidgetProps {
element: DashboardElement;
title?: string;
icon?: string;
bgGradient?: string;
statusConfig?: StatusConfig;
}
interface StatusConfig {
[key: string]: {
label: string;
color: "blue" | "green" | "red" | "yellow" | "orange" | "purple" | "gray";
};
}
// 영어 상태명 → 한글 자동 변환
const statusTranslations: { [key: string]: string } = {
// 배송 관련
"delayed": "지연",
"pickup_waiting": "픽업 대기",
"in_transit": "배송 중",
"delivered": "배송완료",
"pending": "대기중",
"processing": "처리중",
"completed": "완료",
"cancelled": "취소됨",
"failed": "실패",
// 일반 상태
"active": "활성",
"inactive": "비활성",
"enabled": "사용중",
"disabled": "사용안함",
"online": "온라인",
"offline": "오프라인",
"available": "사용가능",
"unavailable": "사용불가",
// 승인 관련
"approved": "승인됨",
"rejected": "거절됨",
"waiting": "대기중",
// 차량 관련
"driving": "운행중",
"parked": "주차",
"maintenance": "정비중",
// 기사 관련 (존중하는 표현)
"waiting": "대기중",
"resting": "휴식중",
"unavailable": "운행불가",
// 기사 평가
"excellent": "우수",
"good": "양호",
"average": "보통",
"poor": "미흡",
// 기사 경력
"veteran": "베테랑",
"experienced": "숙련",
"intermediate": "중급",
"beginner": "초급",
};
// 영어 테이블명 → 한글 자동 변환
const tableTranslations: { [key: string]: string } = {
// 배송/물류 관련
"deliveries": "배송",
"delivery": "배송",
"shipments": "출하",
"shipment": "출하",
"orders": "주문",
"order": "주문",
"cargo": "화물",
"cargos": "화물",
"packages": "소포",
"package": "소포",
// 차량 관련
"vehicles": "차량",
"vehicle": "차량",
"vehicle_locations": "차량위치",
"vehicle_status": "차량상태",
"drivers": "기사",
"driver": "기사",
// 사용자/고객 관련
"users": "사용자",
"user": "사용자",
"customers": "고객",
"customer": "고객",
"members": "회원",
"member": "회원",
// 제품/재고 관련
"products": "제품",
"product": "제품",
"items": "항목",
"item": "항목",
"inventory": "재고",
"stock": "재고",
// 업무 관련
"tasks": "작업",
"task": "작업",
"projects": "프로젝트",
"project": "프로젝트",
"issues": "이슈",
"issue": "이슈",
"tickets": "티켓",
"ticket": "티켓",
// 기타
"logs": "로그",
"log": "로그",
"reports": "리포트",
"report": "리포트",
"alerts": "알림",
"alert": "알림",
};
interface StatusData {
status: string;
count: number;
}
/**
*
* -
* - statusConfig로
*/
export default function StatusSummaryWidget({
element,
title = "상태 요약",
icon = "📊",
bgGradient = "from-slate-50 to-blue-50",
statusConfig
}: StatusSummaryWidgetProps) {
const [statusData, setStatusData] = useState<StatusData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tableName, setTableName] = useState<string | null>(null);
useEffect(() => {
loadData();
// 자동 새로고침 (30초마다)
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
}, [element]);
const loadData = async () => {
if (!element?.dataSource?.query) {
// 쿼리가 없으면 에러가 아니라 초기 상태로 처리
setError(null);
setLoading(false);
setTableName(null);
return;
}
// 쿼리에서 테이블 이름 추출
const extractTableName = (query: string): string | null => {
const fromMatch = query.match(/FROM\s+([a-zA-Z0-9_가-힣]+)/i);
if (fromMatch) {
return fromMatch[1];
}
return null;
};
try {
setLoading(true);
const extractedTableName = extractTableName(element.dataSource.query);
setTableName(extractedTableName);
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) {
const rows = result.data.rows;
// 상태별 카운트 계산
const statusCounts: { [key: string]: number } = {};
// GROUP BY 형식인지 확인
const isGroupedData = rows.length > 0 && rows[0].count !== undefined;
if (isGroupedData) {
// GROUP BY 형식: SELECT status, COUNT(*) as count
rows.forEach((row: any) => {
// 다양한 컬럼명 지원 (status, 상태, state 등)
let status = row.status || row. || row.state || row.STATUS || row.label || row.name || "알 수 없음";
// 영어 → 한글 자동 번역
status = statusTranslations[status] || statusTranslations[status.toLowerCase()] || status;
const count = parseInt(row.count || row. || row.COUNT || row.cnt) || 0;
statusCounts[status] = count;
});
} else {
// SELECT * 형식: 전체 데이터를 가져와서 카운트
rows.forEach((row: any) => {
// 다양한 컬럼명 지원
let status = row.status || row. || row.state || row.STATUS || row.label || row.name || "알 수 없음";
// 영어 → 한글 자동 번역
status = statusTranslations[status] || statusTranslations[status.toLowerCase()] || status;
statusCounts[status] = (statusCounts[status] || 0) + 1;
});
}
// statusConfig가 있으면 해당 순서대로, 없으면 전체 표시
let formattedData: StatusData[];
if (statusConfig) {
formattedData = Object.keys(statusConfig).map((key) => ({
status: statusConfig[key].label,
count: statusCounts[key] || 0,
}));
} else {
formattedData = Object.entries(statusCounts).map(([status, count]) => ({
status,
count,
}));
}
setStatusData(formattedData);
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
} finally {
setLoading(false);
}
};
const getColorClasses = (status: string) => {
// statusConfig에서 색상 찾기
let color: string = "gray";
if (statusConfig) {
const configEntry = Object.entries(statusConfig).find(([_, v]) => v.label === status);
if (configEntry) {
color = configEntry[1].color;
}
}
const colorMap = {
blue: { border: "border-blue-500", dot: "bg-blue-500", text: "text-blue-600" },
green: { border: "border-green-500", dot: "bg-green-500", text: "text-green-600" },
red: { border: "border-red-500", dot: "bg-red-500", text: "text-red-600" },
yellow: { border: "border-yellow-500", dot: "bg-yellow-500", text: "text-yellow-600" },
orange: { border: "border-orange-500", dot: "bg-orange-500", text: "text-orange-600" },
purple: { border: "border-purple-500", dot: "bg-purple-500", text: "text-purple-600" },
gray: { border: "border-gray-500", dot: "bg-gray-500", text: "text-gray-600" },
};
return colorMap[color as keyof typeof colorMap] || colorMap.gray;
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
<p className="mt-2 text-sm text-gray-500"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-red-500">
<p className="text-sm"> {error}</p>
<button
onClick={loadData}
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
>
</button>
</div>
</div>
);
}
if (!element?.dataSource?.query) {
return (
<div className="flex h-full items-center justify-center p-3">
<div className="max-w-xs space-y-2 text-center">
<div className="text-3xl">{icon}</div>
<h3 className="text-sm font-bold text-gray-900">{title}</h3>
<div className="space-y-1.5 text-xs text-gray-600">
<p className="font-medium">📊 </p>
<ul className="space-y-0.5 text-left">
<li> SQL </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
<p className="font-medium"> </p>
<p className="mt-0.5"> </p>
<p>SQL </p>
</div>
</div>
</div>
);
}
const totalCount = statusData.reduce((sum, item) => sum + item.count, 0);
// 테이블 이름이 있으면 제목을 테이블 이름으로 변경
const translateTableName = (name: string): string => {
// 정확한 매칭 시도
if (tableTranslations[name]) {
return tableTranslations[name];
}
// 소문자로 변환하여 매칭 시도
if (tableTranslations[name.toLowerCase()]) {
return tableTranslations[name.toLowerCase()];
}
// 언더스코어 제거하고 매칭 시도
const nameWithoutUnderscore = name.replace(/_/g, '');
if (tableTranslations[nameWithoutUnderscore.toLowerCase()]) {
return tableTranslations[nameWithoutUnderscore.toLowerCase()];
}
// 번역이 없으면 원본 반환
return name;
};
// customTitle이 있으면 사용, 없으면 테이블명으로 자동 생성
const displayTitle = element.customTitle || (tableName ? `${translateTableName(tableName)} 현황` : title);
return (
<div className={`flex h-full w-full flex-col overflow-hidden bg-gradient-to-br ${bgGradient} p-2`}>
{/* 헤더 */}
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
<div className="flex-1">
<h3 className="text-sm font-bold text-gray-900">{icon} {displayTitle}</h3>
{totalCount > 0 ? (
<p className="text-xs text-gray-500"> {totalCount.toLocaleString()}</p>
) : (
<p className="text-xs text-orange-500"> </p>
)}
</div>
<button
onClick={loadData}
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
disabled={loading}
>
{loading ? "⏳" : "🔄"}
</button>
</div>
{/* 스크롤 가능한 콘텐츠 영역 */}
<div className="flex-1 overflow-y-auto">
{/* 상태별 카드 */}
<div className="grid grid-cols-2 gap-1.5">
{statusData.map((item) => {
const colors = getColorClasses(item.status);
return (
<div
key={item.status}
className="rounded border border-gray-200 bg-white p-1.5 shadow-sm"
>
<div className="mb-0.5 flex items-center gap-1">
<div className={`h-1.5 w-1.5 rounded-full ${colors.dot}`}></div>
<div className="text-xs font-medium text-gray-600">{item.status}</div>
</div>
<div className={`text-lg font-bold ${colors.text}`}>{item.count.toLocaleString()}</div>
</div>
);
})}
</div>
</div>
</div>
);
}