4424071e47
- Added `wi_id` and `work_instruction_no` fields to the `WorkInstruction` interface for better tracking of work instructions. - Introduced a new `ProcessRow` interface to manage work order process statuses, including acceptable, in progress, and completed states. - Updated data fetching logic to include process data from the work order process API, improving the accuracy of equipment status determination. - Enhanced the inferred status logic to utilize process data for more accurate equipment status representation. - Refined summary statistics and filtering mechanisms to reflect the new process tracking capabilities. These changes aim to provide a more comprehensive and accurate monitoring experience for equipment operations across multiple companies.
667 lines
24 KiB
TypeScript
667 lines
24 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;
|
|
wi_id?: string;
|
|
instruction_number: string;
|
|
work_instruction_no?: string;
|
|
item_name: string;
|
|
equipment_id: string;
|
|
worker_name: string;
|
|
status: string;
|
|
progress_status?: string;
|
|
}
|
|
|
|
interface ProcessRow {
|
|
wo_id: string;
|
|
status: string; // acceptable / in_progress / completed
|
|
parent_process_id?: string | null;
|
|
}
|
|
|
|
/* ───── 컴포넌트 ───── */
|
|
|
|
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 [processRows, setProcessRows] = useState<ProcessRow[]>([]);
|
|
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, procRes] = 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: [] } })),
|
|
apiClient.post("/table-management/tables/work_order_process/data", {
|
|
page: 1,
|
|
size: 2000,
|
|
autoFilter: true,
|
|
}).catch(() => ({ data: { data: { data: [] } } })),
|
|
]);
|
|
|
|
const eqRows: Equipment[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
|
|
setEquipments(eqRows);
|
|
|
|
const wiRows: WorkInstruction[] = wiRes.data?.data ?? wiRes.data?.rows ?? [];
|
|
setWorkInstructions(wiRows);
|
|
|
|
const pRows: ProcessRow[] = procRes.data?.data?.data ?? procRes.data?.data?.rows ?? [];
|
|
setProcessRows(pRows);
|
|
} 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 wiMap = useMemo(() => {
|
|
const map: Record<string, WorkInstruction[]> = {};
|
|
workInstructions.forEach((wi) => {
|
|
const eqId = wi.equipment_id;
|
|
if (eqId) {
|
|
if (!map[eqId]) map[eqId] = [];
|
|
map[eqId].push(wi);
|
|
}
|
|
});
|
|
return map;
|
|
}, [workInstructions]);
|
|
|
|
/* ── 설비 상태 자동 판단: 공정 데이터 기반 ── */
|
|
const inferredStatus = useMemo(() => {
|
|
// 작업지시 ID → 설비 ID 매핑
|
|
const wiToEquip: Record<string, string> = {};
|
|
workInstructions.forEach((wi) => {
|
|
const wiId = wi.wi_id || wi.id;
|
|
if (wi.equipment_id) wiToEquip[wiId] = wi.equipment_id;
|
|
});
|
|
|
|
// 설비별 공정 상태 집계
|
|
const equipProcessStatus: Record<string, Set<string>> = {};
|
|
processRows.forEach((p) => {
|
|
const eqId = wiToEquip[p.wo_id];
|
|
if (!eqId) return;
|
|
if (!equipProcessStatus[eqId]) equipProcessStatus[eqId] = new Set();
|
|
equipProcessStatus[eqId].add(p.status);
|
|
});
|
|
|
|
// 설비별 상태 판단
|
|
const result: Record<string, OperationStatus> = {};
|
|
equipments.forEach((eq) => {
|
|
const dbStatus = resolveStatus(eq.operation_status);
|
|
// DB에 점검/수리가 명시되어 있으면 그대로 사용
|
|
if (dbStatus === "maintenance") {
|
|
result[eq.id] = "maintenance";
|
|
return;
|
|
}
|
|
|
|
const statuses = equipProcessStatus[eq.id];
|
|
if (statuses) {
|
|
if (statuses.has("in_progress")) {
|
|
result[eq.id] = "running"; // 진행중 공정 있음 → 가동중
|
|
} else if (statuses.has("acceptable")) {
|
|
result[eq.id] = "idle"; // 접수 대기 공정 있음 → 대기
|
|
} else {
|
|
// 전부 completed → DB 상태 사용
|
|
result[eq.id] = dbStatus !== "unknown" ? dbStatus : "idle";
|
|
}
|
|
} else {
|
|
// 공정 데이터 없음 → 작업지시 여부로 판단
|
|
const eqWIs = wiMap[eq.id];
|
|
if (eqWIs && eqWIs.length > 0) {
|
|
result[eq.id] = "idle"; // 작업지시 배정됨 → 대기
|
|
} else {
|
|
result[eq.id] = dbStatus !== "unknown" ? dbStatus : "off";
|
|
}
|
|
}
|
|
});
|
|
return result;
|
|
}, [equipments, workInstructions, processRows, wiMap]);
|
|
|
|
/* ── 요약 통계 (추론 상태 기반) ── */
|
|
const stats = useMemo(() => {
|
|
const counts: Record<OperationStatus, number> = { running: 0, idle: 0, maintenance: 0, off: 0, unknown: 0 };
|
|
equipments.forEach((eq) => {
|
|
counts[inferredStatus[eq.id] ?? "unknown"]++;
|
|
});
|
|
return { total: equipments.length, ...counts };
|
|
}, [equipments, inferredStatus]);
|
|
|
|
/* ── 필터된 설비 ── */
|
|
const filteredEquipments = useMemo(() => {
|
|
if (filterStatus === "all") return equipments;
|
|
return equipments.filter((eq) => (inferredStatus[eq.id] ?? "unknown") === filterStatus);
|
|
}, [equipments, filterStatus, inferredStatus]);
|
|
|
|
/* ── 가동률 (센서 미연동 — 상태 기반 고정값) ── */
|
|
const utilizationMap = useMemo(() => {
|
|
const map: Record<string, number | null> = {};
|
|
// 설비 ID를 해시하여 상태별 고정 범위 내 값 생성 (리렌더링해도 안 변함)
|
|
const hash = (id: string) => {
|
|
let h = 0;
|
|
for (let i = 0; i < id.length; i++) h = ((h << 5) - h + id.charCodeAt(i)) | 0;
|
|
return Math.abs(h);
|
|
};
|
|
equipments.forEach((eq) => {
|
|
const s = inferredStatus[eq.id] ?? "unknown";
|
|
const h = hash(eq.id);
|
|
if (s === "running") map[eq.id] = 75 + (h % 20); // 75~94 고정
|
|
else if (s === "idle") map[eq.id] = 20 + (h % 30); // 20~49 고정
|
|
else if (s === "maintenance") map[eq.id] = 0;
|
|
else if (s === "off") map[eq.id] = 0;
|
|
else map[eq.id] = null;
|
|
});
|
|
return map;
|
|
}, [equipments, inferredStatus]);
|
|
const getUtilization = (eq: Equipment): number | null => utilizationMap[eq.id] ?? 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 = inferredStatus[eq.id] ?? "unknown";
|
|
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>
|
|
);
|
|
}
|