2025-10-15 16:16:27 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import React, { 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-15 16:16:27 +09:00
|
|
|
|
|
|
|
|
|
|
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 } = {
|
|
|
|
|
|
// 배송 관련
|
2025-10-22 13:40:15 +09:00
|
|
|
|
delayed: "지연",
|
|
|
|
|
|
pickup_waiting: "픽업 대기",
|
|
|
|
|
|
in_transit: "배송 중",
|
|
|
|
|
|
delivered: "배송완료",
|
|
|
|
|
|
pending: "대기중",
|
|
|
|
|
|
processing: "처리중",
|
|
|
|
|
|
completed: "완료",
|
|
|
|
|
|
cancelled: "취소됨",
|
|
|
|
|
|
failed: "실패",
|
|
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 일반 상태
|
2025-10-22 13:40:15 +09:00
|
|
|
|
active: "활성",
|
|
|
|
|
|
inactive: "비활성",
|
|
|
|
|
|
enabled: "사용중",
|
|
|
|
|
|
disabled: "사용안함",
|
|
|
|
|
|
online: "온라인",
|
|
|
|
|
|
offline: "오프라인",
|
|
|
|
|
|
available: "사용가능",
|
|
|
|
|
|
unavailable: "사용불가",
|
|
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 승인 관련
|
2025-10-22 13:40:15 +09:00
|
|
|
|
approved: "승인됨",
|
|
|
|
|
|
rejected: "거절됨",
|
|
|
|
|
|
waiting: "대기중",
|
|
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 차량 관련
|
2025-10-22 13:40:15 +09:00
|
|
|
|
driving: "운행중",
|
|
|
|
|
|
parked: "주차",
|
|
|
|
|
|
maintenance: "정비중",
|
|
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 기사 관련 (존중하는 표현)
|
2025-10-22 13:40:15 +09:00
|
|
|
|
waiting: "대기중",
|
|
|
|
|
|
resting: "휴식중",
|
|
|
|
|
|
unavailable: "운행불가",
|
|
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 기사 평가
|
2025-10-22 13:40:15 +09:00
|
|
|
|
excellent: "우수",
|
|
|
|
|
|
good: "양호",
|
|
|
|
|
|
average: "보통",
|
|
|
|
|
|
poor: "미흡",
|
|
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 기사 경력
|
2025-10-22 13:40:15 +09:00
|
|
|
|
veteran: "베테랑",
|
|
|
|
|
|
experienced: "숙련",
|
|
|
|
|
|
intermediate: "중급",
|
|
|
|
|
|
beginner: "초급",
|
2025-10-15 16:16:27 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 영어 테이블명 → 한글 자동 변환
|
|
|
|
|
|
const tableTranslations: { [key: string]: string } = {
|
|
|
|
|
|
// 배송/물류 관련
|
2025-10-22 13:40:15 +09:00
|
|
|
|
deliveries: "배송",
|
|
|
|
|
|
delivery: "배송",
|
|
|
|
|
|
shipments: "출하",
|
|
|
|
|
|
shipment: "출하",
|
|
|
|
|
|
orders: "주문",
|
|
|
|
|
|
order: "주문",
|
|
|
|
|
|
cargo: "화물",
|
|
|
|
|
|
cargos: "화물",
|
|
|
|
|
|
packages: "소포",
|
|
|
|
|
|
package: "소포",
|
|
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 차량 관련
|
2025-10-22 13:40:15 +09:00
|
|
|
|
vehicles: "차량",
|
|
|
|
|
|
vehicle: "차량",
|
|
|
|
|
|
vehicle_locations: "차량위치",
|
|
|
|
|
|
vehicle_status: "차량상태",
|
|
|
|
|
|
drivers: "기사",
|
|
|
|
|
|
driver: "기사",
|
|
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 사용자/고객 관련
|
2025-10-22 13:40:15 +09:00
|
|
|
|
users: "사용자",
|
|
|
|
|
|
user: "사용자",
|
|
|
|
|
|
customers: "고객",
|
|
|
|
|
|
customer: "고객",
|
|
|
|
|
|
members: "회원",
|
|
|
|
|
|
member: "회원",
|
|
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 제품/재고 관련
|
2025-10-22 13:40:15 +09:00
|
|
|
|
products: "제품",
|
|
|
|
|
|
product: "제품",
|
|
|
|
|
|
items: "항목",
|
|
|
|
|
|
item: "항목",
|
|
|
|
|
|
inventory: "재고",
|
|
|
|
|
|
stock: "재고",
|
|
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 업무 관련
|
2025-10-22 13:40:15 +09:00
|
|
|
|
tasks: "작업",
|
|
|
|
|
|
task: "작업",
|
|
|
|
|
|
projects: "프로젝트",
|
|
|
|
|
|
project: "프로젝트",
|
|
|
|
|
|
issues: "이슈",
|
|
|
|
|
|
issue: "이슈",
|
|
|
|
|
|
tickets: "티켓",
|
|
|
|
|
|
ticket: "티켓",
|
|
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 기타
|
2025-10-22 13:40:15 +09:00
|
|
|
|
logs: "로그",
|
|
|
|
|
|
log: "로그",
|
|
|
|
|
|
reports: "리포트",
|
|
|
|
|
|
report: "리포트",
|
|
|
|
|
|
alerts: "알림",
|
|
|
|
|
|
alert: "알림",
|
2025-10-15 16:16:27 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
interface StatusData {
|
|
|
|
|
|
status: string;
|
|
|
|
|
|
count: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 범용 상태 요약 위젯
|
|
|
|
|
|
* - 쿼리 결과를 상태별로 카운트해서 카드로 표시
|
|
|
|
|
|
* - 색상과 라벨은 statusConfig로 커스터마이징 가능
|
|
|
|
|
|
*/
|
2025-10-22 13:40:15 +09:00
|
|
|
|
export default function StatusSummaryWidget({
|
|
|
|
|
|
element,
|
2025-10-15 16:16:27 +09:00
|
|
|
|
title = "상태 요약",
|
|
|
|
|
|
icon = "📊",
|
2025-10-29 17:53:03 +09:00
|
|
|
|
bgGradient = "from-background to-primary/10",
|
2025-10-22 13:40:15 +09:00
|
|
|
|
statusConfig,
|
2025-10-15 16:16:27 +09:00
|
|
|
|
}: 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();
|
2025-10-22 13:40:15 +09:00
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 자동 새로고침 (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);
|
2025-10-22 13:40:15 +09:00
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
const token = localStorage.getItem("authToken");
|
2025-10-24 16:08:57 +09:00
|
|
|
|
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
2025-10-15 16:16:27 +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();
|
2025-10-22 13:40:15 +09:00
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 데이터 처리
|
|
|
|
|
|
if (result.success && result.data?.rows) {
|
|
|
|
|
|
const rows = result.data.rows;
|
2025-10-22 13:40:15 +09:00
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 상태별 카운트 계산
|
|
|
|
|
|
const statusCounts: { [key: string]: number } = {};
|
2025-10-22 13:40:15 +09:00
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// GROUP BY 형식인지 확인
|
|
|
|
|
|
const isGroupedData = rows.length > 0 && rows[0].count !== undefined;
|
2025-10-22 13:40:15 +09:00
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2025-10-22 13:40:15 +09:00
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
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 = {
|
2025-10-29 17:53:03 +09:00
|
|
|
|
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" },
|
2025-10-29 18:04:27 +09:00
|
|
|
|
purple: { border: "border-primary", dot: "bg-primary", text: "text-primary" },
|
2025-10-29 17:53:03 +09:00
|
|
|
|
gray: { border: "border-border", dot: "bg-muted-foreground", text: "text-muted-foreground" },
|
2025-10-15 16:16:27 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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" />
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
2025-10-15 16:16:27 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="text-center text-destructive">
|
2025-10-15 16:16:27 +09:00
|
|
|
|
<p className="text-sm">⚠️ {error}</p>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={loadData}
|
2025-10-29 17:53:03 +09:00
|
|
|
|
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
|
2025-10-15 16:16:27 +09:00
|
|
|
|
>
|
|
|
|
|
|
다시 시도
|
|
|
|
|
|
</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>
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<h3 className="text-sm font-bold text-foreground">{title}</h3>
|
|
|
|
|
|
<div className="space-y-1.5 text-xs text-foreground">
|
2025-10-15 16:16:27 +09:00
|
|
|
|
<p className="font-medium">📊 상태별 데이터 집계 위젯</p>
|
|
|
|
|
|
<ul className="space-y-0.5 text-left">
|
|
|
|
|
|
<li>• SQL 쿼리로 데이터를 불러옵니다</li>
|
|
|
|
|
|
<li>• 상태별로 자동 집계하여 카드로 표시</li>
|
|
|
|
|
|
<li>• 실시간 데이터 모니터링 가능</li>
|
|
|
|
|
|
<li>• 색상과 라벨 커스터마이징 지원</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
|
2025-10-15 16:16:27 +09:00
|
|
|
|
<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()];
|
|
|
|
|
|
}
|
|
|
|
|
|
// 언더스코어 제거하고 매칭 시도
|
2025-10-22 13:40:15 +09:00
|
|
|
|
const nameWithoutUnderscore = name.replace(/_/g, "");
|
2025-10-15 16:16:27 +09:00
|
|
|
|
if (tableTranslations[nameWithoutUnderscore.toLowerCase()]) {
|
|
|
|
|
|
return tableTranslations[nameWithoutUnderscore.toLowerCase()];
|
|
|
|
|
|
}
|
|
|
|
|
|
// 번역이 없으면 원본 반환
|
|
|
|
|
|
return name;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-15 18:25:16 +09:00
|
|
|
|
// customTitle이 있으면 사용, 없으면 테이블명으로 자동 생성
|
|
|
|
|
|
const displayTitle = element.customTitle || (tableName ? `${translateTableName(tableName)} 현황` : title);
|
2025-10-15 16:16:27 +09:00
|
|
|
|
|
|
|
|
|
|
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">
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<h3 className="text-sm font-bold text-foreground">
|
2025-10-22 13:40:15 +09:00
|
|
|
|
{icon} {displayTitle}
|
|
|
|
|
|
</h3>
|
2025-10-15 16:16:27 +09:00
|
|
|
|
{totalCount > 0 ? (
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<p className="text-xs text-muted-foreground">총 {totalCount.toLocaleString()}건</p>
|
2025-10-15 16:16:27 +09:00
|
|
|
|
) : (
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<p className="text-xs text-warning">⚙️ 데이터 연결 필요</p>
|
2025-10-15 16:16:27 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={loadData}
|
2025-10-29 17:53:03 +09:00
|
|
|
|
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"
|
2025-10-15 16:16:27 +09:00
|
|
|
|
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 (
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div key={item.status} className="rounded border border-border bg-background p-1.5 shadow-sm">
|
2025-10-15 16:16:27 +09:00
|
|
|
|
<div className="mb-0.5 flex items-center gap-1">
|
|
|
|
|
|
<div className={`h-1.5 w-1.5 rounded-full ${colors.dot}`}></div>
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="text-xs font-medium text-foreground">{item.status}</div>
|
2025-10-15 16:16:27 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className={`text-lg font-bold ${colors.text}`}>{item.count.toLocaleString()}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|