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

399 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect } from "react";
import { DashboardElement } from "@/components/admin/dashboard/types";
import { getApiUrl } from "@/lib/utils/apiUrl";
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-background to-primary/10",
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(getApiUrl("/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-primary", dot: "bg-primary", text: "text-primary" },
green: { border: "border-success", dot: "bg-success", text: "text-success" },
red: { border: "border-destructive", dot: "bg-destructive", text: "text-destructive" },
yellow: { border: "border-warning", dot: "bg-warning", text: "text-warning" },
orange: { border: "border-warning", dot: "bg-warning", text: "text-warning" },
purple: { border: "border-primary", dot: "bg-primary", text: "text-primary" },
gray: { border: "border-border", dot: "bg-muted-foreground", text: "text-muted-foreground" },
};
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-muted-foreground"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-destructive">
<p className="text-sm"> {error}</p>
<button
onClick={loadData}
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
>
</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-foreground">{title}</h3>
<div className="space-y-1.5 text-xs text-foreground">
<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-primary/10 p-2 text-[10px] text-primary">
<p className="font-medium"> </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-foreground">
{icon} {displayTitle}
</h3>
{totalCount > 0 ? (
<p className="text-xs text-muted-foreground"> {totalCount.toLocaleString()}</p>
) : (
<p className="text-xs text-warning"> </p>
)}
</div>
<button
onClick={loadData}
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-background p-0 text-xs 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-border bg-background 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-foreground">{item.status}</div>
</div>
<div className={`text-lg font-bold ${colors.text}`}>{item.count.toLocaleString()}</div>
</div>
);
})}
</div>
</div>
</div>
);
}