범용적으로 만든것들이랑 ui 수정
This commit is contained in:
parent
03635ff82e
commit
39ddf59275
|
|
@ -37,11 +37,42 @@ const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widget
|
|||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const DeliveryStatusWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusWidget"), {
|
||||
// 범용 지도 위젯 (차량, 창고, 고객 등 모든 위치 위젯 통합)
|
||||
const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/MapSummaryWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합)
|
||||
const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 범용 목록 위젯 (차량, 기사, 제품 등 모든 목록 위젯 통합) - 다른 분 작업 중, 임시 주석
|
||||
/* const ListSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/ListSummaryWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
}); */
|
||||
|
||||
// 개별 위젯들 (주석 처리 - StatusSummaryWidget으로 통합됨)
|
||||
// const DeliveryStatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusSummaryWidget"), {
|
||||
// ssr: false,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
// });
|
||||
// const DeliveryTodayStatsWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryTodayStatsWidget"), {
|
||||
// ssr: false,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
// });
|
||||
// const CargoListWidget = dynamic(() => import("@/components/dashboard/widgets/CargoListWidget"), {
|
||||
// ssr: false,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
// });
|
||||
// const CustomerIssuesWidget = dynamic(() => import("@/components/dashboard/widgets/CustomerIssuesWidget"), {
|
||||
// ssr: false,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
// });
|
||||
|
||||
const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
|
|
@ -496,15 +527,86 @@ export function CanvasElement({
|
|||
<div className="widget-interactive-area h-full w-full">
|
||||
<VehicleListWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "map-summary" ? (
|
||||
// 커스텀 지도 카드 - 범용 위젯
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<MapSummaryWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "vehicle-map" ? (
|
||||
// 차량 위치 지도 위젯 렌더링
|
||||
// 차량 위치 지도 위젯 렌더링 (구버전 - 호환용)
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<VehicleMapOnlyWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "delivery-status" ? (
|
||||
// 배송/화물 현황 위젯 렌더링
|
||||
) : element.type === "widget" && element.subtype === "status-summary" ? (
|
||||
// 커스텀 상태 카드 - 범용 위젯
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<DeliveryStatusWidget element={element} />
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="상태 요약"
|
||||
icon="📊"
|
||||
bgGradient="from-slate-50 to-blue-50"
|
||||
/>
|
||||
</div>
|
||||
) : /* element.type === "widget" && element.subtype === "list-summary" ? (
|
||||
// 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석)
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<ListSummaryWidget element={element} />
|
||||
</div>
|
||||
) : */ element.type === "widget" && element.subtype === "delivery-status" ? (
|
||||
// 배송/화물 현황 위젯 - 범용 위젯 사용 (구버전 호환)
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="배송/화물 현황"
|
||||
icon="📦"
|
||||
bgGradient="from-slate-50 to-blue-50"
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "delivery-status-summary" ? (
|
||||
// 배송 상태 요약 - 범용 위젯 사용
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="배송 상태 요약"
|
||||
icon="📊"
|
||||
bgGradient="from-slate-50 to-blue-50"
|
||||
statusConfig={{
|
||||
"배송중": { label: "배송중", color: "blue" },
|
||||
"완료": { label: "완료", color: "green" },
|
||||
"지연": { label: "지연", color: "red" },
|
||||
"픽업 대기": { label: "픽업 대기", color: "yellow" }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "delivery-today-stats" ? (
|
||||
// 오늘 처리 현황 - 범용 위젯 사용
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="오늘 처리 현황"
|
||||
icon="📈"
|
||||
bgGradient="from-slate-50 to-green-50"
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "cargo-list" ? (
|
||||
// 화물 목록 - 범용 위젯 사용
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="화물 목록"
|
||||
icon="📦"
|
||||
bgGradient="from-slate-50 to-orange-50"
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "customer-issues" ? (
|
||||
// 고객 클레임/이슈 - 범용 위젯 사용
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="고객 클레임/이슈"
|
||||
icon="⚠️"
|
||||
bgGradient="from-slate-50 to-red-50"
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "risk-alert" ? (
|
||||
// 리스크/알림 위젯 렌더링
|
||||
|
|
|
|||
|
|
@ -1,14 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { DragData, ElementType, ElementSubtype } from "./types";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 대시보드 사이드바 컴포넌트
|
||||
* - 드래그 가능한 차트/위젯 목록
|
||||
* - 카테고리별 구분
|
||||
* - 아코디언 방식으로 카테고리별 구분
|
||||
*/
|
||||
export function DashboardSidebar() {
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
charts: true,
|
||||
widgets: true,
|
||||
operations: true,
|
||||
});
|
||||
|
||||
// 섹션 토글
|
||||
const toggleSection = (section: keyof typeof expandedSections) => {
|
||||
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
|
||||
};
|
||||
|
||||
// 드래그 시작 처리
|
||||
const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => {
|
||||
const dragData: DragData = { type, subtype };
|
||||
|
|
@ -17,27 +29,36 @@ export function DashboardSidebar() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-[370px] overflow-y-auto border-l border-gray-200 bg-white p-6">
|
||||
<div className="w-[370px] overflow-y-auto border-l border-border bg-background p-5">
|
||||
{/* 차트 섹션 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">📊 차트 종류</h3>
|
||||
<div className="mb-5">
|
||||
<button
|
||||
onClick={() => toggleSection("charts")}
|
||||
className="mb-3 flex w-full items-center justify-between px-1 py-2.5 text-xl font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<span>차트 종류</span>
|
||||
{expandedSections.charts ? (
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground transition-transform" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="space-y-3">
|
||||
<DraggableItem
|
||||
icon="📊"
|
||||
title="바 차트"
|
||||
type="chart"
|
||||
subtype="bar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-primary border-l-4"
|
||||
/>
|
||||
{expandedSections.charts && (
|
||||
<div className="space-y-2">
|
||||
<DraggableItem
|
||||
icon="📊"
|
||||
title="바 차트"
|
||||
type="chart"
|
||||
subtype="bar"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📊"
|
||||
title="수평 바 차트"
|
||||
type="chart"
|
||||
subtype="horizontal-bar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📚"
|
||||
|
|
@ -45,7 +66,6 @@ export function DashboardSidebar() {
|
|||
type="chart"
|
||||
subtype="stacked-bar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📈"
|
||||
|
|
@ -53,7 +73,6 @@ export function DashboardSidebar() {
|
|||
type="chart"
|
||||
subtype="line"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-green-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📉"
|
||||
|
|
@ -61,7 +80,6 @@ export function DashboardSidebar() {
|
|||
type="chart"
|
||||
subtype="area"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-green-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🥧"
|
||||
|
|
@ -69,7 +87,6 @@ export function DashboardSidebar() {
|
|||
type="chart"
|
||||
subtype="pie"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-purple-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🍩"
|
||||
|
|
@ -77,39 +94,47 @@ export function DashboardSidebar() {
|
|||
type="chart"
|
||||
subtype="donut"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-purple-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📊📈"
|
||||
icon="📊"
|
||||
title="콤보 차트"
|
||||
type="chart"
|
||||
subtype="combo"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 위젯 섹션 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">🔧 위젯 종류</h3>
|
||||
<div className="mb-5">
|
||||
<button
|
||||
onClick={() => toggleSection("widgets")}
|
||||
className="mb-3 flex w-full items-center justify-between px-1 py-2.5 text-xl font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<span>위젯 종류</span>
|
||||
{expandedSections.widgets ? (
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground transition-transform" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="space-y-3">
|
||||
<DraggableItem
|
||||
icon="💱"
|
||||
title="환율 위젯"
|
||||
type="widget"
|
||||
subtype="exchange"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-orange-500"
|
||||
/>
|
||||
{expandedSections.widgets && (
|
||||
<div className="space-y-2">
|
||||
<DraggableItem
|
||||
icon="💱"
|
||||
title="환율 위젯"
|
||||
type="widget"
|
||||
subtype="exchange"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="☁️"
|
||||
title="날씨 위젯"
|
||||
type="widget"
|
||||
subtype="weather"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-cyan-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🧮"
|
||||
|
|
@ -117,7 +142,6 @@ export function DashboardSidebar() {
|
|||
type="widget"
|
||||
subtype="calculator"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-green-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="⏰"
|
||||
|
|
@ -125,47 +149,28 @@ export function DashboardSidebar() {
|
|||
type="widget"
|
||||
subtype="clock"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-teal-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📊"
|
||||
title="차량 상태 현황"
|
||||
icon="📍"
|
||||
title="커스텀 지도 카드"
|
||||
type="widget"
|
||||
subtype="vehicle-status"
|
||||
subtype="map-summary"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-green-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
{/* 주석: 다른 분이 범용 리스트 작업 중 - 충돌 방지를 위해 임시 주석처리 */}
|
||||
{/* <DraggableItem
|
||||
icon="📋"
|
||||
title="차량 목록"
|
||||
title="커스텀 목록 카드"
|
||||
type="widget"
|
||||
subtype="vehicle-list"
|
||||
subtype="list-summary"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🗺️"
|
||||
title="차량 위치 지도"
|
||||
type="widget"
|
||||
subtype="vehicle-map"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-red-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📦"
|
||||
title="배송/화물 현황"
|
||||
type="widget"
|
||||
subtype="delivery-status"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-amber-500"
|
||||
/>
|
||||
/> */}
|
||||
<DraggableItem
|
||||
icon="⚠️"
|
||||
title="리스크/알림 위젯"
|
||||
type="widget"
|
||||
subtype="risk-alert"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-rose-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📅"
|
||||
|
|
@ -173,65 +178,71 @@ export function DashboardSidebar() {
|
|||
type="widget"
|
||||
subtype="calendar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-indigo-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🚗"
|
||||
title="기사 관리 위젯"
|
||||
icon="📊"
|
||||
title="커스텀 상태 카드"
|
||||
type="widget"
|
||||
subtype="driver-management"
|
||||
subtype="status-summary"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 운영/작업 지원 섹션 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">📋 운영/작업 지원</h3>
|
||||
<div className="mb-5">
|
||||
<button
|
||||
onClick={() => toggleSection("operations")}
|
||||
className="mb-3 flex w-full items-center justify-between px-1 py-2.5 text-xl font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<span>운영/작업 지원</span>
|
||||
{expandedSections.operations ? (
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground transition-transform" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="space-y-3">
|
||||
<DraggableItem
|
||||
icon="✅"
|
||||
title="To-Do / 긴급 지시"
|
||||
type="widget"
|
||||
subtype="todo"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🔔"
|
||||
title="예약 요청 알림"
|
||||
type="widget"
|
||||
subtype="booking-alert"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-rose-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🔧"
|
||||
title="정비 일정 관리"
|
||||
type="widget"
|
||||
subtype="maintenance"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-teal-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📂"
|
||||
title="문서 다운로드"
|
||||
type="widget"
|
||||
subtype="document"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-purple-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📋"
|
||||
title="리스트 위젯"
|
||||
type="widget"
|
||||
subtype="list"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-600"
|
||||
/>
|
||||
</div>
|
||||
{expandedSections.operations && (
|
||||
<div className="space-y-2">
|
||||
<DraggableItem
|
||||
icon="✅"
|
||||
title="To-Do / 긴급 지시"
|
||||
type="widget"
|
||||
subtype="todo"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🔔"
|
||||
title="예약 요청 알림"
|
||||
type="widget"
|
||||
subtype="booking-alert"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🔧"
|
||||
title="정비 일정 관리"
|
||||
type="widget"
|
||||
subtype="maintenance"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📂"
|
||||
title="문서 다운로드"
|
||||
type="widget"
|
||||
subtype="document"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📋"
|
||||
title="리스트 위젯"
|
||||
type="widget"
|
||||
subtype="list"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -253,10 +264,9 @@ function DraggableItem({ icon, title, type, subtype, className = "", onDragStart
|
|||
return (
|
||||
<div
|
||||
draggable
|
||||
className={`cursor-move rounded-lg border-2 border-gray-200 bg-white p-4 text-center text-sm font-medium transition-all duration-200 hover:translate-x-1 hover:border-green-500 hover:bg-gray-50 ${className} `}
|
||||
className="cursor-move rounded-md border border-border bg-card px-4 py-2.5 text-sm font-medium text-card-foreground transition-all duration-150 hover:border-primary hover:bg-accent"
|
||||
onDragStart={(e) => onDragStart(e, type, subtype)}
|
||||
>
|
||||
<span className="mr-2 text-lg">{icon}</span>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,11 +37,17 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
const isSimpleWidget =
|
||||
element.subtype === "vehicle-status" ||
|
||||
element.subtype === "vehicle-list" ||
|
||||
element.subtype === "status-summary" || // 커스텀 상태 카드
|
||||
// element.subtype === "list-summary" || // 커스텀 목록 카드 (다른 분 작업 중 - 임시 주석)
|
||||
element.subtype === "delivery-status" ||
|
||||
element.subtype === "delivery-status-summary" ||
|
||||
element.subtype === "delivery-today-stats" ||
|
||||
element.subtype === "cargo-list" ||
|
||||
element.subtype === "customer-issues" ||
|
||||
element.subtype === "driver-management";
|
||||
|
||||
// 지도 위젯 (위도/경도 매핑 필요)
|
||||
const isMapWidget = element.subtype === "vehicle-map";
|
||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary";
|
||||
|
||||
// 주석
|
||||
// 모달이 열릴 때 초기화
|
||||
|
|
|
|||
|
|
@ -19,11 +19,18 @@ export type ElementSubtype =
|
|||
| "calendar"
|
||||
| "calculator"
|
||||
| "vehicle-status"
|
||||
| "vehicle-list"
|
||||
| "vehicle-map"
|
||||
| "vehicle-list" // (구버전 - 호환용)
|
||||
| "vehicle-map" // (구버전 - 호환용)
|
||||
| "map-summary" // 범용 지도 카드 (통합)
|
||||
| "delivery-status"
|
||||
| "status-summary" // 범용 상태 카드 (통합)
|
||||
// | "list-summary" // 범용 목록 카드 (다른 분 작업 중 - 임시 주석)
|
||||
| "delivery-status-summary" // (구버전 - 호환용)
|
||||
| "delivery-today-stats" // (구버전 - 호환용)
|
||||
| "cargo-list" // (구버전 - 호환용)
|
||||
| "customer-issues" // (구버전 - 호환용)
|
||||
| "risk-alert"
|
||||
| "driver-management"
|
||||
| "driver-management" // (구버전 - 호환용)
|
||||
| "todo"
|
||||
| "booking-alert"
|
||||
| "maintenance"
|
||||
|
|
@ -112,6 +119,9 @@ export interface ChartConfig {
|
|||
|
||||
// 애니메이션
|
||||
enableAnimation?: boolean; // 애니메이션 활성화
|
||||
|
||||
// 상태 필터링 (커스텀 상태 카드용)
|
||||
statusFilter?: string[]; // 표시할 상태 목록 (예: ["driving", "parked"])
|
||||
animationDuration?: number; // 애니메이션 시간 (ms)
|
||||
|
||||
// 툴팁
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
interface CargoListWidgetProps {
|
||||
element: DashboardElement;
|
||||
}
|
||||
|
||||
interface Cargo {
|
||||
id: string | number;
|
||||
tracking_number?: string;
|
||||
trackingNumber?: string;
|
||||
customer_name?: string;
|
||||
customerName?: string;
|
||||
destination?: string;
|
||||
status?: string;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화물 목록 위젯
|
||||
* - 화물 목록 테이블 표시
|
||||
* - 상태별 배지 표시
|
||||
*/
|
||||
export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
||||
const [cargoList, setCargoList] = useState<Cargo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [element]);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!element?.dataSource?.query) {
|
||||
setError("쿼리가 설정되지 않았습니다");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
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) {
|
||||
setCargoList(result.data.rows);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusLower = status?.toLowerCase() || "";
|
||||
|
||||
if (statusLower.includes("배송중") || statusLower.includes("delivering")) {
|
||||
return "bg-primary text-primary-foreground";
|
||||
} else if (statusLower.includes("완료") || statusLower.includes("delivered")) {
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100";
|
||||
} else if (statusLower.includes("지연") || statusLower.includes("delayed")) {
|
||||
return "bg-destructive text-destructive-foreground";
|
||||
} else if (statusLower.includes("픽업") || statusLower.includes("pending")) {
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100";
|
||||
}
|
||||
return "bg-muted text-muted-foreground";
|
||||
};
|
||||
|
||||
const filteredList = cargoList.filter((cargo) => {
|
||||
if (!searchTerm) return true;
|
||||
|
||||
const trackingNum = cargo.tracking_number || cargo.trackingNumber || "";
|
||||
const customerName = cargo.customer_name || cargo.customerName || "";
|
||||
const destination = cargo.destination || "";
|
||||
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
trackingNum.toLowerCase().includes(searchLower) ||
|
||||
customerName.toLowerCase().includes(searchLower) ||
|
||||
destination.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
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-md bg-destructive/10 px-3 py-1 text-xs hover:bg-destructive/20"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!element?.dataSource?.query) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-foreground">📦 화물 목록</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
title="새로고침"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 건수 */}
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
총 <span className="font-semibold text-foreground">{filteredList.length}</span>건
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="border-b border-border p-2 text-left font-medium">운송장번호</th>
|
||||
<th className="border-b border-border p-2 text-left font-medium">고객명</th>
|
||||
<th className="border-b border-border p-2 text-left font-medium">목적지</th>
|
||||
<th className="border-b border-border p-2 text-left font-medium">무게(kg)</th>
|
||||
<th className="border-b border-border p-2 text-left font-medium">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||
{searchTerm ? "검색 결과가 없습니다" : "화물이 없습니다"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredList.map((cargo, index) => (
|
||||
<tr
|
||||
key={cargo.id || index}
|
||||
className="border-b border-border hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<td className="p-2 font-medium text-foreground">
|
||||
{cargo.tracking_number || cargo.trackingNumber || "-"}
|
||||
</td>
|
||||
<td className="p-2 text-foreground">
|
||||
{cargo.customer_name || cargo.customerName || "-"}
|
||||
</td>
|
||||
<td className="p-2 text-muted-foreground">
|
||||
{cargo.destination || "-"}
|
||||
</td>
|
||||
<td className="p-2 text-right text-muted-foreground">
|
||||
{cargo.weight ? `${cargo.weight}kg` : "-"}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-1 text-xs font-medium ${getStatusBadge(cargo.status || "")}`}
|
||||
>
|
||||
{cargo.status || "알 수 없음"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
interface CustomerIssuesWidgetProps {
|
||||
element: DashboardElement;
|
||||
}
|
||||
|
||||
interface Issue {
|
||||
id: string | number;
|
||||
issue_type?: string;
|
||||
issueType?: string;
|
||||
customer_name?: string;
|
||||
customerName?: string;
|
||||
description?: string;
|
||||
priority?: string;
|
||||
created_at?: string;
|
||||
createdAt?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 고객 클레임/이슈 위젯
|
||||
* - 클레임/이슈 목록 표시
|
||||
* - 우선순위별 배지 표시
|
||||
*/
|
||||
export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetProps) {
|
||||
const [issues, setIssues] = useState<Issue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filterPriority, setFilterPriority] = useState<string>("all");
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [element]);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!element?.dataSource?.query) {
|
||||
setError("쿼리가 설정되지 않았습니다");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
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) {
|
||||
setIssues(result.data.rows);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
const priorityLower = priority?.toLowerCase() || "";
|
||||
|
||||
if (priorityLower.includes("긴급") || priorityLower.includes("high") || priorityLower.includes("urgent")) {
|
||||
return "bg-destructive text-destructive-foreground";
|
||||
} else if (priorityLower.includes("보통") || priorityLower.includes("medium") || priorityLower.includes("normal")) {
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100";
|
||||
} else if (priorityLower.includes("낮음") || priorityLower.includes("low")) {
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100";
|
||||
}
|
||||
return "bg-muted text-muted-foreground";
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusLower = status?.toLowerCase() || "";
|
||||
|
||||
if (statusLower.includes("처리중") || statusLower.includes("processing") || statusLower.includes("pending")) {
|
||||
return "bg-primary text-primary-foreground";
|
||||
} else if (statusLower.includes("완료") || statusLower.includes("resolved") || statusLower.includes("closed")) {
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100";
|
||||
}
|
||||
return "bg-muted text-muted-foreground";
|
||||
};
|
||||
|
||||
const filteredIssues = filterPriority === "all"
|
||||
? issues
|
||||
: issues.filter((issue) => {
|
||||
const priority = (issue.priority || "").toLowerCase();
|
||||
return priority.includes(filterPriority);
|
||||
});
|
||||
|
||||
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-md bg-destructive/10 px-3 py-1 text-xs hover:bg-destructive/20"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!element?.dataSource?.query) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-foreground">⚠️ 고객 클레임/이슈</h3>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
title="새로고침"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 필터 버튼 */}
|
||||
<div className="mb-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilterPriority("all")}
|
||||
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
||||
filterPriority === "all"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterPriority("긴급")}
|
||||
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
||||
filterPriority === "긴급"
|
||||
? "bg-destructive text-destructive-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
긴급
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterPriority("보통")}
|
||||
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
||||
filterPriority === "보통"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
보통
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterPriority("낮음")}
|
||||
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
||||
filterPriority === "낮음"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
낮음
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 총 건수 */}
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
총 <span className="font-semibold text-foreground">{filteredIssues.length}</span>건
|
||||
</div>
|
||||
|
||||
{/* 이슈 리스트 */}
|
||||
<div className="flex-1 space-y-2 overflow-auto">
|
||||
{filteredIssues.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
|
||||
<p>이슈가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredIssues.map((issue, index) => (
|
||||
<div
|
||||
key={issue.id || index}
|
||||
className="rounded-lg border border-border bg-card p-3 transition-all hover:shadow-md"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${getPriorityBadge(issue.priority || "")}`}>
|
||||
{issue.priority || "보통"}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${getStatusBadge(issue.status || "")}`}>
|
||||
{issue.status || "처리중"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{issue.issue_type || issue.issueType || "기타"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
고객: {issue.customer_name || issue.customerName || "-"}
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{issue.description || "설명 없음"}
|
||||
</p>
|
||||
|
||||
{(issue.created_at || issue.createdAt) && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{new Date(issue.created_at || issue.createdAt || "").toLocaleDateString("ko-KR")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
interface DeliveryStatusSummaryWidgetProps {
|
||||
element: DashboardElement;
|
||||
}
|
||||
|
||||
interface DeliveryStatus {
|
||||
status: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배송 상태 요약 위젯
|
||||
* - 배송중, 완료, 지연, 픽업 대기 상태별 카운트 표시
|
||||
*/
|
||||
export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusSummaryWidgetProps) {
|
||||
const [statusData, setStatusData] = useState<DeliveryStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [element]);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!element?.dataSource?.query) {
|
||||
setError("쿼리가 설정되지 않았습니다");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
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 = rows.reduce((acc: any, row: any) => {
|
||||
const status = row.status || "알 수 없음";
|
||||
acc[status] = (acc[status] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const formattedData: DeliveryStatus[] = [
|
||||
{ status: "배송중", count: statusCounts["배송중"] || statusCounts["delivering"] || 0 },
|
||||
{ status: "완료", count: statusCounts["완료"] || statusCounts["delivered"] || 0 },
|
||||
{ status: "지연", count: statusCounts["지연"] || statusCounts["delayed"] || 0 },
|
||||
{ status: "픽업 대기", count: statusCounts["픽업 대기"] || statusCounts["pending"] || 0 },
|
||||
];
|
||||
|
||||
setStatusData(formattedData);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getBorderColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "배송중":
|
||||
return "border-blue-500";
|
||||
case "완료":
|
||||
return "border-green-500";
|
||||
case "지연":
|
||||
return "border-red-500";
|
||||
case "픽업 대기":
|
||||
return "border-yellow-500";
|
||||
default:
|
||||
return "border-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getDotColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "배송중":
|
||||
return "bg-blue-500";
|
||||
case "완료":
|
||||
return "bg-green-500";
|
||||
case "지연":
|
||||
return "bg-red-500";
|
||||
case "픽업 대기":
|
||||
return "bg-yellow-500";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getTextColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "배송중":
|
||||
return "text-blue-600";
|
||||
case "완료":
|
||||
return "text-green-600";
|
||||
case "지연":
|
||||
return "text-red-600";
|
||||
case "픽업 대기":
|
||||
return "text-yellow-600";
|
||||
default:
|
||||
return "text-gray-600";
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="text-center text-gray-500">
|
||||
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalCount = statusData.reduce((sum, item) => sum + item.count, 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 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">📊 배송 상태 요약</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) => (
|
||||
<div
|
||||
key={item.status}
|
||||
className={`rounded border-l-2 bg-white p-1.5 shadow-sm ${getBorderColor(item.status)}`}
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${getDotColor(item.status)}`}></div>
|
||||
<div className="text-xs font-medium text-gray-600">{item.status}</div>
|
||||
</div>
|
||||
<div className={`text-lg font-bold ${getTextColor(item.status)}`}>{item.count.toLocaleString()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
interface DeliveryTodayStatsWidgetProps {
|
||||
element: DashboardElement;
|
||||
}
|
||||
|
||||
interface TodayStats {
|
||||
shipped: number;
|
||||
delivered: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘 처리 현황 위젯
|
||||
* - 오늘 발송 건수
|
||||
* - 오늘 도착 건수
|
||||
*/
|
||||
export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStatsWidgetProps) {
|
||||
const [todayStats, setTodayStats] = useState<TodayStats>({ shipped: 0, delivered: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [element]);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!element?.dataSource?.query) {
|
||||
setError("쿼리가 설정되지 않았습니다");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
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 today = new Date().toISOString().split("T")[0];
|
||||
|
||||
// 오늘 발송 건수 (created_at 기준)
|
||||
const shippedToday = rows.filter((row: any) => {
|
||||
const createdDate = row.created_at?.split("T")[0] || row.createdAt?.split("T")[0];
|
||||
return createdDate === today;
|
||||
}).length;
|
||||
|
||||
// 오늘 도착 건수 (status === 'delivered' AND estimated_delivery 기준)
|
||||
const deliveredToday = rows.filter((row: any) => {
|
||||
const status = row.status || "";
|
||||
const deliveryDate = row.estimated_delivery?.split("T")[0] || row.estimatedDelivery?.split("T")[0];
|
||||
return (status === "delivered" || status === "완료") && deliveryDate === today;
|
||||
}).length;
|
||||
|
||||
setTodayStats({
|
||||
shipped: shippedToday,
|
||||
delivered: deliveredToday,
|
||||
});
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="text-center text-gray-500">
|
||||
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-white p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-800">📅 오늘 처리 현황</h3>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="rounded-full p-1 text-gray-500 hover:bg-gray-100"
|
||||
title="새로고침"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
{/* 오늘 발송 */}
|
||||
<div className="flex flex-1 flex-col items-center justify-center rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 p-6">
|
||||
<div className="mb-2 text-4xl">📤</div>
|
||||
<p className="text-sm font-medium text-blue-700">오늘 발송</p>
|
||||
<p className="mt-2 text-4xl font-bold text-blue-800">{todayStats.shipped.toLocaleString()}</p>
|
||||
<p className="mt-1 text-xs text-blue-600">건</p>
|
||||
</div>
|
||||
|
||||
{/* 오늘 도착 */}
|
||||
<div className="flex flex-1 flex-col items-center justify-center rounded-lg bg-gradient-to-br from-green-50 to-green-100 p-6">
|
||||
<div className="mb-2 text-4xl">📥</div>
|
||||
<p className="text-sm font-medium text-green-700">오늘 도착</p>
|
||||
<p className="mt-2 text-4xl font-bold text-green-800">{todayStats.delivered.toLocaleString()}</p>
|
||||
<p className="mt-1 text-xs text-green-600">건</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
/**
|
||||
* ⚠️ 임시 주석 처리된 파일
|
||||
* 다른 분이 범용 리스트 작업 중이어서 충돌 방지를 위해 주석 처리
|
||||
* 나중에 merge 시 활성화 필요
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
interface ListSummaryWidgetProps {
|
||||
element: DashboardElement;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 컬럼명 한글 번역
|
||||
const translateColumnName = (colName: string): string => {
|
||||
const columnTranslations: { [key: string]: string } = {
|
||||
// 공통
|
||||
"id": "ID",
|
||||
"name": "이름",
|
||||
"status": "상태",
|
||||
"created_at": "생성일",
|
||||
"updated_at": "수정일",
|
||||
"created_date": "생성일",
|
||||
"updated_date": "수정일",
|
||||
|
||||
// 기사 관련
|
||||
"driver_id": "기사ID",
|
||||
"phone": "전화번호",
|
||||
"license_number": "면허번호",
|
||||
"vehicle_id": "차량ID",
|
||||
"current_location": "현재위치",
|
||||
"rating": "평점",
|
||||
"total_deliveries": "총배송건수",
|
||||
"average_delivery_time": "평균배송시간",
|
||||
"total_distance": "총운행거리",
|
||||
"join_date": "가입일",
|
||||
"last_active": "마지막활동",
|
||||
|
||||
// 차량 관련
|
||||
"vehicle_number": "차량번호",
|
||||
"model": "모델",
|
||||
"year": "연식",
|
||||
"color": "색상",
|
||||
"type": "종류",
|
||||
|
||||
// 배송 관련
|
||||
"delivery_id": "배송ID",
|
||||
"order_id": "주문ID",
|
||||
"customer_name": "고객명",
|
||||
"address": "주소",
|
||||
"delivery_date": "배송일",
|
||||
"estimated_time": "예상시간",
|
||||
|
||||
// 제품 관련
|
||||
"product_id": "제품ID",
|
||||
"product_name": "제품명",
|
||||
"price": "가격",
|
||||
"stock": "재고",
|
||||
"category": "카테고리",
|
||||
"description": "설명",
|
||||
|
||||
// 주문 관련
|
||||
"order_date": "주문일",
|
||||
"quantity": "수량",
|
||||
"total_amount": "총금액",
|
||||
"payment_status": "결제상태",
|
||||
|
||||
// 고객 관련
|
||||
"customer_id": "고객ID",
|
||||
"email": "이메일",
|
||||
"company": "회사",
|
||||
"department": "부서",
|
||||
};
|
||||
|
||||
return columnTranslations[colName.toLowerCase()] ||
|
||||
columnTranslations[colName.replace(/_/g, '').toLowerCase()] ||
|
||||
colName;
|
||||
};
|
||||
|
||||
/**
|
||||
* 범용 목록 위젯
|
||||
* - SQL 쿼리 결과를 테이블 형식으로 표시
|
||||
* - 어떤 데이터든 표시 가능 (기사, 차량, 제품, 주문 등)
|
||||
*/
|
||||
export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tableName, setTableName] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
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;
|
||||
|
||||
// 컬럼 정보 추출 (한글 번역 적용)
|
||||
if (rows.length > 0) {
|
||||
const cols: ColumnInfo[] = Object.keys(rows[0]).map((key) => ({
|
||||
key,
|
||||
label: translateColumnName(key),
|
||||
}));
|
||||
setColumns(cols);
|
||||
}
|
||||
|
||||
setData(rows);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 이름 한글 번역
|
||||
const translateTableName = (name: string): string => {
|
||||
const tableTranslations: { [key: string]: string } = {
|
||||
"drivers": "기사",
|
||||
"driver": "기사",
|
||||
"vehicles": "차량",
|
||||
"vehicle": "차량",
|
||||
"products": "제품",
|
||||
"product": "제품",
|
||||
"orders": "주문",
|
||||
"order": "주문",
|
||||
"customers": "고객",
|
||||
"customer": "고객",
|
||||
"deliveries": "배송",
|
||||
"delivery": "배송",
|
||||
"users": "사용자",
|
||||
"user": "사용자",
|
||||
};
|
||||
|
||||
return tableTranslations[name.toLowerCase()] ||
|
||||
tableTranslations[name.replace(/_/g, '').toLowerCase()] ||
|
||||
name;
|
||||
};
|
||||
|
||||
const displayTitle = tableName ? `${translateTableName(tableName)} 목록` : "데이터 목록";
|
||||
|
||||
// 검색 필터링
|
||||
const filteredData = data.filter((row) =>
|
||||
Object.values(row).some((value) =>
|
||||
String(value).toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
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">📋</div>
|
||||
<h3 className="text-sm font-bold text-gray-900">데이터 목록</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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 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">📋 {displayTitle}</h3>
|
||||
<p className="text-xs text-gray-500">총 {filteredData.length.toLocaleString()}건</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>
|
||||
|
||||
{/* 검색 */}
|
||||
{data.length > 0 && (
|
||||
<div className="mb-2 flex-shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{filteredData.length > 0 ? (
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 bg-gray-100">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="border border-gray-300 px-2 py-1 text-left font-semibold text-gray-700"
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white">
|
||||
{filteredData.map((row, idx) => (
|
||||
<tr key={idx} className="hover:bg-gray-50">
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className="border border-gray-300 px-2 py-1 text-gray-800"
|
||||
>
|
||||
{String(row[col.key] || "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-gray-500">
|
||||
<p className="text-sm">검색 결과가 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||
if (typeof window !== "undefined") {
|
||||
const L = require("leaflet");
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
});
|
||||
}
|
||||
|
||||
// Leaflet 동적 import (SSR 방지)
|
||||
const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false });
|
||||
const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false });
|
||||
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
|
||||
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
||||
|
||||
// 브이월드 API 키
|
||||
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
||||
|
||||
interface MapSummaryWidgetProps {
|
||||
element: DashboardElement;
|
||||
}
|
||||
|
||||
interface MarkerData {
|
||||
lat: number;
|
||||
lng: number;
|
||||
name: string;
|
||||
info: any;
|
||||
}
|
||||
|
||||
// 테이블명 한글 번역
|
||||
const translateTableName = (name: string): string => {
|
||||
const tableTranslations: { [key: string]: string } = {
|
||||
"vehicle_locations": "차량",
|
||||
"vehicles": "차량",
|
||||
"warehouses": "창고",
|
||||
"warehouse": "창고",
|
||||
"customers": "고객",
|
||||
"customer": "고객",
|
||||
"deliveries": "배송",
|
||||
"delivery": "배송",
|
||||
"drivers": "기사",
|
||||
"driver": "기사",
|
||||
"stores": "매장",
|
||||
"store": "매장",
|
||||
};
|
||||
|
||||
return tableTranslations[name.toLowerCase()] ||
|
||||
tableTranslations[name.replace(/_/g, '').toLowerCase()] ||
|
||||
name;
|
||||
};
|
||||
|
||||
/**
|
||||
* 범용 지도 위젯 (커스텀 지도 카드)
|
||||
* - 위도/경도가 있는 모든 데이터를 지도에 표시
|
||||
* - 차량, 창고, 고객, 배송 등 모든 위치 데이터 지원
|
||||
* - Leaflet + 브이월드 지도 사용
|
||||
*/
|
||||
export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
||||
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tableName, setTableName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (element?.dataSource?.query) {
|
||||
loadMapData();
|
||||
}
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(() => {
|
||||
if (element?.dataSource?.query) {
|
||||
loadMapData();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [element]);
|
||||
|
||||
const loadMapData = async () => {
|
||||
if (!element?.dataSource?.query) {
|
||||
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 latCol = element.chartConfig?.latitudeColumn || "latitude";
|
||||
const lngCol = element.chartConfig?.longitudeColumn || "longitude";
|
||||
|
||||
// 유효한 좌표 필터링 및 마커 데이터 생성
|
||||
const markerData = rows
|
||||
.filter((row: any) => row[latCol] && row[lngCol])
|
||||
.map((row: any) => ({
|
||||
lat: parseFloat(row[latCol]),
|
||||
lng: parseFloat(row[lngCol]),
|
||||
name: row.name || row.vehicle_number || row.warehouse_name || row.customer_name || "알 수 없음",
|
||||
info: row,
|
||||
}));
|
||||
|
||||
setMarkers(markerData);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayTitle = tableName ? `${translateTableName(tableName)} 위치` : "위치 지도";
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 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">📍 {displayTitle}</h3>
|
||||
{element?.dataSource?.query ? (
|
||||
<p className="text-xs text-gray-500">총 {markers.length.toLocaleString()}개 마커</p>
|
||||
) : (
|
||||
<p className="text-xs text-orange-500">⚙️ 톱니바퀴 버튼을 눌러 데이터를 연결하세요</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadMapData}
|
||||
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 || !element?.dataSource?.query}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 (지도 위에 오버레이) */}
|
||||
{error && (
|
||||
<div className="mb-2 rounded border border-red-300 bg-red-50 p-2 text-center text-xs text-red-600">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 지도 (항상 표시) */}
|
||||
<div className="flex-1 rounded border border-gray-300 bg-white overflow-hidden">
|
||||
<MapContainer
|
||||
center={[36.5, 127.5]}
|
||||
zoom={7}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
zoomControl={true}
|
||||
preferCanvas={true}
|
||||
>
|
||||
{/* 브이월드 타일맵 */}
|
||||
<TileLayer
|
||||
url={`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`}
|
||||
attribution='© <a href="https://www.vworld.kr">VWorld (국토교통부)</a>'
|
||||
maxZoom={19}
|
||||
minZoom={7}
|
||||
updateWhenIdle={true}
|
||||
updateWhenZooming={false}
|
||||
keepBuffer={2}
|
||||
/>
|
||||
|
||||
{/* 마커 표시 */}
|
||||
{markers.map((marker, idx) => (
|
||||
<Marker key={idx} position={[marker.lat, marker.lng]}>
|
||||
<Popup>
|
||||
<div className="text-xs">
|
||||
<div className="mb-1 text-sm font-bold">{marker.name}</div>
|
||||
{Object.entries(marker.info)
|
||||
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
|
||||
.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
"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;
|
||||
};
|
||||
|
||||
const displayTitle = 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -348,36 +348,16 @@ export class ComponentRegistry {
|
|||
// Hot Reload 제어
|
||||
hotReload: {
|
||||
status: async () => {
|
||||
try {
|
||||
const hotReload = await import("../utils/hotReload");
|
||||
return {
|
||||
active: hotReload.isHotReloadActive(),
|
||||
componentCount: this.getComponentCount(),
|
||||
timestamp: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("Hot Reload 모듈 로드 실패:", error);
|
||||
return {
|
||||
active: false,
|
||||
componentCount: this.getComponentCount(),
|
||||
timestamp: new Date(),
|
||||
error: "Hot Reload 모듈을 로드할 수 없습니다",
|
||||
};
|
||||
}
|
||||
// hotReload 기능 제거 (불필요)
|
||||
return {
|
||||
active: false,
|
||||
componentCount: this.getComponentCount(),
|
||||
timestamp: new Date(),
|
||||
};
|
||||
},
|
||||
force: async () => {
|
||||
try {
|
||||
// hotReload 모듈이 존재하는 경우에만 실행
|
||||
const hotReload = await import("../utils/hotReload").catch(() => null);
|
||||
if (hotReload) {
|
||||
hotReload.forceReloadComponents();
|
||||
console.log("✅ 강제 Hot Reload 실행 완료");
|
||||
} else {
|
||||
console.log("⚠️ hotReload 모듈이 없어 건너뜀");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 강제 Hot Reload 실행 실패:", error);
|
||||
}
|
||||
// hotReload 기능 비활성화 (불필요)
|
||||
console.log("⚠️ 강제 Hot Reload는 더 이상 필요하지 않습니다");
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue