518990171e
- Integrated monitoring settings and theme management into the Equipment, Production, and Quality monitoring pages. - Updated auto-refresh functionality to utilize user-defined settings for refresh intervals. - Improved UI elements with dynamic theming for better visual consistency across COMPANY_10, COMPANY_16, and COMPANY_29. - Added settings button to access monitoring configuration, enhancing user experience in managing monitoring preferences. These changes aim to provide a more customizable and user-friendly interface for monitoring operations across multiple companies.
591 lines
21 KiB
TypeScript
591 lines
21 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 설비운영모니터링 — 하드코딩 페이지
|
|
*
|
|
* 설비(equipment_mng) 목록 + 작업지시(work_instruction) 연결
|
|
* 실시간 카드 그리드 형태 모니터링 대시보드
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { cn } from "@/lib/utils";
|
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
|
import { useTabStore } from "@/stores/tabStore";
|
|
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
|
|
|
|
/* ───── 상태 정의 ───── */
|
|
|
|
type OperationStatus = "running" | "idle" | "maintenance" | "off" | "unknown";
|
|
|
|
interface StatusConfig {
|
|
label: string;
|
|
color: string;
|
|
bg: string;
|
|
border: string;
|
|
bar: string;
|
|
icon: React.ReactNode;
|
|
badgeBg: string;
|
|
badgeText: string;
|
|
cardGlow: string;
|
|
}
|
|
|
|
const STATUS_MAP: Record<OperationStatus, StatusConfig> = {
|
|
running: {
|
|
label: "가동중",
|
|
color: "text-emerald-400",
|
|
bg: "bg-emerald-500/10",
|
|
border: "border-emerald-500/30",
|
|
bar: "bg-emerald-400",
|
|
icon: <Zap className="h-4 w-4" />,
|
|
badgeBg: "bg-emerald-500/20",
|
|
badgeText: "text-emerald-300",
|
|
cardGlow: "shadow-emerald-500/5",
|
|
},
|
|
idle: {
|
|
label: "대기",
|
|
color: "text-amber-400",
|
|
bg: "bg-amber-500/10",
|
|
border: "border-amber-500/30",
|
|
bar: "bg-amber-400",
|
|
icon: <Pause className="h-4 w-4" />,
|
|
badgeBg: "bg-amber-500/20",
|
|
badgeText: "text-amber-300",
|
|
cardGlow: "shadow-amber-500/5",
|
|
},
|
|
maintenance: {
|
|
label: "점검/수리",
|
|
color: "text-red-400",
|
|
bg: "bg-red-500/10",
|
|
border: "border-red-500/30",
|
|
bar: "bg-red-400",
|
|
icon: <Wrench className="h-4 w-4" />,
|
|
badgeBg: "bg-red-500/20",
|
|
badgeText: "text-red-300",
|
|
cardGlow: "shadow-red-500/5",
|
|
},
|
|
off: {
|
|
label: "비가동",
|
|
color: "text-gray-400",
|
|
bg: "bg-gray-500/10",
|
|
border: "border-gray-500/30",
|
|
bar: "bg-gray-500",
|
|
icon: <Power className="h-4 w-4" />,
|
|
badgeBg: "bg-gray-500/20",
|
|
badgeText: "text-gray-400",
|
|
cardGlow: "shadow-gray-500/5",
|
|
},
|
|
unknown: {
|
|
label: "미설정",
|
|
color: "text-gray-500",
|
|
bg: "bg-gray-500/10",
|
|
border: "border-gray-600/30",
|
|
bar: "bg-gray-600",
|
|
icon: <Power className="h-4 w-4" />,
|
|
badgeBg: "bg-gray-600/20",
|
|
badgeText: "text-gray-500",
|
|
cardGlow: "",
|
|
},
|
|
};
|
|
|
|
/** operation_status 값 → 내부 키 매핑 */
|
|
function resolveStatus(raw: string | null | undefined): OperationStatus {
|
|
if (!raw) return "unknown";
|
|
const v = raw.trim().toLowerCase();
|
|
if (["running", "가동", "가동중"].includes(v)) return "running";
|
|
if (["idle", "대기"].includes(v)) return "idle";
|
|
if (["maintenance", "점검", "수리", "점검/수리", "점검중"].includes(v)) return "maintenance";
|
|
if (["off", "비가동", "정지"].includes(v)) return "off";
|
|
return "unknown";
|
|
}
|
|
|
|
/* ───── 타입 ───── */
|
|
|
|
interface Equipment {
|
|
id: string;
|
|
equipment_code: string;
|
|
equipment_name: string;
|
|
equipment_type: string;
|
|
installation_location: string;
|
|
operation_status: string;
|
|
manufacturer: string;
|
|
model_name: string;
|
|
image_path: string;
|
|
}
|
|
|
|
interface WorkInstruction {
|
|
id: string;
|
|
instruction_number: string;
|
|
item_name: string;
|
|
equipment_id: string;
|
|
worker_name: string;
|
|
status: string;
|
|
}
|
|
|
|
/* ───── 컴포넌트 ───── */
|
|
|
|
export default function EquipmentMonitoringPage() {
|
|
const { settings } = useMonitoringSettings("equipment");
|
|
const theme = getMonitoringTheme(settings.theme);
|
|
const openTab = useTabStore((s) => s.openTab);
|
|
const df = settings.displayFields;
|
|
|
|
const [equipments, setEquipments] = useState<Equipment[]>([]);
|
|
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [currentTime, setCurrentTime] = useState(new Date());
|
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
|
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
|
const autoRefreshRef = useRef(autoRefresh);
|
|
|
|
// autoRefreshRef 동기화
|
|
useEffect(() => {
|
|
autoRefreshRef.current = autoRefresh;
|
|
}, [autoRefresh]);
|
|
|
|
/* ── 시간 업데이트 ── */
|
|
useEffect(() => {
|
|
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
/* ── 데이터 fetch ── */
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const [equipRes, wiRes] = await Promise.all([
|
|
apiClient.post("/table-management/tables/equipment_mng/data", {
|
|
autoFilter: true,
|
|
page: 1,
|
|
size: 500,
|
|
}),
|
|
apiClient.get("/work-instruction/list").catch(() => ({ data: { data: [] } })),
|
|
]);
|
|
|
|
const eqRows: Equipment[] = equipRes.data?.data?.rows ?? equipRes.data?.rows ?? [];
|
|
setEquipments(eqRows);
|
|
|
|
const wiRows: WorkInstruction[] = wiRes.data?.data ?? wiRes.data?.rows ?? [];
|
|
setWorkInstructions(wiRows);
|
|
} catch (err) {
|
|
console.error("설비 모니터링 데이터 조회 실패:", err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
/* ── 자동 갱신 ── */
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
if (autoRefreshRef.current) fetchData();
|
|
}, settings.refreshInterval * 1000);
|
|
return () => clearInterval(interval);
|
|
}, [fetchData, settings.refreshInterval]);
|
|
|
|
/* ── 요약 통계 ── */
|
|
const stats = useMemo(() => {
|
|
const counts: Record<OperationStatus, number> = {
|
|
running: 0,
|
|
idle: 0,
|
|
maintenance: 0,
|
|
off: 0,
|
|
unknown: 0,
|
|
};
|
|
equipments.forEach((eq) => {
|
|
const s = resolveStatus(eq.operation_status);
|
|
counts[s]++;
|
|
});
|
|
return { total: equipments.length, ...counts };
|
|
}, [equipments]);
|
|
|
|
/* ── 필터된 설비 ── */
|
|
const filteredEquipments = useMemo(() => {
|
|
if (filterStatus === "all") return equipments;
|
|
return equipments.filter((eq) => resolveStatus(eq.operation_status) === filterStatus);
|
|
}, [equipments, filterStatus]);
|
|
|
|
/* ── 설비별 작업지시 맵 ── */
|
|
const wiMap = useMemo(() => {
|
|
const map: Record<string, WorkInstruction[]> = {};
|
|
workInstructions.forEach((wi) => {
|
|
if (wi.equipment_id) {
|
|
if (!map[wi.equipment_id]) map[wi.equipment_id] = [];
|
|
map[wi.equipment_id].push(wi);
|
|
}
|
|
});
|
|
return map;
|
|
}, [workInstructions]);
|
|
|
|
/* ── 가동률 (모킹 — 센서 미연동) ── */
|
|
const getUtilization = (eq: Equipment): number | null => {
|
|
const s = resolveStatus(eq.operation_status);
|
|
if (s === "running") return 75 + Math.floor(Math.random() * 20); // 75~94
|
|
if (s === "idle") return 20 + Math.floor(Math.random() * 30); // 20~49
|
|
if (s === "maintenance") return 0;
|
|
if (s === "off") return 0;
|
|
return null;
|
|
};
|
|
|
|
/* ── 요약 카드 배열 ── */
|
|
const summaryCards: {
|
|
label: string;
|
|
count: number;
|
|
status: OperationStatus | "total";
|
|
color: string;
|
|
bg: string;
|
|
border: string;
|
|
icon: React.ReactNode;
|
|
}[] = [
|
|
{
|
|
label: "전체설비",
|
|
count: stats.total,
|
|
status: "total",
|
|
color: "text-blue-400",
|
|
bg: "bg-blue-500/10",
|
|
border: "border-blue-500/30",
|
|
icon: <Inbox className="h-5 w-5" />,
|
|
},
|
|
{
|
|
label: "가동중",
|
|
count: stats.running,
|
|
status: "running",
|
|
color: "text-emerald-400",
|
|
bg: "bg-emerald-500/10",
|
|
border: "border-emerald-500/30",
|
|
icon: <Zap className="h-5 w-5" />,
|
|
},
|
|
{
|
|
label: "대기",
|
|
count: stats.idle,
|
|
status: "idle",
|
|
color: "text-amber-400",
|
|
bg: "bg-amber-500/10",
|
|
border: "border-amber-500/30",
|
|
icon: <Pause className="h-5 w-5" />,
|
|
},
|
|
{
|
|
label: "점검/수리",
|
|
count: stats.maintenance,
|
|
status: "maintenance",
|
|
color: "text-red-400",
|
|
bg: "bg-red-500/10",
|
|
border: "border-red-500/30",
|
|
icon: <Wrench className="h-5 w-5" />,
|
|
},
|
|
{
|
|
label: "비가동",
|
|
count: stats.off + stats.unknown,
|
|
status: "off",
|
|
color: "text-gray-400",
|
|
bg: "bg-gray-500/10",
|
|
border: "border-gray-500/30",
|
|
icon: <Power className="h-5 w-5" />,
|
|
},
|
|
];
|
|
|
|
/* ── 필터 pill ── */
|
|
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
|
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
|
|
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-700 hover:bg-emerald-500/30" },
|
|
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-700 hover:bg-amber-500/30" },
|
|
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-700 hover:bg-red-500/30" },
|
|
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-700 hover:bg-gray-500/30" },
|
|
];
|
|
|
|
/* ── 포맷 ── */
|
|
const formatTime = (d: Date) =>
|
|
d.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
|
|
const formatDate = (d: Date) =>
|
|
d.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", weekday: "short" });
|
|
|
|
/* ────────────── 렌더 ────────────── */
|
|
|
|
return (
|
|
<div className={cn("min-h-screen space-y-5 p-4 md:p-6", theme.root)} style={theme.cssVars}>
|
|
{/* ── 헤더 ── */}
|
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-8 w-1.5 rounded-full bg-amber-400" />
|
|
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}>설비운영모니터링</h1>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
{/* 현재 시간 */}
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm",
|
|
theme.mutedText,
|
|
theme.cardBorder,
|
|
)}
|
|
>
|
|
<Clock className="h-4 w-4" />
|
|
<span className="font-mono">{formatDate(currentTime)}</span>
|
|
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
|
|
</div>
|
|
|
|
{/* 자동갱신 토글 */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className={cn(
|
|
"gap-1.5 text-xs",
|
|
autoRefresh
|
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
|
|
: "bg-muted text-muted-foreground hover:bg-accent",
|
|
)}
|
|
onClick={() => setAutoRefresh((v) => !v)}
|
|
>
|
|
<span
|
|
className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "animate-pulse bg-emerald-400" : "bg-muted-foreground")}
|
|
/>
|
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
|
</Button>
|
|
|
|
{/* 새로고침 */}
|
|
<Button variant="outline" size="sm" className="gap-1.5" onClick={fetchData} disabled={loading}>
|
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
|
새로고침
|
|
</Button>
|
|
|
|
{/* 설정 */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-1.5"
|
|
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
|
>
|
|
<Settings2 className="h-4 w-4" />
|
|
설정
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* ── 요약 카드 5개 ── */}
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
|
{summaryCards.map((card) => (
|
|
<button
|
|
key={card.label}
|
|
onClick={() => setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))}
|
|
className={cn(
|
|
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
|
card.bg,
|
|
card.border,
|
|
"hover:shadow-lg",
|
|
)}
|
|
>
|
|
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
|
{card.icon}
|
|
</div>
|
|
<div className="text-left">
|
|
<p className="text-muted-foreground text-xs">{card.label}</p>
|
|
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── 필터 pill ── */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{filterPills.map((pill) => (
|
|
<button
|
|
key={pill.value}
|
|
onClick={() => setFilterStatus(pill.value)}
|
|
className={cn(
|
|
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
|
filterStatus === pill.value
|
|
? cn(pill.color, "ring-1 ring-foreground/10")
|
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
|
)}
|
|
>
|
|
{pill.label}
|
|
</button>
|
|
))}
|
|
<span className="text-muted-foreground ml-auto self-center text-sm">{filteredEquipments.length}대 표시</span>
|
|
</div>
|
|
|
|
{/* ── 로딩 ── */}
|
|
{loading && equipments.length === 0 && (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
|
<span className="text-muted-foreground ml-3">설비 데이터를 불러오는 중...</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 데이터 없음 ── */}
|
|
{!loading && equipments.length === 0 && (
|
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
|
<Inbox className="mb-3 h-12 w-12" />
|
|
<p className="text-lg">등록된 설비가 없습니다.</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 설비 카드 그리드 ── */}
|
|
{filteredEquipments.length > 0 && (
|
|
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}>
|
|
{filteredEquipments.map((eq) => {
|
|
const status = resolveStatus(eq.operation_status);
|
|
const cfg = STATUS_MAP[status];
|
|
const utilization = getUtilization(eq);
|
|
const eqWIs = wiMap[eq.id] ?? [];
|
|
|
|
return (
|
|
<div
|
|
key={eq.id}
|
|
className={cn(
|
|
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
|
|
cfg.border,
|
|
cfg.cardGlow,
|
|
)}
|
|
>
|
|
{/* 좌측 색상 바 */}
|
|
<div className={cn("absolute top-0 bottom-0 left-0 w-1 rounded-l-xl", cfg.bar)} />
|
|
|
|
{/* 상단: 설비명 + 상태 배지 */}
|
|
<div className="flex items-start justify-between px-4 pt-3 pb-2 pl-5">
|
|
<div className="min-w-0 flex-1">
|
|
{df.equipmentName && (
|
|
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
|
|
{eq.equipment_name || "이름 없음"}
|
|
</h3>
|
|
)}
|
|
<p className={cn("mt-0.5 truncate text-xs", theme.mutedText)}>
|
|
{df.equipmentType && (eq.equipment_type || "-")}
|
|
{df.equipmentType && df.equipmentLocation && " · "}
|
|
{df.equipmentLocation && (eq.installation_location || "-")}
|
|
</p>
|
|
</div>
|
|
{df.operationStatus && (
|
|
<Badge
|
|
className={cn("ml-2 shrink-0 gap-1 border-0 text-xs font-medium", cfg.badgeBg, cfg.badgeText)}
|
|
>
|
|
{cfg.icon}
|
|
{cfg.label}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* 구분선 */}
|
|
<div className="mx-4 ml-5 border-t border-border" />
|
|
|
|
{/* 정보 그리드 */}
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
|
|
{df.dailyOperationTime && (
|
|
<div>
|
|
<span className={cn("text-xs", theme.mutedText)}>금일 가동시간</span>
|
|
<p className={cn("font-medium", theme.text)}>-</p>
|
|
</div>
|
|
)}
|
|
{df.dailyProductionQty && (
|
|
<div>
|
|
<span className={cn("text-xs", theme.mutedText)}>생산수량</span>
|
|
<p className={cn("font-medium", theme.text)}>-</p>
|
|
</div>
|
|
)}
|
|
{df.worker && (
|
|
<div>
|
|
<span className={cn("text-xs", theme.mutedText)}>작업자</span>
|
|
<p className={cn("font-medium", theme.text)}>
|
|
{eqWIs.length > 0 && eqWIs[0].worker_name ? eqWIs[0].worker_name : "-"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<span className={cn("text-xs", theme.mutedText)}>설비코드</span>
|
|
<p className={cn("truncate font-medium", theme.text)}>{eq.equipment_code || "-"}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 구분선 */}
|
|
<div className="mx-4 ml-5 border-t border-border" />
|
|
|
|
{/* 가동률 프로그레스 */}
|
|
{df.utilizationBar && (
|
|
<div className="px-4 py-2.5 pl-5">
|
|
<div className="mb-1.5 flex items-center justify-between text-xs">
|
|
<span className={theme.mutedText}>가동률</span>
|
|
<span
|
|
className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : theme.mutedText)}
|
|
>
|
|
{utilization !== null ? `${utilization}%` : "-"}
|
|
</span>
|
|
</div>
|
|
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
|
{utilization !== null && (
|
|
<div
|
|
className={cn("h-full rounded-full transition-all duration-700", cfg.bar)}
|
|
style={{ width: `${utilization}%` }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 구분선 */}
|
|
<div className="mx-4 ml-5 border-t border-border" />
|
|
|
|
{/* 현재 작업지시 */}
|
|
{df.currentWorkInstruction && (
|
|
<div className="px-4 py-2.5 pl-5">
|
|
<p className={cn("mb-1 text-xs", theme.mutedText)}>현재 작업지시</p>
|
|
{eqWIs.length > 0 ? (
|
|
<div className="space-y-1">
|
|
{eqWIs.slice(0, 2).map((wi) => (
|
|
<div key={wi.id} className="flex items-center gap-2 text-sm">
|
|
<span className="shrink-0 font-mono text-xs text-blue-400">
|
|
{wi.instruction_number || "-"}
|
|
</span>
|
|
<span className={cn("truncate", theme.mutedText)}>{wi.item_name || "-"}</span>
|
|
</div>
|
|
))}
|
|
{eqWIs.length > 2 && <p className={cn("text-xs", theme.mutedText)}>+{eqWIs.length - 2}건 더</p>}
|
|
</div>
|
|
) : (
|
|
<p className={cn("text-sm italic", theme.mutedText)}>배정된 작업 없음</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 구분선 */}
|
|
<div className="mx-4 ml-5 border-t border-border" />
|
|
|
|
{/* 센서 데이터 (PLC 미연동) */}
|
|
{df.sensorData && (
|
|
<div className="flex items-center gap-4 px-4 py-2.5 pl-5 text-xs">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className={theme.mutedText}>온도</span>
|
|
<span className={cn("font-mono", theme.mutedText)}>-</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className={theme.mutedText}>압력</span>
|
|
<span className={cn("font-mono", theme.mutedText)}>-</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className={theme.mutedText}>RPM</span>
|
|
<span className={cn("font-mono", theme.mutedText)}>-</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* 필터 결과 없음 */}
|
|
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
|
|
<Inbox className="mb-2 h-10 w-10" />
|
|
<p>해당 상태의 설비가 없습니다.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|