Files
pipeline/frontend/app/(main)/COMPANY_16/monitoring/equipment/page.tsx
T
kjs 518990171e feat: Enhance monitoring pages with dynamic settings and themes
- 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.
2026-04-09 15:12:36 +09:00

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>
);
}