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.
This commit is contained in:
@@ -12,16 +12,10 @@ import { apiClient } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Wrench,
|
||||
Zap,
|
||||
Pause,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
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";
|
||||
|
||||
/* ───── 상태 정의 ───── */
|
||||
|
||||
@@ -134,11 +128,16 @@ interface WorkInstruction {
|
||||
/* ───── 컴포넌트 ───── */
|
||||
|
||||
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(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||
const autoRefreshRef = useRef(autoRefresh);
|
||||
|
||||
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
/* ── 자동 갱신 (30초) ── */
|
||||
/* ── 자동 갱신 ── */
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (autoRefreshRef.current) fetchData();
|
||||
}, 30000);
|
||||
}, settings.refreshInterval * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
}, [fetchData, settings.refreshInterval]);
|
||||
|
||||
/* ── 요약 통계 ── */
|
||||
const stats = useMemo(() => {
|
||||
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
|
||||
|
||||
/* ── 필터 pill ── */
|
||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
||||
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-300 hover:bg-blue-500/30" },
|
||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30" },
|
||||
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-300 hover:bg-amber-500/30" },
|
||||
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-300 hover:bg-red-500/30" },
|
||||
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-300 hover:bg-gray-500/30" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
/* ── 포맷 ── */
|
||||
@@ -309,20 +308,26 @@ export default function EquipmentMonitoringPage() {
|
||||
/* ────────────── 렌더 ────────────── */
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
|
||||
<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="text-2xl font-bold tracking-tight">설비운영모니터링</h1>
|
||||
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}>설비운영모니터링</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 현재 시간 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 bg-gray-800/60 rounded-lg px-3 py-1.5 border border-gray-700/50">
|
||||
<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="font-mono text-white">{formatTime(currentTime)}</span>
|
||||
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
|
||||
{/* 자동갱신 토글 */}
|
||||
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"border-gray-700 text-xs gap-1.5",
|
||||
"gap-1.5 text-xs",
|
||||
autoRefresh
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
||||
? "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 ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
|
||||
<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="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── 요약 카드 5개 ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<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))
|
||||
}
|
||||
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"
|
||||
"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-xs text-gray-500">{card.label}</p>
|
||||
<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>
|
||||
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||
filterStatus === pill.value
|
||||
? cn(pill.color, "ring-1 ring-white/20")
|
||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
||||
? 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="ml-auto text-sm text-gray-600 self-center">
|
||||
{filteredEquipments.length}대 표시
|
||||
</span>
|
||||
<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="h-8 w-8 animate-spin text-gray-600" />
|
||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
||||
<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="flex flex-col items-center justify-center py-20 text-gray-600">
|
||||
<Inbox className="h-12 w-12 mb-3" />
|
||||
<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))" }}
|
||||
>
|
||||
<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];
|
||||
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
|
||||
<div
|
||||
key={eq.id}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
|
||||
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
|
||||
cfg.border,
|
||||
cfg.cardGlow
|
||||
cfg.cardGlow,
|
||||
)}
|
||||
>
|
||||
{/* 좌측 색상 바 */}
|
||||
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
|
||||
<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">
|
||||
<h3 className="text-base font-semibold text-white truncate">
|
||||
{eq.equipment_name || "이름 없음"}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
||||
{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>
|
||||
<Badge
|
||||
className={cn(
|
||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
||||
cfg.badgeBg,
|
||||
cfg.badgeText
|
||||
)}
|
||||
>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 정보 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">생산수량</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">작업자</span>
|
||||
<p className="text-white font-medium">
|
||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
||||
? eqWIs[0].worker_name
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">설비코드</span>
|
||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 가동률 프로그레스 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500">가동률</span>
|
||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
||||
{utilization !== null ? `${utilization}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
||||
{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-gray-800/80" />
|
||||
|
||||
{/* 현재 작업지시 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</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="text-blue-400 font-mono text-xs shrink-0">
|
||||
{wi.instruction_number || "-"}
|
||||
</span>
|
||||
<span className="text-gray-300 truncate">
|
||||
{wi.item_name || "-"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{eqWIs.length > 2 && (
|
||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
||||
{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-gray-800/80" />
|
||||
<div className="mx-4 ml-5 border-t border-border" />
|
||||
|
||||
{/* 센서 데이터 (PLC 미연동) */}
|
||||
<div className="flex items-center gap-4 px-4 pl-5 py-2.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">온도</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">압력</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">RPM</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
{/* 정보 그리드 */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
|
||||
|
||||
{/* 필터 결과 없음 */}
|
||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
||||
<Inbox className="h-10 w-10 mb-2" />
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,10 @@ 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 type { ProductionDisplayFields } from "@/types/monitoringSettings";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
@@ -16,6 +20,7 @@ import {
|
||||
TrendingUp,
|
||||
Play,
|
||||
Pause,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
|
||||
}
|
||||
|
||||
// 작업지시별 공정현황으로 진행상태 계산
|
||||
function computeProgress(
|
||||
wiId: string,
|
||||
processMap: Map<string, ProcessStep[]>
|
||||
): "대기" | "진행중" | "완료" {
|
||||
function computeProgress(wiId: string, processMap: Map<string, ProcessStep[]>): "대기" | "진행중" | "완료" {
|
||||
const steps = processMap.get(wiId);
|
||||
if (!steps || steps.length === 0) return "대기";
|
||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||
@@ -85,11 +87,15 @@ function computeProgress(
|
||||
|
||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||
export default function ProductionMonitoringPage() {
|
||||
const { settings } = useMonitoringSettings("production");
|
||||
const theme = getMonitoringTheme(settings.theme);
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
|
||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||
|
||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// 작업지시 목록 조회
|
||||
const wiRes = await apiClient.get("/work-instruction/list");
|
||||
const wiRaw: WorkInstruction[] =
|
||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||
const seen = new Set<string>();
|
||||
const wiData = wiRaw.filter((wi) => {
|
||||
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
|
||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||
try {
|
||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
||||
page: 1, size: 1000, autoFilter: true,
|
||||
page: 1,
|
||||
size: 1000,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||
|
||||
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
||||
// ─── 자동갱신 ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const timer = setInterval(fetchData, 30000);
|
||||
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRefresh, fetchData]);
|
||||
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||
|
||||
// ─── 통계 계산 ───────────────────────────────────────────
|
||||
const stats = useMemo(() => {
|
||||
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// ─── 렌더링 ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
|
||||
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
||||
<div className="flex flex-shrink-0 items-center justify-between">
|
||||
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
||||
<Clock className="w-4 h-4" />
|
||||
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
||||
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||
<SummaryCard
|
||||
icon={<Timer className="w-5 h-5" />}
|
||||
icon={<Timer className="h-5 w-5" />}
|
||||
label="대기중"
|
||||
value={stats.waiting}
|
||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<Loader2 className="w-5 h-5" />}
|
||||
icon={<Loader2 className="h-5 w-5" />}
|
||||
label="진행중"
|
||||
value={stats.inProgress}
|
||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
||||
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||
label="완료"
|
||||
value={stats.completed}
|
||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
label="달성율"
|
||||
value={`${stats.achievementRate}%`}
|
||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 탭 필터 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"min-w-[64px]",
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{loading && workInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
|
||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{!loading && filteredInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Inbox className="w-12 h-12 mb-3" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Inbox className="mb-3 h-12 w-12" />
|
||||
<span className="text-sm">
|
||||
{activeTab === "전체"
|
||||
? "등록된 작업지시가 없습니다."
|
||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 작업 카드 그리드 */}
|
||||
{/* 작업 카드 */}
|
||||
{filteredInstructions.length > 0 && (
|
||||
<div
|
||||
className="grid gap-4 flex-1"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
||||
className={cn(
|
||||
"flex-1 gap-4",
|
||||
settings.layout === "grid" && "grid",
|
||||
settings.layout === "list" && "flex flex-col",
|
||||
settings.layout === "split" && "grid grid-cols-2",
|
||||
)}
|
||||
style={
|
||||
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
|
||||
}
|
||||
>
|
||||
{filteredInstructions.map((wi, idx) => (
|
||||
<WorkCard
|
||||
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
|
||||
instruction={wi}
|
||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||
displayFields={settings.displayFields}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -338,11 +354,11 @@ function SummaryCard({
|
||||
colorClass: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
||||
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
|
||||
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<span className="text-foreground text-2xl font-bold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -353,10 +369,12 @@ function WorkCard({
|
||||
instruction: wi,
|
||||
steps,
|
||||
progress,
|
||||
displayFields: df,
|
||||
}: {
|
||||
instruction: WorkInstruction;
|
||||
steps: ProcessStep[];
|
||||
progress: "대기" | "진행중" | "완료";
|
||||
displayFields: ProductionDisplayFields;
|
||||
}) {
|
||||
// API 응답은 flat 구조 (details 배열 아님)
|
||||
const itemName = (wi as any).item_name || "-";
|
||||
@@ -373,36 +391,28 @@ function WorkCard({
|
||||
const currentStep = steps.find((s) => s.status !== "completed");
|
||||
|
||||
// 프로그레스바 색상
|
||||
const barColor =
|
||||
progressPercent >= 100
|
||||
? "bg-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "bg-blue-500"
|
||||
: "bg-amber-500";
|
||||
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
|
||||
|
||||
// 상태 배지 스타일
|
||||
const statusBadge: Record<string, string> = {
|
||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
"진행중": "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
대기: "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
진행중: "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
완료: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
};
|
||||
|
||||
const isUrgent = wi.status === "긴급";
|
||||
|
||||
return (
|
||||
<div className="bg-card border rounded-lg overflow-hidden flex flex-col">
|
||||
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
|
||||
{/* 카드 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">
|
||||
{wi.work_instruction_no}
|
||||
</span>
|
||||
{isUrgent && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{df.workInstructionNo && (
|
||||
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
|
||||
)}
|
||||
{df.priority && isUrgent && (
|
||||
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
긴급
|
||||
</Badge>
|
||||
)}
|
||||
@@ -413,82 +423,88 @@ function WorkCard({
|
||||
</div>
|
||||
|
||||
{/* 카드 본문 - 정보 */}
|
||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
||||
<InfoRow label="품목명" value={itemName} />
|
||||
<InfoRow label="규격" value={spec} />
|
||||
<InfoRow label="거래처" value={customerName} />
|
||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
|
||||
{df.itemName && <InfoRow label="품목명" value={itemName} />}
|
||||
{df.spec && <InfoRow label="규격" value={spec} />}
|
||||
{df.customerName && <InfoRow label="거래처" value={customerName} />}
|
||||
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
|
||||
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
|
||||
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
|
||||
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
|
||||
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
|
||||
</div>
|
||||
|
||||
{/* 공정현황 */}
|
||||
<div className="px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{df.processProgress && (
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "bg-blue-500/20 text-blue-400 animate-pulse",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 프로그레스바 */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500"
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
{df.progressBar && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500",
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted h-2.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -496,7 +512,7 @@ function WorkCard({
|
||||
// ─── 정보 행 ───────────────────────────────────────────────
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="text-muted-foreground shrink-0">{label}:</span>
|
||||
<span className="text-foreground truncate">{value}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,23 +4,12 @@ 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Search,
|
||||
ClipboardCheck,
|
||||
} from "lucide-react";
|
||||
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
||||
|
||||
/* ───── 타입 ───── */
|
||||
interface ProcessRow {
|
||||
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
|
||||
|
||||
/* ───── 유틸 ───── */
|
||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||
const pct = (n: number) =>
|
||||
`${n.toFixed(1)}%`;
|
||||
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||
|
||||
const badgeVariant = (
|
||||
type: "result" | "type" | "defectRate",
|
||||
value: string | number,
|
||||
) => {
|
||||
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
|
||||
if (type === "result") {
|
||||
if (value === "합격")
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||
}
|
||||
if (type === "type") {
|
||||
if (value === "공정검사")
|
||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
}
|
||||
@@ -93,10 +76,15 @@ const badgeVariant = (
|
||||
|
||||
/* ───── 컴포넌트 ───── */
|
||||
export default function QualityMonitoringPage() {
|
||||
const { settings } = useMonitoringSettings("quality");
|
||||
const theme = getMonitoringTheme(settings.theme);
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
const tc = settings.tableColumns;
|
||||
|
||||
const [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
"/table-management/tables/work_order_process/data",
|
||||
{ autoFilter: true },
|
||||
);
|
||||
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||
setProcessData(rows);
|
||||
} catch (err) {
|
||||
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
|
||||
/* ───── 자동 갱신 ───── */
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(fetchData, 30_000);
|
||||
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [autoRefresh, fetchData]);
|
||||
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||
|
||||
/* ───── 검사 행 변환 ───── */
|
||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
|
||||
const goodQty = r.good_qty ?? 0;
|
||||
const defectQty = r.defect_qty ?? 0;
|
||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||
const result: InspectionRow["result"] =
|
||||
r.status !== "completed"
|
||||
? "대기"
|
||||
: defectQty > 0
|
||||
? "불합격"
|
||||
: "합격";
|
||||
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
|
||||
|
||||
return {
|
||||
no: idx + 1,
|
||||
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
|
||||
|
||||
/* ───── 렌더링 ───── */
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
|
||||
<div className={cn("flex items-center justify-between border-b px-6 py-4", theme.header)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
품질점검현황{" "}
|
||||
<span className="text-emerald-600">모니터링</span>
|
||||
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
||||
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className={cn("flex items-center gap-2 text-sm", theme.mutedText)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">
|
||||
{currentTime.toLocaleString("ko-KR", {
|
||||
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
<span className="ml-1">새로고침</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh((p) => !p)}
|
||||
className={cn(
|
||||
autoRefresh &&
|
||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
||||
)}
|
||||
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
<Clock className="mr-1 h-4 w-4" />
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 본문 ── */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||
<div className="flex-1 space-y-6 overflow-auto p-6">
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{summaryCards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className={cn(
|
||||
"rounded-xl bg-gradient-to-br p-5 shadow-md",
|
||||
card.color,
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-white/80">
|
||||
{card.label}
|
||||
</p>
|
||||
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||
{card.value}
|
||||
{card.sub && (
|
||||
<span className="ml-1 text-base font-normal text-white/70">
|
||||
{card.sub}
|
||||
</span>
|
||||
)}
|
||||
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
activeTab === tab.key
|
||||
? "bg-emerald-600 text-white shadow"
|
||||
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border",
|
||||
: "border bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
|
||||
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
|
||||
{/* 입고/출하 준비중 */}
|
||||
{(activeTab === "incoming" || activeTab === "shipping") ? (
|
||||
{activeTab === "incoming" || activeTab === "shipping" ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Search className="h-12 w-12 mb-4 opacity-40" />
|
||||
<Search className="mb-4 h-12 w-12 opacity-40" />
|
||||
<p className="text-lg font-medium">준비중</p>
|
||||
<p className="text-sm mt-1">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
||||
아직 지원되지 않습니다.
|
||||
<p className="mt-1 text-sm">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : loading && filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Loader2 className="h-10 w-10 animate-spin mb-4" />
|
||||
<Loader2 className="mb-4 h-10 w-10 animate-spin" />
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Inbox className="h-12 w-12 mb-4 opacity-40" />
|
||||
<Inbox className="mb-4 h-12 w-12 opacity-40" />
|
||||
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<TableRow className={theme.tableHeader}>
|
||||
<TableHead className="w-[50px] text-center">No</TableHead>
|
||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||
<TableHead className="min-w-[90px] text-center">
|
||||
검사유형
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
검사수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
불합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-right">
|
||||
불량율
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[160px] text-center">
|
||||
검사결과
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-center">
|
||||
판정
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-center">
|
||||
검사자
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
||||
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
||||
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
||||
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
||||
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
||||
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
||||
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
||||
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
||||
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
||||
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
||||
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
||||
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
||||
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
||||
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.map((row) => {
|
||||
const goodPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.goodQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const defectPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.defectQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
||||
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.no}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
||||
>
|
||||
<TableCell className="text-center text-sm text-gray-500">
|
||||
{row.no}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{row.inspectionNo}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("type", row.inspectionType),
|
||||
)}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-medium">
|
||||
{row.itemName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.spec}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{fmt(row.inspectionQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-emerald-600">
|
||||
{fmt(row.goodQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-red-600">
|
||||
{fmt(row.defectQty)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right text-sm",
|
||||
badgeVariant("defectRate", row.defectRate),
|
||||
)}
|
||||
>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
{/* 검사결과 프로그레스바 */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-red-500 transition-all"
|
||||
style={{ width: `${defectPct}%` }}
|
||||
/>
|
||||
<TableRow key={row.no} className={theme.tableRowHover}>
|
||||
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
||||
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
||||
{tc.inspectionType && (
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
||||
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
||||
{tc.inspectionQty && (
|
||||
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
||||
)}
|
||||
{tc.passFailQty && (
|
||||
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
||||
)}
|
||||
{tc.passFailQty && (
|
||||
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
||||
)}
|
||||
{tc.defectRate && (
|
||||
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.resultBar && (
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
||||
</div>
|
||||
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* 판정 배지 */}
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("result", row.result),
|
||||
)}
|
||||
>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{row.inspectorName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString(
|
||||
"ko-KR",
|
||||
{
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.judgment && (
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
||||
{tc.inspectedAt && (
|
||||
<TableCell className={cn("text-sm", theme.mutedText)}>
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
},
|
||||
)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-400">
|
||||
{row.remark || "-"}
|
||||
</TableCell>
|
||||
})
|
||||
: "-"}
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -12,16 +12,10 @@ import { apiClient } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Wrench,
|
||||
Zap,
|
||||
Pause,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
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";
|
||||
|
||||
/* ───── 상태 정의 ───── */
|
||||
|
||||
@@ -134,11 +128,16 @@ interface WorkInstruction {
|
||||
/* ───── 컴포넌트 ───── */
|
||||
|
||||
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(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||
const autoRefreshRef = useRef(autoRefresh);
|
||||
|
||||
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
/* ── 자동 갱신 (30초) ── */
|
||||
/* ── 자동 갱신 ── */
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (autoRefreshRef.current) fetchData();
|
||||
}, 30000);
|
||||
}, settings.refreshInterval * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
}, [fetchData, settings.refreshInterval]);
|
||||
|
||||
/* ── 요약 통계 ── */
|
||||
const stats = useMemo(() => {
|
||||
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
|
||||
|
||||
/* ── 필터 pill ── */
|
||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
||||
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-300 hover:bg-blue-500/30" },
|
||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30" },
|
||||
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-300 hover:bg-amber-500/30" },
|
||||
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-300 hover:bg-red-500/30" },
|
||||
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-300 hover:bg-gray-500/30" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
/* ── 포맷 ── */
|
||||
@@ -309,20 +308,26 @@ export default function EquipmentMonitoringPage() {
|
||||
/* ────────────── 렌더 ────────────── */
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
|
||||
<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="text-2xl font-bold tracking-tight">설비운영모니터링</h1>
|
||||
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}>설비운영모니터링</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 현재 시간 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 bg-gray-800/60 rounded-lg px-3 py-1.5 border border-gray-700/50">
|
||||
<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="font-mono text-white">{formatTime(currentTime)}</span>
|
||||
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
|
||||
{/* 자동갱신 토글 */}
|
||||
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"border-gray-700 text-xs gap-1.5",
|
||||
"gap-1.5 text-xs",
|
||||
autoRefresh
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
||||
? "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 ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
|
||||
<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="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── 요약 카드 5개 ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<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))
|
||||
}
|
||||
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"
|
||||
"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-xs text-gray-500">{card.label}</p>
|
||||
<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>
|
||||
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||
filterStatus === pill.value
|
||||
? cn(pill.color, "ring-1 ring-white/20")
|
||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
||||
? 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="ml-auto text-sm text-gray-600 self-center">
|
||||
{filteredEquipments.length}대 표시
|
||||
</span>
|
||||
<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="h-8 w-8 animate-spin text-gray-600" />
|
||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
||||
<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="flex flex-col items-center justify-center py-20 text-gray-600">
|
||||
<Inbox className="h-12 w-12 mb-3" />
|
||||
<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))" }}
|
||||
>
|
||||
<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];
|
||||
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
|
||||
<div
|
||||
key={eq.id}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
|
||||
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
|
||||
cfg.border,
|
||||
cfg.cardGlow
|
||||
cfg.cardGlow,
|
||||
)}
|
||||
>
|
||||
{/* 좌측 색상 바 */}
|
||||
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
|
||||
<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">
|
||||
<h3 className="text-base font-semibold text-white truncate">
|
||||
{eq.equipment_name || "이름 없음"}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
||||
{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>
|
||||
<Badge
|
||||
className={cn(
|
||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
||||
cfg.badgeBg,
|
||||
cfg.badgeText
|
||||
)}
|
||||
>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 정보 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">생산수량</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">작업자</span>
|
||||
<p className="text-white font-medium">
|
||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
||||
? eqWIs[0].worker_name
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">설비코드</span>
|
||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 가동률 프로그레스 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500">가동률</span>
|
||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
||||
{utilization !== null ? `${utilization}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
||||
{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-gray-800/80" />
|
||||
|
||||
{/* 현재 작업지시 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</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="text-blue-400 font-mono text-xs shrink-0">
|
||||
{wi.instruction_number || "-"}
|
||||
</span>
|
||||
<span className="text-gray-300 truncate">
|
||||
{wi.item_name || "-"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{eqWIs.length > 2 && (
|
||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
||||
{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-gray-800/80" />
|
||||
<div className="mx-4 ml-5 border-t border-border" />
|
||||
|
||||
{/* 센서 데이터 (PLC 미연동) */}
|
||||
<div className="flex items-center gap-4 px-4 pl-5 py-2.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">온도</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">압력</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">RPM</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
{/* 정보 그리드 */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
|
||||
|
||||
{/* 필터 결과 없음 */}
|
||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
||||
<Inbox className="h-10 w-10 mb-2" />
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,10 @@ 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 type { ProductionDisplayFields } from "@/types/monitoringSettings";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
@@ -16,6 +20,7 @@ import {
|
||||
TrendingUp,
|
||||
Play,
|
||||
Pause,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
|
||||
}
|
||||
|
||||
// 작업지시별 공정현황으로 진행상태 계산
|
||||
function computeProgress(
|
||||
wiId: string,
|
||||
processMap: Map<string, ProcessStep[]>
|
||||
): "대기" | "진행중" | "완료" {
|
||||
function computeProgress(wiId: string, processMap: Map<string, ProcessStep[]>): "대기" | "진행중" | "완료" {
|
||||
const steps = processMap.get(wiId);
|
||||
if (!steps || steps.length === 0) return "대기";
|
||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||
@@ -85,11 +87,15 @@ function computeProgress(
|
||||
|
||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||
export default function ProductionMonitoringPage() {
|
||||
const { settings } = useMonitoringSettings("production");
|
||||
const theme = getMonitoringTheme(settings.theme);
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
|
||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||
|
||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// 작업지시 목록 조회
|
||||
const wiRes = await apiClient.get("/work-instruction/list");
|
||||
const wiRaw: WorkInstruction[] =
|
||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||
const seen = new Set<string>();
|
||||
const wiData = wiRaw.filter((wi) => {
|
||||
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
|
||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||
try {
|
||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
||||
page: 1, size: 1000, autoFilter: true,
|
||||
page: 1,
|
||||
size: 1000,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||
|
||||
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
||||
// ─── 자동갱신 ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const timer = setInterval(fetchData, 30000);
|
||||
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRefresh, fetchData]);
|
||||
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||
|
||||
// ─── 통계 계산 ───────────────────────────────────────────
|
||||
const stats = useMemo(() => {
|
||||
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// ─── 렌더링 ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
|
||||
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
||||
<div className="flex flex-shrink-0 items-center justify-between">
|
||||
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
||||
<Clock className="w-4 h-4" />
|
||||
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
||||
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||
<SummaryCard
|
||||
icon={<Timer className="w-5 h-5" />}
|
||||
icon={<Timer className="h-5 w-5" />}
|
||||
label="대기중"
|
||||
value={stats.waiting}
|
||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<Loader2 className="w-5 h-5" />}
|
||||
icon={<Loader2 className="h-5 w-5" />}
|
||||
label="진행중"
|
||||
value={stats.inProgress}
|
||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
||||
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||
label="완료"
|
||||
value={stats.completed}
|
||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
label="달성율"
|
||||
value={`${stats.achievementRate}%`}
|
||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 탭 필터 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"min-w-[64px]",
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{loading && workInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
|
||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{!loading && filteredInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Inbox className="w-12 h-12 mb-3" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Inbox className="mb-3 h-12 w-12" />
|
||||
<span className="text-sm">
|
||||
{activeTab === "전체"
|
||||
? "등록된 작업지시가 없습니다."
|
||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 작업 카드 그리드 */}
|
||||
{/* 작업 카드 */}
|
||||
{filteredInstructions.length > 0 && (
|
||||
<div
|
||||
className="grid gap-4 flex-1"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
||||
className={cn(
|
||||
"flex-1 gap-4",
|
||||
settings.layout === "grid" && "grid",
|
||||
settings.layout === "list" && "flex flex-col",
|
||||
settings.layout === "split" && "grid grid-cols-2",
|
||||
)}
|
||||
style={
|
||||
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
|
||||
}
|
||||
>
|
||||
{filteredInstructions.map((wi, idx) => (
|
||||
<WorkCard
|
||||
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
|
||||
instruction={wi}
|
||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||
displayFields={settings.displayFields}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -338,11 +354,11 @@ function SummaryCard({
|
||||
colorClass: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
||||
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
|
||||
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<span className="text-foreground text-2xl font-bold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -353,10 +369,12 @@ function WorkCard({
|
||||
instruction: wi,
|
||||
steps,
|
||||
progress,
|
||||
displayFields: df,
|
||||
}: {
|
||||
instruction: WorkInstruction;
|
||||
steps: ProcessStep[];
|
||||
progress: "대기" | "진행중" | "완료";
|
||||
displayFields: ProductionDisplayFields;
|
||||
}) {
|
||||
// API 응답은 flat 구조 (details 배열 아님)
|
||||
const itemName = (wi as any).item_name || "-";
|
||||
@@ -373,36 +391,28 @@ function WorkCard({
|
||||
const currentStep = steps.find((s) => s.status !== "completed");
|
||||
|
||||
// 프로그레스바 색상
|
||||
const barColor =
|
||||
progressPercent >= 100
|
||||
? "bg-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "bg-blue-500"
|
||||
: "bg-amber-500";
|
||||
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
|
||||
|
||||
// 상태 배지 스타일
|
||||
const statusBadge: Record<string, string> = {
|
||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
"진행중": "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
대기: "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
진행중: "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
완료: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
};
|
||||
|
||||
const isUrgent = wi.status === "긴급";
|
||||
|
||||
return (
|
||||
<div className="bg-card border rounded-lg overflow-hidden flex flex-col">
|
||||
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
|
||||
{/* 카드 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">
|
||||
{wi.work_instruction_no}
|
||||
</span>
|
||||
{isUrgent && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{df.workInstructionNo && (
|
||||
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
|
||||
)}
|
||||
{df.priority && isUrgent && (
|
||||
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
긴급
|
||||
</Badge>
|
||||
)}
|
||||
@@ -413,82 +423,88 @@ function WorkCard({
|
||||
</div>
|
||||
|
||||
{/* 카드 본문 - 정보 */}
|
||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
||||
<InfoRow label="품목명" value={itemName} />
|
||||
<InfoRow label="규격" value={spec} />
|
||||
<InfoRow label="거래처" value={customerName} />
|
||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
|
||||
{df.itemName && <InfoRow label="품목명" value={itemName} />}
|
||||
{df.spec && <InfoRow label="규격" value={spec} />}
|
||||
{df.customerName && <InfoRow label="거래처" value={customerName} />}
|
||||
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
|
||||
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
|
||||
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
|
||||
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
|
||||
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
|
||||
</div>
|
||||
|
||||
{/* 공정현황 */}
|
||||
<div className="px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{df.processProgress && (
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "bg-blue-500/20 text-blue-400 animate-pulse",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 프로그레스바 */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500"
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
{df.progressBar && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500",
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted h-2.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -496,7 +512,7 @@ function WorkCard({
|
||||
// ─── 정보 행 ───────────────────────────────────────────────
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="text-muted-foreground shrink-0">{label}:</span>
|
||||
<span className="text-foreground truncate">{value}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,23 +4,12 @@ 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Search,
|
||||
ClipboardCheck,
|
||||
} from "lucide-react";
|
||||
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
||||
|
||||
/* ───── 타입 ───── */
|
||||
interface ProcessRow {
|
||||
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
|
||||
|
||||
/* ───── 유틸 ───── */
|
||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||
const pct = (n: number) =>
|
||||
`${n.toFixed(1)}%`;
|
||||
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||
|
||||
const badgeVariant = (
|
||||
type: "result" | "type" | "defectRate",
|
||||
value: string | number,
|
||||
) => {
|
||||
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
|
||||
if (type === "result") {
|
||||
if (value === "합격")
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||
}
|
||||
if (type === "type") {
|
||||
if (value === "공정검사")
|
||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
}
|
||||
@@ -93,10 +76,15 @@ const badgeVariant = (
|
||||
|
||||
/* ───── 컴포넌트 ───── */
|
||||
export default function QualityMonitoringPage() {
|
||||
const { settings } = useMonitoringSettings("quality");
|
||||
const theme = getMonitoringTheme(settings.theme);
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
const tc = settings.tableColumns;
|
||||
|
||||
const [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
"/table-management/tables/work_order_process/data",
|
||||
{ autoFilter: true },
|
||||
);
|
||||
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||
setProcessData(rows);
|
||||
} catch (err) {
|
||||
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
|
||||
/* ───── 자동 갱신 ───── */
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(fetchData, 30_000);
|
||||
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [autoRefresh, fetchData]);
|
||||
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||
|
||||
/* ───── 검사 행 변환 ───── */
|
||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
|
||||
const goodQty = r.good_qty ?? 0;
|
||||
const defectQty = r.defect_qty ?? 0;
|
||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||
const result: InspectionRow["result"] =
|
||||
r.status !== "completed"
|
||||
? "대기"
|
||||
: defectQty > 0
|
||||
? "불합격"
|
||||
: "합격";
|
||||
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
|
||||
|
||||
return {
|
||||
no: idx + 1,
|
||||
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
|
||||
|
||||
/* ───── 렌더링 ───── */
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
|
||||
<div className={cn("flex items-center justify-between border-b px-6 py-4", theme.header)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
품질점검현황{" "}
|
||||
<span className="text-emerald-600">모니터링</span>
|
||||
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
||||
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className={cn("flex items-center gap-2 text-sm", theme.mutedText)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">
|
||||
{currentTime.toLocaleString("ko-KR", {
|
||||
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
<span className="ml-1">새로고침</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh((p) => !p)}
|
||||
className={cn(
|
||||
autoRefresh &&
|
||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
||||
)}
|
||||
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
<Clock className="mr-1 h-4 w-4" />
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 본문 ── */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||
<div className="flex-1 space-y-6 overflow-auto p-6">
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{summaryCards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className={cn(
|
||||
"rounded-xl bg-gradient-to-br p-5 shadow-md",
|
||||
card.color,
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-white/80">
|
||||
{card.label}
|
||||
</p>
|
||||
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||
{card.value}
|
||||
{card.sub && (
|
||||
<span className="ml-1 text-base font-normal text-white/70">
|
||||
{card.sub}
|
||||
</span>
|
||||
)}
|
||||
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
activeTab === tab.key
|
||||
? "bg-emerald-600 text-white shadow"
|
||||
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border",
|
||||
: "border bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
|
||||
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
|
||||
{/* 입고/출하 준비중 */}
|
||||
{(activeTab === "incoming" || activeTab === "shipping") ? (
|
||||
{activeTab === "incoming" || activeTab === "shipping" ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Search className="h-12 w-12 mb-4 opacity-40" />
|
||||
<Search className="mb-4 h-12 w-12 opacity-40" />
|
||||
<p className="text-lg font-medium">준비중</p>
|
||||
<p className="text-sm mt-1">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
||||
아직 지원되지 않습니다.
|
||||
<p className="mt-1 text-sm">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : loading && filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Loader2 className="h-10 w-10 animate-spin mb-4" />
|
||||
<Loader2 className="mb-4 h-10 w-10 animate-spin" />
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Inbox className="h-12 w-12 mb-4 opacity-40" />
|
||||
<Inbox className="mb-4 h-12 w-12 opacity-40" />
|
||||
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<TableRow className={theme.tableHeader}>
|
||||
<TableHead className="w-[50px] text-center">No</TableHead>
|
||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||
<TableHead className="min-w-[90px] text-center">
|
||||
검사유형
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
검사수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
불합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-right">
|
||||
불량율
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[160px] text-center">
|
||||
검사결과
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-center">
|
||||
판정
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-center">
|
||||
검사자
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
||||
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
||||
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
||||
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
||||
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
||||
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
||||
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
||||
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
||||
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
||||
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
||||
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
||||
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
||||
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
||||
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.map((row) => {
|
||||
const goodPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.goodQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const defectPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.defectQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
||||
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.no}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
||||
>
|
||||
<TableCell className="text-center text-sm text-gray-500">
|
||||
{row.no}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{row.inspectionNo}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("type", row.inspectionType),
|
||||
)}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-medium">
|
||||
{row.itemName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.spec}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{fmt(row.inspectionQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-emerald-600">
|
||||
{fmt(row.goodQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-red-600">
|
||||
{fmt(row.defectQty)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right text-sm",
|
||||
badgeVariant("defectRate", row.defectRate),
|
||||
)}
|
||||
>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
{/* 검사결과 프로그레스바 */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-red-500 transition-all"
|
||||
style={{ width: `${defectPct}%` }}
|
||||
/>
|
||||
<TableRow key={row.no} className={theme.tableRowHover}>
|
||||
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
||||
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
||||
{tc.inspectionType && (
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
||||
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
||||
{tc.inspectionQty && (
|
||||
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
||||
)}
|
||||
{tc.passFailQty && (
|
||||
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
||||
)}
|
||||
{tc.passFailQty && (
|
||||
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
||||
)}
|
||||
{tc.defectRate && (
|
||||
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.resultBar && (
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
||||
</div>
|
||||
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* 판정 배지 */}
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("result", row.result),
|
||||
)}
|
||||
>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{row.inspectorName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString(
|
||||
"ko-KR",
|
||||
{
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.judgment && (
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
||||
{tc.inspectedAt && (
|
||||
<TableCell className={cn("text-sm", theme.mutedText)}>
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
},
|
||||
)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-400">
|
||||
{row.remark || "-"}
|
||||
</TableCell>
|
||||
})
|
||||
: "-"}
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -12,16 +12,10 @@ import { apiClient } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Wrench,
|
||||
Zap,
|
||||
Pause,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
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";
|
||||
|
||||
/* ───── 상태 정의 ───── */
|
||||
|
||||
@@ -134,11 +128,16 @@ interface WorkInstruction {
|
||||
/* ───── 컴포넌트 ───── */
|
||||
|
||||
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(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||
const autoRefreshRef = useRef(autoRefresh);
|
||||
|
||||
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
/* ── 자동 갱신 (30초) ── */
|
||||
/* ── 자동 갱신 ── */
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (autoRefreshRef.current) fetchData();
|
||||
}, 30000);
|
||||
}, settings.refreshInterval * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
}, [fetchData, settings.refreshInterval]);
|
||||
|
||||
/* ── 요약 통계 ── */
|
||||
const stats = useMemo(() => {
|
||||
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
|
||||
|
||||
/* ── 필터 pill ── */
|
||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
||||
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-300 hover:bg-blue-500/30" },
|
||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30" },
|
||||
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-300 hover:bg-amber-500/30" },
|
||||
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-300 hover:bg-red-500/30" },
|
||||
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-300 hover:bg-gray-500/30" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
/* ── 포맷 ── */
|
||||
@@ -309,20 +308,26 @@ export default function EquipmentMonitoringPage() {
|
||||
/* ────────────── 렌더 ────────────── */
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
|
||||
<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="text-2xl font-bold tracking-tight">설비운영모니터링</h1>
|
||||
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}>설비운영모니터링</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 현재 시간 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 bg-gray-800/60 rounded-lg px-3 py-1.5 border border-gray-700/50">
|
||||
<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="font-mono text-white">{formatTime(currentTime)}</span>
|
||||
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
|
||||
{/* 자동갱신 토글 */}
|
||||
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"border-gray-700 text-xs gap-1.5",
|
||||
"gap-1.5 text-xs",
|
||||
autoRefresh
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
||||
? "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 ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
|
||||
<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="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── 요약 카드 5개 ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<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))
|
||||
}
|
||||
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"
|
||||
"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-xs text-gray-500">{card.label}</p>
|
||||
<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>
|
||||
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||
filterStatus === pill.value
|
||||
? cn(pill.color, "ring-1 ring-white/20")
|
||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
||||
? 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="ml-auto text-sm text-gray-600 self-center">
|
||||
{filteredEquipments.length}대 표시
|
||||
</span>
|
||||
<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="h-8 w-8 animate-spin text-gray-600" />
|
||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
||||
<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="flex flex-col items-center justify-center py-20 text-gray-600">
|
||||
<Inbox className="h-12 w-12 mb-3" />
|
||||
<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))" }}
|
||||
>
|
||||
<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];
|
||||
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
|
||||
<div
|
||||
key={eq.id}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
|
||||
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
|
||||
cfg.border,
|
||||
cfg.cardGlow
|
||||
cfg.cardGlow,
|
||||
)}
|
||||
>
|
||||
{/* 좌측 색상 바 */}
|
||||
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
|
||||
<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">
|
||||
<h3 className="text-base font-semibold text-white truncate">
|
||||
{eq.equipment_name || "이름 없음"}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
||||
{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>
|
||||
<Badge
|
||||
className={cn(
|
||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
||||
cfg.badgeBg,
|
||||
cfg.badgeText
|
||||
)}
|
||||
>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 정보 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">생산수량</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">작업자</span>
|
||||
<p className="text-white font-medium">
|
||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
||||
? eqWIs[0].worker_name
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">설비코드</span>
|
||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 가동률 프로그레스 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500">가동률</span>
|
||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
||||
{utilization !== null ? `${utilization}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
||||
{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-gray-800/80" />
|
||||
|
||||
{/* 현재 작업지시 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</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="text-blue-400 font-mono text-xs shrink-0">
|
||||
{wi.instruction_number || "-"}
|
||||
</span>
|
||||
<span className="text-gray-300 truncate">
|
||||
{wi.item_name || "-"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{eqWIs.length > 2 && (
|
||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
||||
{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-gray-800/80" />
|
||||
<div className="mx-4 ml-5 border-t border-border" />
|
||||
|
||||
{/* 센서 데이터 (PLC 미연동) */}
|
||||
<div className="flex items-center gap-4 px-4 pl-5 py-2.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">온도</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">압력</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">RPM</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
{/* 정보 그리드 */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
|
||||
|
||||
{/* 필터 결과 없음 */}
|
||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
||||
<Inbox className="h-10 w-10 mb-2" />
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,10 @@ 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 type { ProductionDisplayFields } from "@/types/monitoringSettings";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
@@ -16,6 +20,7 @@ import {
|
||||
TrendingUp,
|
||||
Play,
|
||||
Pause,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
|
||||
}
|
||||
|
||||
// 작업지시별 공정현황으로 진행상태 계산
|
||||
function computeProgress(
|
||||
wiId: string,
|
||||
processMap: Map<string, ProcessStep[]>
|
||||
): "대기" | "진행중" | "완료" {
|
||||
function computeProgress(wiId: string, processMap: Map<string, ProcessStep[]>): "대기" | "진행중" | "완료" {
|
||||
const steps = processMap.get(wiId);
|
||||
if (!steps || steps.length === 0) return "대기";
|
||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||
@@ -85,11 +87,15 @@ function computeProgress(
|
||||
|
||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||
export default function ProductionMonitoringPage() {
|
||||
const { settings } = useMonitoringSettings("production");
|
||||
const theme = getMonitoringTheme(settings.theme);
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
|
||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||
|
||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// 작업지시 목록 조회
|
||||
const wiRes = await apiClient.get("/work-instruction/list");
|
||||
const wiRaw: WorkInstruction[] =
|
||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||
const seen = new Set<string>();
|
||||
const wiData = wiRaw.filter((wi) => {
|
||||
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
|
||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||
try {
|
||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
||||
page: 1, size: 1000, autoFilter: true,
|
||||
page: 1,
|
||||
size: 1000,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||
|
||||
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
||||
// ─── 자동갱신 ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const timer = setInterval(fetchData, 30000);
|
||||
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRefresh, fetchData]);
|
||||
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||
|
||||
// ─── 통계 계산 ───────────────────────────────────────────
|
||||
const stats = useMemo(() => {
|
||||
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// ─── 렌더링 ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
|
||||
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
||||
<div className="flex flex-shrink-0 items-center justify-between">
|
||||
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
||||
<Clock className="w-4 h-4" />
|
||||
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
||||
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||
<SummaryCard
|
||||
icon={<Timer className="w-5 h-5" />}
|
||||
icon={<Timer className="h-5 w-5" />}
|
||||
label="대기중"
|
||||
value={stats.waiting}
|
||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<Loader2 className="w-5 h-5" />}
|
||||
icon={<Loader2 className="h-5 w-5" />}
|
||||
label="진행중"
|
||||
value={stats.inProgress}
|
||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
||||
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||
label="완료"
|
||||
value={stats.completed}
|
||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
label="달성율"
|
||||
value={`${stats.achievementRate}%`}
|
||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 탭 필터 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"min-w-[64px]",
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{loading && workInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
|
||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{!loading && filteredInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Inbox className="w-12 h-12 mb-3" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Inbox className="mb-3 h-12 w-12" />
|
||||
<span className="text-sm">
|
||||
{activeTab === "전체"
|
||||
? "등록된 작업지시가 없습니다."
|
||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 작업 카드 그리드 */}
|
||||
{/* 작업 카드 */}
|
||||
{filteredInstructions.length > 0 && (
|
||||
<div
|
||||
className="grid gap-4 flex-1"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
||||
className={cn(
|
||||
"flex-1 gap-4",
|
||||
settings.layout === "grid" && "grid",
|
||||
settings.layout === "list" && "flex flex-col",
|
||||
settings.layout === "split" && "grid grid-cols-2",
|
||||
)}
|
||||
style={
|
||||
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
|
||||
}
|
||||
>
|
||||
{filteredInstructions.map((wi, idx) => (
|
||||
<WorkCard
|
||||
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
|
||||
instruction={wi}
|
||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||
displayFields={settings.displayFields}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -338,11 +354,11 @@ function SummaryCard({
|
||||
colorClass: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
||||
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
|
||||
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<span className="text-foreground text-2xl font-bold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -353,10 +369,12 @@ function WorkCard({
|
||||
instruction: wi,
|
||||
steps,
|
||||
progress,
|
||||
displayFields: df,
|
||||
}: {
|
||||
instruction: WorkInstruction;
|
||||
steps: ProcessStep[];
|
||||
progress: "대기" | "진행중" | "완료";
|
||||
displayFields: ProductionDisplayFields;
|
||||
}) {
|
||||
// API 응답은 flat 구조 (details 배열 아님)
|
||||
const itemName = (wi as any).item_name || "-";
|
||||
@@ -373,36 +391,28 @@ function WorkCard({
|
||||
const currentStep = steps.find((s) => s.status !== "completed");
|
||||
|
||||
// 프로그레스바 색상
|
||||
const barColor =
|
||||
progressPercent >= 100
|
||||
? "bg-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "bg-blue-500"
|
||||
: "bg-amber-500";
|
||||
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
|
||||
|
||||
// 상태 배지 스타일
|
||||
const statusBadge: Record<string, string> = {
|
||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
"진행중": "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
대기: "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
진행중: "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
완료: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
};
|
||||
|
||||
const isUrgent = wi.status === "긴급";
|
||||
|
||||
return (
|
||||
<div className="bg-card border rounded-lg overflow-hidden flex flex-col">
|
||||
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
|
||||
{/* 카드 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">
|
||||
{wi.work_instruction_no}
|
||||
</span>
|
||||
{isUrgent && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{df.workInstructionNo && (
|
||||
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
|
||||
)}
|
||||
{df.priority && isUrgent && (
|
||||
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
긴급
|
||||
</Badge>
|
||||
)}
|
||||
@@ -413,82 +423,88 @@ function WorkCard({
|
||||
</div>
|
||||
|
||||
{/* 카드 본문 - 정보 */}
|
||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
||||
<InfoRow label="품목명" value={itemName} />
|
||||
<InfoRow label="규격" value={spec} />
|
||||
<InfoRow label="거래처" value={customerName} />
|
||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
|
||||
{df.itemName && <InfoRow label="품목명" value={itemName} />}
|
||||
{df.spec && <InfoRow label="규격" value={spec} />}
|
||||
{df.customerName && <InfoRow label="거래처" value={customerName} />}
|
||||
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
|
||||
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
|
||||
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
|
||||
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
|
||||
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
|
||||
</div>
|
||||
|
||||
{/* 공정현황 */}
|
||||
<div className="px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{df.processProgress && (
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "bg-blue-500/20 text-blue-400 animate-pulse",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 프로그레스바 */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500"
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
{df.progressBar && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500",
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted h-2.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -496,7 +512,7 @@ function WorkCard({
|
||||
// ─── 정보 행 ───────────────────────────────────────────────
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="text-muted-foreground shrink-0">{label}:</span>
|
||||
<span className="text-foreground truncate">{value}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,23 +4,12 @@ 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Search,
|
||||
ClipboardCheck,
|
||||
} from "lucide-react";
|
||||
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
||||
|
||||
/* ───── 타입 ───── */
|
||||
interface ProcessRow {
|
||||
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
|
||||
|
||||
/* ───── 유틸 ───── */
|
||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||
const pct = (n: number) =>
|
||||
`${n.toFixed(1)}%`;
|
||||
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||
|
||||
const badgeVariant = (
|
||||
type: "result" | "type" | "defectRate",
|
||||
value: string | number,
|
||||
) => {
|
||||
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
|
||||
if (type === "result") {
|
||||
if (value === "합격")
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||
}
|
||||
if (type === "type") {
|
||||
if (value === "공정검사")
|
||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
}
|
||||
@@ -93,10 +76,15 @@ const badgeVariant = (
|
||||
|
||||
/* ───── 컴포넌트 ───── */
|
||||
export default function QualityMonitoringPage() {
|
||||
const { settings } = useMonitoringSettings("quality");
|
||||
const theme = getMonitoringTheme(settings.theme);
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
const tc = settings.tableColumns;
|
||||
|
||||
const [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
"/table-management/tables/work_order_process/data",
|
||||
{ autoFilter: true },
|
||||
);
|
||||
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||
setProcessData(rows);
|
||||
} catch (err) {
|
||||
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
|
||||
/* ───── 자동 갱신 ───── */
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(fetchData, 30_000);
|
||||
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [autoRefresh, fetchData]);
|
||||
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||
|
||||
/* ───── 검사 행 변환 ───── */
|
||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
|
||||
const goodQty = r.good_qty ?? 0;
|
||||
const defectQty = r.defect_qty ?? 0;
|
||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||
const result: InspectionRow["result"] =
|
||||
r.status !== "completed"
|
||||
? "대기"
|
||||
: defectQty > 0
|
||||
? "불합격"
|
||||
: "합격";
|
||||
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
|
||||
|
||||
return {
|
||||
no: idx + 1,
|
||||
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
|
||||
|
||||
/* ───── 렌더링 ───── */
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
|
||||
<div className={cn("flex items-center justify-between border-b px-6 py-4", theme.header)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
품질점검현황{" "}
|
||||
<span className="text-emerald-600">모니터링</span>
|
||||
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
||||
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className={cn("flex items-center gap-2 text-sm", theme.mutedText)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">
|
||||
{currentTime.toLocaleString("ko-KR", {
|
||||
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
<span className="ml-1">새로고침</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh((p) => !p)}
|
||||
className={cn(
|
||||
autoRefresh &&
|
||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
||||
)}
|
||||
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
<Clock className="mr-1 h-4 w-4" />
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 본문 ── */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||
<div className="flex-1 space-y-6 overflow-auto p-6">
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{summaryCards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className={cn(
|
||||
"rounded-xl bg-gradient-to-br p-5 shadow-md",
|
||||
card.color,
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-white/80">
|
||||
{card.label}
|
||||
</p>
|
||||
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||
{card.value}
|
||||
{card.sub && (
|
||||
<span className="ml-1 text-base font-normal text-white/70">
|
||||
{card.sub}
|
||||
</span>
|
||||
)}
|
||||
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
activeTab === tab.key
|
||||
? "bg-emerald-600 text-white shadow"
|
||||
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border",
|
||||
: "border bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
|
||||
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
|
||||
{/* 입고/출하 준비중 */}
|
||||
{(activeTab === "incoming" || activeTab === "shipping") ? (
|
||||
{activeTab === "incoming" || activeTab === "shipping" ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Search className="h-12 w-12 mb-4 opacity-40" />
|
||||
<Search className="mb-4 h-12 w-12 opacity-40" />
|
||||
<p className="text-lg font-medium">준비중</p>
|
||||
<p className="text-sm mt-1">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
||||
아직 지원되지 않습니다.
|
||||
<p className="mt-1 text-sm">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : loading && filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Loader2 className="h-10 w-10 animate-spin mb-4" />
|
||||
<Loader2 className="mb-4 h-10 w-10 animate-spin" />
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Inbox className="h-12 w-12 mb-4 opacity-40" />
|
||||
<Inbox className="mb-4 h-12 w-12 opacity-40" />
|
||||
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<TableRow className={theme.tableHeader}>
|
||||
<TableHead className="w-[50px] text-center">No</TableHead>
|
||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||
<TableHead className="min-w-[90px] text-center">
|
||||
검사유형
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
검사수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
불합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-right">
|
||||
불량율
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[160px] text-center">
|
||||
검사결과
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-center">
|
||||
판정
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-center">
|
||||
검사자
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
||||
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
||||
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
||||
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
||||
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
||||
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
||||
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
||||
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
||||
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
||||
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
||||
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
||||
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
||||
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
||||
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.map((row) => {
|
||||
const goodPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.goodQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const defectPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.defectQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
||||
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.no}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
||||
>
|
||||
<TableCell className="text-center text-sm text-gray-500">
|
||||
{row.no}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{row.inspectionNo}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("type", row.inspectionType),
|
||||
)}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-medium">
|
||||
{row.itemName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.spec}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{fmt(row.inspectionQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-emerald-600">
|
||||
{fmt(row.goodQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-red-600">
|
||||
{fmt(row.defectQty)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right text-sm",
|
||||
badgeVariant("defectRate", row.defectRate),
|
||||
)}
|
||||
>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
{/* 검사결과 프로그레스바 */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-red-500 transition-all"
|
||||
style={{ width: `${defectPct}%` }}
|
||||
/>
|
||||
<TableRow key={row.no} className={theme.tableRowHover}>
|
||||
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
||||
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
||||
{tc.inspectionType && (
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
||||
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
||||
{tc.inspectionQty && (
|
||||
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
||||
)}
|
||||
{tc.passFailQty && (
|
||||
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
||||
)}
|
||||
{tc.passFailQty && (
|
||||
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
||||
)}
|
||||
{tc.defectRate && (
|
||||
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.resultBar && (
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
||||
</div>
|
||||
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* 판정 배지 */}
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("result", row.result),
|
||||
)}
|
||||
>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{row.inspectorName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString(
|
||||
"ko-KR",
|
||||
{
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.judgment && (
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
||||
{tc.inspectedAt && (
|
||||
<TableCell className={cn("text-sm", theme.mutedText)}>
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
},
|
||||
)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-400">
|
||||
{row.remark || "-"}
|
||||
</TableCell>
|
||||
})
|
||||
: "-"}
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -12,16 +12,10 @@ import { apiClient } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Wrench,
|
||||
Zap,
|
||||
Pause,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
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";
|
||||
|
||||
/* ───── 상태 정의 ───── */
|
||||
|
||||
@@ -134,11 +128,16 @@ interface WorkInstruction {
|
||||
/* ───── 컴포넌트 ───── */
|
||||
|
||||
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(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||
const autoRefreshRef = useRef(autoRefresh);
|
||||
|
||||
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
/* ── 자동 갱신 (30초) ── */
|
||||
/* ── 자동 갱신 ── */
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (autoRefreshRef.current) fetchData();
|
||||
}, 30000);
|
||||
}, settings.refreshInterval * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
}, [fetchData, settings.refreshInterval]);
|
||||
|
||||
/* ── 요약 통계 ── */
|
||||
const stats = useMemo(() => {
|
||||
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
|
||||
|
||||
/* ── 필터 pill ── */
|
||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
||||
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-300 hover:bg-blue-500/30" },
|
||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30" },
|
||||
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-300 hover:bg-amber-500/30" },
|
||||
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-300 hover:bg-red-500/30" },
|
||||
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-300 hover:bg-gray-500/30" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
/* ── 포맷 ── */
|
||||
@@ -309,20 +308,26 @@ export default function EquipmentMonitoringPage() {
|
||||
/* ────────────── 렌더 ────────────── */
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
|
||||
<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="text-2xl font-bold tracking-tight">설비운영모니터링</h1>
|
||||
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}>설비운영모니터링</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 현재 시간 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 bg-gray-800/60 rounded-lg px-3 py-1.5 border border-gray-700/50">
|
||||
<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="font-mono text-white">{formatTime(currentTime)}</span>
|
||||
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
|
||||
{/* 자동갱신 토글 */}
|
||||
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"border-gray-700 text-xs gap-1.5",
|
||||
"gap-1.5 text-xs",
|
||||
autoRefresh
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
||||
? "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 ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
|
||||
<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="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── 요약 카드 5개 ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<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))
|
||||
}
|
||||
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"
|
||||
"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-xs text-gray-500">{card.label}</p>
|
||||
<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>
|
||||
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||
filterStatus === pill.value
|
||||
? cn(pill.color, "ring-1 ring-white/20")
|
||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
||||
? 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="ml-auto text-sm text-gray-600 self-center">
|
||||
{filteredEquipments.length}대 표시
|
||||
</span>
|
||||
<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="h-8 w-8 animate-spin text-gray-600" />
|
||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
||||
<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="flex flex-col items-center justify-center py-20 text-gray-600">
|
||||
<Inbox className="h-12 w-12 mb-3" />
|
||||
<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))" }}
|
||||
>
|
||||
<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];
|
||||
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
|
||||
<div
|
||||
key={eq.id}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
|
||||
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
|
||||
cfg.border,
|
||||
cfg.cardGlow
|
||||
cfg.cardGlow,
|
||||
)}
|
||||
>
|
||||
{/* 좌측 색상 바 */}
|
||||
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
|
||||
<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">
|
||||
<h3 className="text-base font-semibold text-white truncate">
|
||||
{eq.equipment_name || "이름 없음"}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
||||
{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>
|
||||
<Badge
|
||||
className={cn(
|
||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
||||
cfg.badgeBg,
|
||||
cfg.badgeText
|
||||
)}
|
||||
>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 정보 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">생산수량</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">작업자</span>
|
||||
<p className="text-white font-medium">
|
||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
||||
? eqWIs[0].worker_name
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">설비코드</span>
|
||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 가동률 프로그레스 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500">가동률</span>
|
||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
||||
{utilization !== null ? `${utilization}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
||||
{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-gray-800/80" />
|
||||
|
||||
{/* 현재 작업지시 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</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="text-blue-400 font-mono text-xs shrink-0">
|
||||
{wi.instruction_number || "-"}
|
||||
</span>
|
||||
<span className="text-gray-300 truncate">
|
||||
{wi.item_name || "-"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{eqWIs.length > 2 && (
|
||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
||||
{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-gray-800/80" />
|
||||
<div className="mx-4 ml-5 border-t border-border" />
|
||||
|
||||
{/* 센서 데이터 (PLC 미연동) */}
|
||||
<div className="flex items-center gap-4 px-4 pl-5 py-2.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">온도</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">압력</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">RPM</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
{/* 정보 그리드 */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
|
||||
|
||||
{/* 필터 결과 없음 */}
|
||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
||||
<Inbox className="h-10 w-10 mb-2" />
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,10 @@ 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 type { ProductionDisplayFields } from "@/types/monitoringSettings";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
@@ -16,6 +20,7 @@ import {
|
||||
TrendingUp,
|
||||
Play,
|
||||
Pause,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
|
||||
}
|
||||
|
||||
// 작업지시별 공정현황으로 진행상태 계산
|
||||
function computeProgress(
|
||||
wiId: string,
|
||||
processMap: Map<string, ProcessStep[]>
|
||||
): "대기" | "진행중" | "완료" {
|
||||
function computeProgress(wiId: string, processMap: Map<string, ProcessStep[]>): "대기" | "진행중" | "완료" {
|
||||
const steps = processMap.get(wiId);
|
||||
if (!steps || steps.length === 0) return "대기";
|
||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||
@@ -85,11 +87,15 @@ function computeProgress(
|
||||
|
||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||
export default function ProductionMonitoringPage() {
|
||||
const { settings } = useMonitoringSettings("production");
|
||||
const theme = getMonitoringTheme(settings.theme);
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
|
||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||
|
||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// 작업지시 목록 조회
|
||||
const wiRes = await apiClient.get("/work-instruction/list");
|
||||
const wiRaw: WorkInstruction[] =
|
||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||
const seen = new Set<string>();
|
||||
const wiData = wiRaw.filter((wi) => {
|
||||
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
|
||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||
try {
|
||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
||||
page: 1, size: 1000, autoFilter: true,
|
||||
page: 1,
|
||||
size: 1000,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||
|
||||
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
||||
// ─── 자동갱신 ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const timer = setInterval(fetchData, 30000);
|
||||
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRefresh, fetchData]);
|
||||
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||
|
||||
// ─── 통계 계산 ───────────────────────────────────────────
|
||||
const stats = useMemo(() => {
|
||||
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// ─── 렌더링 ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
|
||||
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
||||
<div className="flex flex-shrink-0 items-center justify-between">
|
||||
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
||||
<Clock className="w-4 h-4" />
|
||||
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
||||
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||
<SummaryCard
|
||||
icon={<Timer className="w-5 h-5" />}
|
||||
icon={<Timer className="h-5 w-5" />}
|
||||
label="대기중"
|
||||
value={stats.waiting}
|
||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<Loader2 className="w-5 h-5" />}
|
||||
icon={<Loader2 className="h-5 w-5" />}
|
||||
label="진행중"
|
||||
value={stats.inProgress}
|
||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
||||
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||
label="완료"
|
||||
value={stats.completed}
|
||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
label="달성율"
|
||||
value={`${stats.achievementRate}%`}
|
||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 탭 필터 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"min-w-[64px]",
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{loading && workInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
|
||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{!loading && filteredInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Inbox className="w-12 h-12 mb-3" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Inbox className="mb-3 h-12 w-12" />
|
||||
<span className="text-sm">
|
||||
{activeTab === "전체"
|
||||
? "등록된 작업지시가 없습니다."
|
||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 작업 카드 그리드 */}
|
||||
{/* 작업 카드 */}
|
||||
{filteredInstructions.length > 0 && (
|
||||
<div
|
||||
className="grid gap-4 flex-1"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
||||
className={cn(
|
||||
"flex-1 gap-4",
|
||||
settings.layout === "grid" && "grid",
|
||||
settings.layout === "list" && "flex flex-col",
|
||||
settings.layout === "split" && "grid grid-cols-2",
|
||||
)}
|
||||
style={
|
||||
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
|
||||
}
|
||||
>
|
||||
{filteredInstructions.map((wi, idx) => (
|
||||
<WorkCard
|
||||
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
|
||||
instruction={wi}
|
||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||
displayFields={settings.displayFields}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -338,11 +354,11 @@ function SummaryCard({
|
||||
colorClass: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
||||
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
|
||||
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<span className="text-foreground text-2xl font-bold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -353,10 +369,12 @@ function WorkCard({
|
||||
instruction: wi,
|
||||
steps,
|
||||
progress,
|
||||
displayFields: df,
|
||||
}: {
|
||||
instruction: WorkInstruction;
|
||||
steps: ProcessStep[];
|
||||
progress: "대기" | "진행중" | "완료";
|
||||
displayFields: ProductionDisplayFields;
|
||||
}) {
|
||||
// API 응답은 flat 구조 (details 배열 아님)
|
||||
const itemName = (wi as any).item_name || "-";
|
||||
@@ -373,36 +391,28 @@ function WorkCard({
|
||||
const currentStep = steps.find((s) => s.status !== "completed");
|
||||
|
||||
// 프로그레스바 색상
|
||||
const barColor =
|
||||
progressPercent >= 100
|
||||
? "bg-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "bg-blue-500"
|
||||
: "bg-amber-500";
|
||||
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
|
||||
|
||||
// 상태 배지 스타일
|
||||
const statusBadge: Record<string, string> = {
|
||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
"진행중": "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
대기: "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
진행중: "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
완료: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
};
|
||||
|
||||
const isUrgent = wi.status === "긴급";
|
||||
|
||||
return (
|
||||
<div className="bg-card border rounded-lg overflow-hidden flex flex-col">
|
||||
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
|
||||
{/* 카드 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">
|
||||
{wi.work_instruction_no}
|
||||
</span>
|
||||
{isUrgent && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{df.workInstructionNo && (
|
||||
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
|
||||
)}
|
||||
{df.priority && isUrgent && (
|
||||
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
긴급
|
||||
</Badge>
|
||||
)}
|
||||
@@ -413,82 +423,88 @@ function WorkCard({
|
||||
</div>
|
||||
|
||||
{/* 카드 본문 - 정보 */}
|
||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
||||
<InfoRow label="품목명" value={itemName} />
|
||||
<InfoRow label="규격" value={spec} />
|
||||
<InfoRow label="거래처" value={customerName} />
|
||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
|
||||
{df.itemName && <InfoRow label="품목명" value={itemName} />}
|
||||
{df.spec && <InfoRow label="규격" value={spec} />}
|
||||
{df.customerName && <InfoRow label="거래처" value={customerName} />}
|
||||
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
|
||||
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
|
||||
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
|
||||
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
|
||||
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
|
||||
</div>
|
||||
|
||||
{/* 공정현황 */}
|
||||
<div className="px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{df.processProgress && (
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "bg-blue-500/20 text-blue-400 animate-pulse",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 프로그레스바 */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500"
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
{df.progressBar && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500",
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted h-2.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -496,7 +512,7 @@ function WorkCard({
|
||||
// ─── 정보 행 ───────────────────────────────────────────────
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="text-muted-foreground shrink-0">{label}:</span>
|
||||
<span className="text-foreground truncate">{value}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,23 +4,12 @@ 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Search,
|
||||
ClipboardCheck,
|
||||
} from "lucide-react";
|
||||
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
||||
|
||||
/* ───── 타입 ───── */
|
||||
interface ProcessRow {
|
||||
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
|
||||
|
||||
/* ───── 유틸 ───── */
|
||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||
const pct = (n: number) =>
|
||||
`${n.toFixed(1)}%`;
|
||||
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||
|
||||
const badgeVariant = (
|
||||
type: "result" | "type" | "defectRate",
|
||||
value: string | number,
|
||||
) => {
|
||||
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
|
||||
if (type === "result") {
|
||||
if (value === "합격")
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||
}
|
||||
if (type === "type") {
|
||||
if (value === "공정검사")
|
||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
}
|
||||
@@ -93,10 +76,15 @@ const badgeVariant = (
|
||||
|
||||
/* ───── 컴포넌트 ───── */
|
||||
export default function QualityMonitoringPage() {
|
||||
const { settings } = useMonitoringSettings("quality");
|
||||
const theme = getMonitoringTheme(settings.theme);
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
const tc = settings.tableColumns;
|
||||
|
||||
const [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
"/table-management/tables/work_order_process/data",
|
||||
{ autoFilter: true },
|
||||
);
|
||||
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||
setProcessData(rows);
|
||||
} catch (err) {
|
||||
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
|
||||
/* ───── 자동 갱신 ───── */
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(fetchData, 30_000);
|
||||
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [autoRefresh, fetchData]);
|
||||
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||
|
||||
/* ───── 검사 행 변환 ───── */
|
||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
|
||||
const goodQty = r.good_qty ?? 0;
|
||||
const defectQty = r.defect_qty ?? 0;
|
||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||
const result: InspectionRow["result"] =
|
||||
r.status !== "completed"
|
||||
? "대기"
|
||||
: defectQty > 0
|
||||
? "불합격"
|
||||
: "합격";
|
||||
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
|
||||
|
||||
return {
|
||||
no: idx + 1,
|
||||
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
|
||||
|
||||
/* ───── 렌더링 ───── */
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
|
||||
<div className={cn("flex items-center justify-between border-b px-6 py-4", theme.header)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
품질점검현황{" "}
|
||||
<span className="text-emerald-600">모니터링</span>
|
||||
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
||||
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className={cn("flex items-center gap-2 text-sm", theme.mutedText)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">
|
||||
{currentTime.toLocaleString("ko-KR", {
|
||||
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
<span className="ml-1">새로고침</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh((p) => !p)}
|
||||
className={cn(
|
||||
autoRefresh &&
|
||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
||||
)}
|
||||
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
<Clock className="mr-1 h-4 w-4" />
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 본문 ── */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||
<div className="flex-1 space-y-6 overflow-auto p-6">
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{summaryCards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className={cn(
|
||||
"rounded-xl bg-gradient-to-br p-5 shadow-md",
|
||||
card.color,
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-white/80">
|
||||
{card.label}
|
||||
</p>
|
||||
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||
{card.value}
|
||||
{card.sub && (
|
||||
<span className="ml-1 text-base font-normal text-white/70">
|
||||
{card.sub}
|
||||
</span>
|
||||
)}
|
||||
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
activeTab === tab.key
|
||||
? "bg-emerald-600 text-white shadow"
|
||||
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border",
|
||||
: "border bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
|
||||
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
|
||||
{/* 입고/출하 준비중 */}
|
||||
{(activeTab === "incoming" || activeTab === "shipping") ? (
|
||||
{activeTab === "incoming" || activeTab === "shipping" ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Search className="h-12 w-12 mb-4 opacity-40" />
|
||||
<Search className="mb-4 h-12 w-12 opacity-40" />
|
||||
<p className="text-lg font-medium">준비중</p>
|
||||
<p className="text-sm mt-1">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
||||
아직 지원되지 않습니다.
|
||||
<p className="mt-1 text-sm">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : loading && filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Loader2 className="h-10 w-10 animate-spin mb-4" />
|
||||
<Loader2 className="mb-4 h-10 w-10 animate-spin" />
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Inbox className="h-12 w-12 mb-4 opacity-40" />
|
||||
<Inbox className="mb-4 h-12 w-12 opacity-40" />
|
||||
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<TableRow className={theme.tableHeader}>
|
||||
<TableHead className="w-[50px] text-center">No</TableHead>
|
||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||
<TableHead className="min-w-[90px] text-center">
|
||||
검사유형
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
검사수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
불합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-right">
|
||||
불량율
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[160px] text-center">
|
||||
검사결과
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-center">
|
||||
판정
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-center">
|
||||
검사자
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
||||
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
||||
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
||||
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
||||
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
||||
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
||||
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
||||
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
||||
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
||||
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
||||
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
||||
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
||||
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
||||
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.map((row) => {
|
||||
const goodPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.goodQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const defectPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.defectQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
||||
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.no}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
||||
>
|
||||
<TableCell className="text-center text-sm text-gray-500">
|
||||
{row.no}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{row.inspectionNo}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("type", row.inspectionType),
|
||||
)}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-medium">
|
||||
{row.itemName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.spec}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{fmt(row.inspectionQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-emerald-600">
|
||||
{fmt(row.goodQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-red-600">
|
||||
{fmt(row.defectQty)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right text-sm",
|
||||
badgeVariant("defectRate", row.defectRate),
|
||||
)}
|
||||
>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
{/* 검사결과 프로그레스바 */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-red-500 transition-all"
|
||||
style={{ width: `${defectPct}%` }}
|
||||
/>
|
||||
<TableRow key={row.no} className={theme.tableRowHover}>
|
||||
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
||||
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
||||
{tc.inspectionType && (
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
||||
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
||||
{tc.inspectionQty && (
|
||||
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
||||
)}
|
||||
{tc.passFailQty && (
|
||||
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
||||
)}
|
||||
{tc.passFailQty && (
|
||||
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
||||
)}
|
||||
{tc.defectRate && (
|
||||
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.resultBar && (
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
||||
</div>
|
||||
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* 판정 배지 */}
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("result", row.result),
|
||||
)}
|
||||
>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{row.inspectorName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString(
|
||||
"ko-KR",
|
||||
{
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.judgment && (
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
||||
{tc.inspectedAt && (
|
||||
<TableCell className={cn("text-sm", theme.mutedText)}>
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
},
|
||||
)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-400">
|
||||
{row.remark || "-"}
|
||||
</TableCell>
|
||||
})
|
||||
: "-"}
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -12,16 +12,10 @@ import { apiClient } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Wrench,
|
||||
Zap,
|
||||
Pause,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
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";
|
||||
|
||||
/* ───── 상태 정의 ───── */
|
||||
|
||||
@@ -134,11 +128,16 @@ interface WorkInstruction {
|
||||
/* ───── 컴포넌트 ───── */
|
||||
|
||||
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(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||
const autoRefreshRef = useRef(autoRefresh);
|
||||
|
||||
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
/* ── 자동 갱신 (30초) ── */
|
||||
/* ── 자동 갱신 ── */
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (autoRefreshRef.current) fetchData();
|
||||
}, 30000);
|
||||
}, settings.refreshInterval * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
}, [fetchData, settings.refreshInterval]);
|
||||
|
||||
/* ── 요약 통계 ── */
|
||||
const stats = useMemo(() => {
|
||||
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
|
||||
|
||||
/* ── 필터 pill ── */
|
||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
||||
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-300 hover:bg-blue-500/30" },
|
||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30" },
|
||||
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-300 hover:bg-amber-500/30" },
|
||||
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-300 hover:bg-red-500/30" },
|
||||
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-300 hover:bg-gray-500/30" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
/* ── 포맷 ── */
|
||||
@@ -309,20 +308,26 @@ export default function EquipmentMonitoringPage() {
|
||||
/* ────────────── 렌더 ────────────── */
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
|
||||
<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="text-2xl font-bold tracking-tight">설비운영모니터링</h1>
|
||||
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}>설비운영모니터링</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 현재 시간 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 bg-gray-800/60 rounded-lg px-3 py-1.5 border border-gray-700/50">
|
||||
<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="font-mono text-white">{formatTime(currentTime)}</span>
|
||||
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
|
||||
{/* 자동갱신 토글 */}
|
||||
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"border-gray-700 text-xs gap-1.5",
|
||||
"gap-1.5 text-xs",
|
||||
autoRefresh
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
||||
? "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 ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
|
||||
<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="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── 요약 카드 5개 ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<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))
|
||||
}
|
||||
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"
|
||||
"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-xs text-gray-500">{card.label}</p>
|
||||
<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>
|
||||
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||
filterStatus === pill.value
|
||||
? cn(pill.color, "ring-1 ring-white/20")
|
||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
||||
? 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="ml-auto text-sm text-gray-600 self-center">
|
||||
{filteredEquipments.length}대 표시
|
||||
</span>
|
||||
<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="h-8 w-8 animate-spin text-gray-600" />
|
||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
||||
<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="flex flex-col items-center justify-center py-20 text-gray-600">
|
||||
<Inbox className="h-12 w-12 mb-3" />
|
||||
<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))" }}
|
||||
>
|
||||
<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];
|
||||
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
|
||||
<div
|
||||
key={eq.id}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
|
||||
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
|
||||
cfg.border,
|
||||
cfg.cardGlow
|
||||
cfg.cardGlow,
|
||||
)}
|
||||
>
|
||||
{/* 좌측 색상 바 */}
|
||||
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
|
||||
<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">
|
||||
<h3 className="text-base font-semibold text-white truncate">
|
||||
{eq.equipment_name || "이름 없음"}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
||||
{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>
|
||||
<Badge
|
||||
className={cn(
|
||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
||||
cfg.badgeBg,
|
||||
cfg.badgeText
|
||||
)}
|
||||
>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 정보 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">생산수량</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">작업자</span>
|
||||
<p className="text-white font-medium">
|
||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
||||
? eqWIs[0].worker_name
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">설비코드</span>
|
||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 가동률 프로그레스 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500">가동률</span>
|
||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
||||
{utilization !== null ? `${utilization}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
||||
{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-gray-800/80" />
|
||||
|
||||
{/* 현재 작업지시 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</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="text-blue-400 font-mono text-xs shrink-0">
|
||||
{wi.instruction_number || "-"}
|
||||
</span>
|
||||
<span className="text-gray-300 truncate">
|
||||
{wi.item_name || "-"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{eqWIs.length > 2 && (
|
||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
||||
{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-gray-800/80" />
|
||||
<div className="mx-4 ml-5 border-t border-border" />
|
||||
|
||||
{/* 센서 데이터 (PLC 미연동) */}
|
||||
<div className="flex items-center gap-4 px-4 pl-5 py-2.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">온도</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">압력</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">RPM</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
{/* 정보 그리드 */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
|
||||
|
||||
{/* 필터 결과 없음 */}
|
||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
||||
<Inbox className="h-10 w-10 mb-2" />
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,10 @@ 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 type { ProductionDisplayFields } from "@/types/monitoringSettings";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
@@ -16,6 +20,7 @@ import {
|
||||
TrendingUp,
|
||||
Play,
|
||||
Pause,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
|
||||
}
|
||||
|
||||
// 작업지시별 공정현황으로 진행상태 계산
|
||||
function computeProgress(
|
||||
wiId: string,
|
||||
processMap: Map<string, ProcessStep[]>
|
||||
): "대기" | "진행중" | "완료" {
|
||||
function computeProgress(wiId: string, processMap: Map<string, ProcessStep[]>): "대기" | "진행중" | "완료" {
|
||||
const steps = processMap.get(wiId);
|
||||
if (!steps || steps.length === 0) return "대기";
|
||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||
@@ -85,11 +87,15 @@ function computeProgress(
|
||||
|
||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||
export default function ProductionMonitoringPage() {
|
||||
const { settings } = useMonitoringSettings("production");
|
||||
const theme = getMonitoringTheme(settings.theme);
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
|
||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||
|
||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// 작업지시 목록 조회
|
||||
const wiRes = await apiClient.get("/work-instruction/list");
|
||||
const wiRaw: WorkInstruction[] =
|
||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||
const seen = new Set<string>();
|
||||
const wiData = wiRaw.filter((wi) => {
|
||||
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
|
||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||
try {
|
||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
||||
page: 1, size: 1000, autoFilter: true,
|
||||
page: 1,
|
||||
size: 1000,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||
|
||||
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
||||
// ─── 자동갱신 ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const timer = setInterval(fetchData, 30000);
|
||||
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRefresh, fetchData]);
|
||||
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||
|
||||
// ─── 통계 계산 ───────────────────────────────────────────
|
||||
const stats = useMemo(() => {
|
||||
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// ─── 렌더링 ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
|
||||
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
||||
<div className="flex flex-shrink-0 items-center justify-between">
|
||||
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
||||
<Clock className="w-4 h-4" />
|
||||
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
||||
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||
<SummaryCard
|
||||
icon={<Timer className="w-5 h-5" />}
|
||||
icon={<Timer className="h-5 w-5" />}
|
||||
label="대기중"
|
||||
value={stats.waiting}
|
||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<Loader2 className="w-5 h-5" />}
|
||||
icon={<Loader2 className="h-5 w-5" />}
|
||||
label="진행중"
|
||||
value={stats.inProgress}
|
||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
||||
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||
label="완료"
|
||||
value={stats.completed}
|
||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
label="달성율"
|
||||
value={`${stats.achievementRate}%`}
|
||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 탭 필터 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"min-w-[64px]",
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{loading && workInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
|
||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{!loading && filteredInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Inbox className="w-12 h-12 mb-3" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Inbox className="mb-3 h-12 w-12" />
|
||||
<span className="text-sm">
|
||||
{activeTab === "전체"
|
||||
? "등록된 작업지시가 없습니다."
|
||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 작업 카드 그리드 */}
|
||||
{/* 작업 카드 */}
|
||||
{filteredInstructions.length > 0 && (
|
||||
<div
|
||||
className="grid gap-4 flex-1"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
||||
className={cn(
|
||||
"flex-1 gap-4",
|
||||
settings.layout === "grid" && "grid",
|
||||
settings.layout === "list" && "flex flex-col",
|
||||
settings.layout === "split" && "grid grid-cols-2",
|
||||
)}
|
||||
style={
|
||||
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
|
||||
}
|
||||
>
|
||||
{filteredInstructions.map((wi, idx) => (
|
||||
<WorkCard
|
||||
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
|
||||
instruction={wi}
|
||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||
displayFields={settings.displayFields}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -338,11 +354,11 @@ function SummaryCard({
|
||||
colorClass: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
||||
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
|
||||
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<span className="text-foreground text-2xl font-bold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -353,10 +369,12 @@ function WorkCard({
|
||||
instruction: wi,
|
||||
steps,
|
||||
progress,
|
||||
displayFields: df,
|
||||
}: {
|
||||
instruction: WorkInstruction;
|
||||
steps: ProcessStep[];
|
||||
progress: "대기" | "진행중" | "완료";
|
||||
displayFields: ProductionDisplayFields;
|
||||
}) {
|
||||
// API 응답은 flat 구조 (details 배열 아님)
|
||||
const itemName = (wi as any).item_name || "-";
|
||||
@@ -373,36 +391,28 @@ function WorkCard({
|
||||
const currentStep = steps.find((s) => s.status !== "completed");
|
||||
|
||||
// 프로그레스바 색상
|
||||
const barColor =
|
||||
progressPercent >= 100
|
||||
? "bg-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "bg-blue-500"
|
||||
: "bg-amber-500";
|
||||
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
|
||||
|
||||
// 상태 배지 스타일
|
||||
const statusBadge: Record<string, string> = {
|
||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
"진행중": "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
대기: "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
진행중: "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
완료: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
};
|
||||
|
||||
const isUrgent = wi.status === "긴급";
|
||||
|
||||
return (
|
||||
<div className="bg-card border rounded-lg overflow-hidden flex flex-col">
|
||||
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
|
||||
{/* 카드 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">
|
||||
{wi.work_instruction_no}
|
||||
</span>
|
||||
{isUrgent && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{df.workInstructionNo && (
|
||||
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
|
||||
)}
|
||||
{df.priority && isUrgent && (
|
||||
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
긴급
|
||||
</Badge>
|
||||
)}
|
||||
@@ -413,82 +423,88 @@ function WorkCard({
|
||||
</div>
|
||||
|
||||
{/* 카드 본문 - 정보 */}
|
||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
||||
<InfoRow label="품목명" value={itemName} />
|
||||
<InfoRow label="규격" value={spec} />
|
||||
<InfoRow label="거래처" value={customerName} />
|
||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
|
||||
{df.itemName && <InfoRow label="품목명" value={itemName} />}
|
||||
{df.spec && <InfoRow label="규격" value={spec} />}
|
||||
{df.customerName && <InfoRow label="거래처" value={customerName} />}
|
||||
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
|
||||
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
|
||||
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
|
||||
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
|
||||
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
|
||||
</div>
|
||||
|
||||
{/* 공정현황 */}
|
||||
<div className="px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{df.processProgress && (
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "bg-blue-500/20 text-blue-400 animate-pulse",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 프로그레스바 */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500"
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
{df.progressBar && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500",
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted h-2.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -496,7 +512,7 @@ function WorkCard({
|
||||
// ─── 정보 행 ───────────────────────────────────────────────
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="text-muted-foreground shrink-0">{label}:</span>
|
||||
<span className="text-foreground truncate">{value}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,23 +4,12 @@ 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Search,
|
||||
ClipboardCheck,
|
||||
} from "lucide-react";
|
||||
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
||||
|
||||
/* ───── 타입 ───── */
|
||||
interface ProcessRow {
|
||||
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
|
||||
|
||||
/* ───── 유틸 ───── */
|
||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||
const pct = (n: number) =>
|
||||
`${n.toFixed(1)}%`;
|
||||
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||
|
||||
const badgeVariant = (
|
||||
type: "result" | "type" | "defectRate",
|
||||
value: string | number,
|
||||
) => {
|
||||
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
|
||||
if (type === "result") {
|
||||
if (value === "합격")
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||
}
|
||||
if (type === "type") {
|
||||
if (value === "공정검사")
|
||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
}
|
||||
@@ -93,10 +76,15 @@ const badgeVariant = (
|
||||
|
||||
/* ───── 컴포넌트 ───── */
|
||||
export default function QualityMonitoringPage() {
|
||||
const { settings } = useMonitoringSettings("quality");
|
||||
const theme = getMonitoringTheme(settings.theme);
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
const tc = settings.tableColumns;
|
||||
|
||||
const [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
"/table-management/tables/work_order_process/data",
|
||||
{ autoFilter: true },
|
||||
);
|
||||
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||
setProcessData(rows);
|
||||
} catch (err) {
|
||||
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
|
||||
/* ───── 자동 갱신 ───── */
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(fetchData, 30_000);
|
||||
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [autoRefresh, fetchData]);
|
||||
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||
|
||||
/* ───── 검사 행 변환 ───── */
|
||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
|
||||
const goodQty = r.good_qty ?? 0;
|
||||
const defectQty = r.defect_qty ?? 0;
|
||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||
const result: InspectionRow["result"] =
|
||||
r.status !== "completed"
|
||||
? "대기"
|
||||
: defectQty > 0
|
||||
? "불합격"
|
||||
: "합격";
|
||||
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
|
||||
|
||||
return {
|
||||
no: idx + 1,
|
||||
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
|
||||
|
||||
/* ───── 렌더링 ───── */
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
|
||||
<div className={cn("flex items-center justify-between border-b px-6 py-4", theme.header)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
품질점검현황{" "}
|
||||
<span className="text-emerald-600">모니터링</span>
|
||||
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
||||
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className={cn("flex items-center gap-2 text-sm", theme.mutedText)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">
|
||||
{currentTime.toLocaleString("ko-KR", {
|
||||
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
<span className="ml-1">새로고침</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh((p) => !p)}
|
||||
className={cn(
|
||||
autoRefresh &&
|
||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
||||
)}
|
||||
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
<Clock className="mr-1 h-4 w-4" />
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 본문 ── */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||
<div className="flex-1 space-y-6 overflow-auto p-6">
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{summaryCards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className={cn(
|
||||
"rounded-xl bg-gradient-to-br p-5 shadow-md",
|
||||
card.color,
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-white/80">
|
||||
{card.label}
|
||||
</p>
|
||||
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||
{card.value}
|
||||
{card.sub && (
|
||||
<span className="ml-1 text-base font-normal text-white/70">
|
||||
{card.sub}
|
||||
</span>
|
||||
)}
|
||||
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
activeTab === tab.key
|
||||
? "bg-emerald-600 text-white shadow"
|
||||
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border",
|
||||
: "border bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
|
||||
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
|
||||
{/* 입고/출하 준비중 */}
|
||||
{(activeTab === "incoming" || activeTab === "shipping") ? (
|
||||
{activeTab === "incoming" || activeTab === "shipping" ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Search className="h-12 w-12 mb-4 opacity-40" />
|
||||
<Search className="mb-4 h-12 w-12 opacity-40" />
|
||||
<p className="text-lg font-medium">준비중</p>
|
||||
<p className="text-sm mt-1">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
||||
아직 지원되지 않습니다.
|
||||
<p className="mt-1 text-sm">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : loading && filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Loader2 className="h-10 w-10 animate-spin mb-4" />
|
||||
<Loader2 className="mb-4 h-10 w-10 animate-spin" />
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Inbox className="h-12 w-12 mb-4 opacity-40" />
|
||||
<Inbox className="mb-4 h-12 w-12 opacity-40" />
|
||||
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<TableRow className={theme.tableHeader}>
|
||||
<TableHead className="w-[50px] text-center">No</TableHead>
|
||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||
<TableHead className="min-w-[90px] text-center">
|
||||
검사유형
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
검사수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
불합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-right">
|
||||
불량율
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[160px] text-center">
|
||||
검사결과
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-center">
|
||||
판정
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-center">
|
||||
검사자
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
||||
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
||||
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
||||
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
||||
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
||||
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
||||
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
||||
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
||||
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
||||
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
||||
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
||||
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
||||
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
||||
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.map((row) => {
|
||||
const goodPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.goodQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const defectPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.defectQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
||||
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.no}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
||||
>
|
||||
<TableCell className="text-center text-sm text-gray-500">
|
||||
{row.no}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{row.inspectionNo}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("type", row.inspectionType),
|
||||
)}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-medium">
|
||||
{row.itemName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.spec}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{fmt(row.inspectionQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-emerald-600">
|
||||
{fmt(row.goodQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-red-600">
|
||||
{fmt(row.defectQty)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right text-sm",
|
||||
badgeVariant("defectRate", row.defectRate),
|
||||
)}
|
||||
>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
{/* 검사결과 프로그레스바 */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-red-500 transition-all"
|
||||
style={{ width: `${defectPct}%` }}
|
||||
/>
|
||||
<TableRow key={row.no} className={theme.tableRowHover}>
|
||||
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
||||
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
||||
{tc.inspectionType && (
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
||||
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
||||
{tc.inspectionQty && (
|
||||
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
||||
)}
|
||||
{tc.passFailQty && (
|
||||
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
||||
)}
|
||||
{tc.passFailQty && (
|
||||
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
||||
)}
|
||||
{tc.defectRate && (
|
||||
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.resultBar && (
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
||||
</div>
|
||||
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* 판정 배지 */}
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("result", row.result),
|
||||
)}
|
||||
>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{row.inspectorName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString(
|
||||
"ko-KR",
|
||||
{
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.judgment && (
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
||||
{tc.inspectedAt && (
|
||||
<TableCell className={cn("text-sm", theme.mutedText)}>
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
},
|
||||
)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-400">
|
||||
{row.remark || "-"}
|
||||
</TableCell>
|
||||
})
|
||||
: "-"}
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -12,16 +12,10 @@ import { apiClient } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Wrench,
|
||||
Zap,
|
||||
Pause,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
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";
|
||||
|
||||
/* ───── 상태 정의 ───── */
|
||||
|
||||
@@ -134,11 +128,16 @@ interface WorkInstruction {
|
||||
/* ───── 컴포넌트 ───── */
|
||||
|
||||
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(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||
const autoRefreshRef = useRef(autoRefresh);
|
||||
|
||||
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
/* ── 자동 갱신 (30초) ── */
|
||||
/* ── 자동 갱신 ── */
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (autoRefreshRef.current) fetchData();
|
||||
}, 30000);
|
||||
}, settings.refreshInterval * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
}, [fetchData, settings.refreshInterval]);
|
||||
|
||||
/* ── 요약 통계 ── */
|
||||
const stats = useMemo(() => {
|
||||
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
|
||||
|
||||
/* ── 필터 pill ── */
|
||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
||||
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-300 hover:bg-blue-500/30" },
|
||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30" },
|
||||
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-300 hover:bg-amber-500/30" },
|
||||
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-300 hover:bg-red-500/30" },
|
||||
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-300 hover:bg-gray-500/30" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
/* ── 포맷 ── */
|
||||
@@ -309,20 +308,26 @@ export default function EquipmentMonitoringPage() {
|
||||
/* ────────────── 렌더 ────────────── */
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
|
||||
<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="text-2xl font-bold tracking-tight">설비운영모니터링</h1>
|
||||
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}>설비운영모니터링</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 현재 시간 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 bg-gray-800/60 rounded-lg px-3 py-1.5 border border-gray-700/50">
|
||||
<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="font-mono text-white">{formatTime(currentTime)}</span>
|
||||
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
|
||||
{/* 자동갱신 토글 */}
|
||||
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"border-gray-700 text-xs gap-1.5",
|
||||
"gap-1.5 text-xs",
|
||||
autoRefresh
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
||||
? "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 ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
|
||||
<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="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── 요약 카드 5개 ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<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))
|
||||
}
|
||||
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"
|
||||
"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-xs text-gray-500">{card.label}</p>
|
||||
<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>
|
||||
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||
filterStatus === pill.value
|
||||
? cn(pill.color, "ring-1 ring-white/20")
|
||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
||||
? 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="ml-auto text-sm text-gray-600 self-center">
|
||||
{filteredEquipments.length}대 표시
|
||||
</span>
|
||||
<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="h-8 w-8 animate-spin text-gray-600" />
|
||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
||||
<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="flex flex-col items-center justify-center py-20 text-gray-600">
|
||||
<Inbox className="h-12 w-12 mb-3" />
|
||||
<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))" }}
|
||||
>
|
||||
<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];
|
||||
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
|
||||
<div
|
||||
key={eq.id}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
|
||||
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
|
||||
cfg.border,
|
||||
cfg.cardGlow
|
||||
cfg.cardGlow,
|
||||
)}
|
||||
>
|
||||
{/* 좌측 색상 바 */}
|
||||
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
|
||||
<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">
|
||||
<h3 className="text-base font-semibold text-white truncate">
|
||||
{eq.equipment_name || "이름 없음"}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
||||
{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>
|
||||
<Badge
|
||||
className={cn(
|
||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
||||
cfg.badgeBg,
|
||||
cfg.badgeText
|
||||
)}
|
||||
>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 정보 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">생산수량</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">작업자</span>
|
||||
<p className="text-white font-medium">
|
||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
||||
? eqWIs[0].worker_name
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">설비코드</span>
|
||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 가동률 프로그레스 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500">가동률</span>
|
||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
||||
{utilization !== null ? `${utilization}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
||||
{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-gray-800/80" />
|
||||
|
||||
{/* 현재 작업지시 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</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="text-blue-400 font-mono text-xs shrink-0">
|
||||
{wi.instruction_number || "-"}
|
||||
</span>
|
||||
<span className="text-gray-300 truncate">
|
||||
{wi.item_name || "-"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{eqWIs.length > 2 && (
|
||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
||||
{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-gray-800/80" />
|
||||
<div className="mx-4 ml-5 border-t border-border" />
|
||||
|
||||
{/* 센서 데이터 (PLC 미연동) */}
|
||||
<div className="flex items-center gap-4 px-4 pl-5 py-2.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">온도</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">압력</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">RPM</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
{/* 정보 그리드 */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
|
||||
|
||||
{/* 필터 결과 없음 */}
|
||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
||||
<Inbox className="h-10 w-10 mb-2" />
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,10 @@ 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 type { ProductionDisplayFields } from "@/types/monitoringSettings";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
@@ -16,6 +20,7 @@ import {
|
||||
TrendingUp,
|
||||
Play,
|
||||
Pause,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
|
||||
}
|
||||
|
||||
// 작업지시별 공정현황으로 진행상태 계산
|
||||
function computeProgress(
|
||||
wiId: string,
|
||||
processMap: Map<string, ProcessStep[]>
|
||||
): "대기" | "진행중" | "완료" {
|
||||
function computeProgress(wiId: string, processMap: Map<string, ProcessStep[]>): "대기" | "진행중" | "완료" {
|
||||
const steps = processMap.get(wiId);
|
||||
if (!steps || steps.length === 0) return "대기";
|
||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||
@@ -85,11 +87,15 @@ function computeProgress(
|
||||
|
||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||
export default function ProductionMonitoringPage() {
|
||||
const { settings } = useMonitoringSettings("production");
|
||||
const theme = getMonitoringTheme(settings.theme);
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
|
||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||
|
||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// 작업지시 목록 조회
|
||||
const wiRes = await apiClient.get("/work-instruction/list");
|
||||
const wiRaw: WorkInstruction[] =
|
||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||
const seen = new Set<string>();
|
||||
const wiData = wiRaw.filter((wi) => {
|
||||
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
|
||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||
try {
|
||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
||||
page: 1, size: 1000, autoFilter: true,
|
||||
page: 1,
|
||||
size: 1000,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||
|
||||
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
||||
// ─── 자동갱신 ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const timer = setInterval(fetchData, 30000);
|
||||
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRefresh, fetchData]);
|
||||
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||
|
||||
// ─── 통계 계산 ───────────────────────────────────────────
|
||||
const stats = useMemo(() => {
|
||||
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// ─── 렌더링 ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
|
||||
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
||||
<div className="flex flex-shrink-0 items-center justify-between">
|
||||
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
||||
<Clock className="w-4 h-4" />
|
||||
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
||||
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||
<SummaryCard
|
||||
icon={<Timer className="w-5 h-5" />}
|
||||
icon={<Timer className="h-5 w-5" />}
|
||||
label="대기중"
|
||||
value={stats.waiting}
|
||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<Loader2 className="w-5 h-5" />}
|
||||
icon={<Loader2 className="h-5 w-5" />}
|
||||
label="진행중"
|
||||
value={stats.inProgress}
|
||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
||||
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||
label="완료"
|
||||
value={stats.completed}
|
||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
label="달성율"
|
||||
value={`${stats.achievementRate}%`}
|
||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 탭 필터 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"min-w-[64px]",
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{loading && workInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
|
||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{!loading && filteredInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Inbox className="w-12 h-12 mb-3" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Inbox className="mb-3 h-12 w-12" />
|
||||
<span className="text-sm">
|
||||
{activeTab === "전체"
|
||||
? "등록된 작업지시가 없습니다."
|
||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 작업 카드 그리드 */}
|
||||
{/* 작업 카드 */}
|
||||
{filteredInstructions.length > 0 && (
|
||||
<div
|
||||
className="grid gap-4 flex-1"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
||||
className={cn(
|
||||
"flex-1 gap-4",
|
||||
settings.layout === "grid" && "grid",
|
||||
settings.layout === "list" && "flex flex-col",
|
||||
settings.layout === "split" && "grid grid-cols-2",
|
||||
)}
|
||||
style={
|
||||
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
|
||||
}
|
||||
>
|
||||
{filteredInstructions.map((wi, idx) => (
|
||||
<WorkCard
|
||||
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
|
||||
instruction={wi}
|
||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||
displayFields={settings.displayFields}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -338,11 +354,11 @@ function SummaryCard({
|
||||
colorClass: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
||||
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
|
||||
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<span className="text-foreground text-2xl font-bold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -353,10 +369,12 @@ function WorkCard({
|
||||
instruction: wi,
|
||||
steps,
|
||||
progress,
|
||||
displayFields: df,
|
||||
}: {
|
||||
instruction: WorkInstruction;
|
||||
steps: ProcessStep[];
|
||||
progress: "대기" | "진행중" | "완료";
|
||||
displayFields: ProductionDisplayFields;
|
||||
}) {
|
||||
// API 응답은 flat 구조 (details 배열 아님)
|
||||
const itemName = (wi as any).item_name || "-";
|
||||
@@ -373,36 +391,28 @@ function WorkCard({
|
||||
const currentStep = steps.find((s) => s.status !== "completed");
|
||||
|
||||
// 프로그레스바 색상
|
||||
const barColor =
|
||||
progressPercent >= 100
|
||||
? "bg-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "bg-blue-500"
|
||||
: "bg-amber-500";
|
||||
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
|
||||
|
||||
// 상태 배지 스타일
|
||||
const statusBadge: Record<string, string> = {
|
||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
"진행중": "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
대기: "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
진행중: "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
완료: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
};
|
||||
|
||||
const isUrgent = wi.status === "긴급";
|
||||
|
||||
return (
|
||||
<div className="bg-card border rounded-lg overflow-hidden flex flex-col">
|
||||
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
|
||||
{/* 카드 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">
|
||||
{wi.work_instruction_no}
|
||||
</span>
|
||||
{isUrgent && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{df.workInstructionNo && (
|
||||
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
|
||||
)}
|
||||
{df.priority && isUrgent && (
|
||||
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
긴급
|
||||
</Badge>
|
||||
)}
|
||||
@@ -413,82 +423,88 @@ function WorkCard({
|
||||
</div>
|
||||
|
||||
{/* 카드 본문 - 정보 */}
|
||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
||||
<InfoRow label="품목명" value={itemName} />
|
||||
<InfoRow label="규격" value={spec} />
|
||||
<InfoRow label="거래처" value={customerName} />
|
||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
|
||||
{df.itemName && <InfoRow label="품목명" value={itemName} />}
|
||||
{df.spec && <InfoRow label="규격" value={spec} />}
|
||||
{df.customerName && <InfoRow label="거래처" value={customerName} />}
|
||||
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
|
||||
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
|
||||
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
|
||||
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
|
||||
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
|
||||
</div>
|
||||
|
||||
{/* 공정현황 */}
|
||||
<div className="px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{df.processProgress && (
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "bg-blue-500/20 text-blue-400 animate-pulse",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 프로그레스바 */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500"
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
{df.progressBar && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500",
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted h-2.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -496,7 +512,7 @@ function WorkCard({
|
||||
// ─── 정보 행 ───────────────────────────────────────────────
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="text-muted-foreground shrink-0">{label}:</span>
|
||||
<span className="text-foreground truncate">{value}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,23 +4,12 @@ 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Search,
|
||||
ClipboardCheck,
|
||||
} from "lucide-react";
|
||||
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
||||
|
||||
/* ───── 타입 ───── */
|
||||
interface ProcessRow {
|
||||
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
|
||||
|
||||
/* ───── 유틸 ───── */
|
||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||
const pct = (n: number) =>
|
||||
`${n.toFixed(1)}%`;
|
||||
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||
|
||||
const badgeVariant = (
|
||||
type: "result" | "type" | "defectRate",
|
||||
value: string | number,
|
||||
) => {
|
||||
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
|
||||
if (type === "result") {
|
||||
if (value === "합격")
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||
}
|
||||
if (type === "type") {
|
||||
if (value === "공정검사")
|
||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
}
|
||||
@@ -93,10 +76,15 @@ const badgeVariant = (
|
||||
|
||||
/* ───── 컴포넌트 ───── */
|
||||
export default function QualityMonitoringPage() {
|
||||
const { settings } = useMonitoringSettings("quality");
|
||||
const theme = getMonitoringTheme(settings.theme);
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
const tc = settings.tableColumns;
|
||||
|
||||
const [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
"/table-management/tables/work_order_process/data",
|
||||
{ autoFilter: true },
|
||||
);
|
||||
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||
setProcessData(rows);
|
||||
} catch (err) {
|
||||
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
|
||||
/* ───── 자동 갱신 ───── */
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(fetchData, 30_000);
|
||||
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [autoRefresh, fetchData]);
|
||||
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||
|
||||
/* ───── 검사 행 변환 ───── */
|
||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
|
||||
const goodQty = r.good_qty ?? 0;
|
||||
const defectQty = r.defect_qty ?? 0;
|
||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||
const result: InspectionRow["result"] =
|
||||
r.status !== "completed"
|
||||
? "대기"
|
||||
: defectQty > 0
|
||||
? "불합격"
|
||||
: "합격";
|
||||
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
|
||||
|
||||
return {
|
||||
no: idx + 1,
|
||||
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
|
||||
|
||||
/* ───── 렌더링 ───── */
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
|
||||
<div className={cn("flex items-center justify-between border-b px-6 py-4", theme.header)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
품질점검현황{" "}
|
||||
<span className="text-emerald-600">모니터링</span>
|
||||
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
||||
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className={cn("flex items-center gap-2 text-sm", theme.mutedText)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">
|
||||
{currentTime.toLocaleString("ko-KR", {
|
||||
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
<span className="ml-1">새로고침</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh((p) => !p)}
|
||||
className={cn(
|
||||
autoRefresh &&
|
||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
||||
)}
|
||||
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
<Clock className="mr-1 h-4 w-4" />
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 본문 ── */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||
<div className="flex-1 space-y-6 overflow-auto p-6">
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{summaryCards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className={cn(
|
||||
"rounded-xl bg-gradient-to-br p-5 shadow-md",
|
||||
card.color,
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-white/80">
|
||||
{card.label}
|
||||
</p>
|
||||
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||
{card.value}
|
||||
{card.sub && (
|
||||
<span className="ml-1 text-base font-normal text-white/70">
|
||||
{card.sub}
|
||||
</span>
|
||||
)}
|
||||
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
activeTab === tab.key
|
||||
? "bg-emerald-600 text-white shadow"
|
||||
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border",
|
||||
: "border bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
|
||||
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
|
||||
{/* 입고/출하 준비중 */}
|
||||
{(activeTab === "incoming" || activeTab === "shipping") ? (
|
||||
{activeTab === "incoming" || activeTab === "shipping" ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Search className="h-12 w-12 mb-4 opacity-40" />
|
||||
<Search className="mb-4 h-12 w-12 opacity-40" />
|
||||
<p className="text-lg font-medium">준비중</p>
|
||||
<p className="text-sm mt-1">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
||||
아직 지원되지 않습니다.
|
||||
<p className="mt-1 text-sm">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : loading && filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Loader2 className="h-10 w-10 animate-spin mb-4" />
|
||||
<Loader2 className="mb-4 h-10 w-10 animate-spin" />
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Inbox className="h-12 w-12 mb-4 opacity-40" />
|
||||
<Inbox className="mb-4 h-12 w-12 opacity-40" />
|
||||
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<TableRow className={theme.tableHeader}>
|
||||
<TableHead className="w-[50px] text-center">No</TableHead>
|
||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||
<TableHead className="min-w-[90px] text-center">
|
||||
검사유형
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
검사수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
불합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-right">
|
||||
불량율
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[160px] text-center">
|
||||
검사결과
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-center">
|
||||
판정
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-center">
|
||||
검사자
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
||||
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
||||
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
||||
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
||||
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
||||
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
||||
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
||||
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
||||
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
||||
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
||||
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
||||
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
||||
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
||||
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.map((row) => {
|
||||
const goodPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.goodQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const defectPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.defectQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
||||
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.no}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
||||
>
|
||||
<TableCell className="text-center text-sm text-gray-500">
|
||||
{row.no}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{row.inspectionNo}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("type", row.inspectionType),
|
||||
)}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-medium">
|
||||
{row.itemName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.spec}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{fmt(row.inspectionQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-emerald-600">
|
||||
{fmt(row.goodQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-red-600">
|
||||
{fmt(row.defectQty)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right text-sm",
|
||||
badgeVariant("defectRate", row.defectRate),
|
||||
)}
|
||||
>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
{/* 검사결과 프로그레스바 */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-red-500 transition-all"
|
||||
style={{ width: `${defectPct}%` }}
|
||||
/>
|
||||
<TableRow key={row.no} className={theme.tableRowHover}>
|
||||
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
||||
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
||||
{tc.inspectionType && (
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
||||
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
||||
{tc.inspectionQty && (
|
||||
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
||||
)}
|
||||
{tc.passFailQty && (
|
||||
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
||||
)}
|
||||
{tc.passFailQty && (
|
||||
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
||||
)}
|
||||
{tc.defectRate && (
|
||||
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.resultBar && (
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
||||
</div>
|
||||
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* 판정 배지 */}
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("result", row.result),
|
||||
)}
|
||||
>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{row.inspectorName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString(
|
||||
"ko-KR",
|
||||
{
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.judgment && (
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
||||
{tc.inspectedAt && (
|
||||
<TableCell className={cn("text-sm", theme.mutedText)}>
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
},
|
||||
)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-400">
|
||||
{row.remark || "-"}
|
||||
</TableCell>
|
||||
})
|
||||
: "-"}
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -12,16 +12,10 @@ import { apiClient } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Wrench,
|
||||
Zap,
|
||||
Pause,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
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";
|
||||
|
||||
/* ───── 상태 정의 ───── */
|
||||
|
||||
@@ -134,11 +128,16 @@ interface WorkInstruction {
|
||||
/* ───── 컴포넌트 ───── */
|
||||
|
||||
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(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||
const autoRefreshRef = useRef(autoRefresh);
|
||||
|
||||
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
/* ── 자동 갱신 (30초) ── */
|
||||
/* ── 자동 갱신 ── */
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (autoRefreshRef.current) fetchData();
|
||||
}, 30000);
|
||||
}, settings.refreshInterval * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
}, [fetchData, settings.refreshInterval]);
|
||||
|
||||
/* ── 요약 통계 ── */
|
||||
const stats = useMemo(() => {
|
||||
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
|
||||
|
||||
/* ── 필터 pill ── */
|
||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
||||
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-300 hover:bg-blue-500/30" },
|
||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30" },
|
||||
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-300 hover:bg-amber-500/30" },
|
||||
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-300 hover:bg-red-500/30" },
|
||||
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-300 hover:bg-gray-500/30" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
/* ── 포맷 ── */
|
||||
@@ -309,20 +308,26 @@ export default function EquipmentMonitoringPage() {
|
||||
/* ────────────── 렌더 ────────────── */
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
|
||||
<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="text-2xl font-bold tracking-tight">설비운영모니터링</h1>
|
||||
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}>설비운영모니터링</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 현재 시간 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 bg-gray-800/60 rounded-lg px-3 py-1.5 border border-gray-700/50">
|
||||
<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="font-mono text-white">{formatTime(currentTime)}</span>
|
||||
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
|
||||
{/* 자동갱신 토글 */}
|
||||
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"border-gray-700 text-xs gap-1.5",
|
||||
"gap-1.5 text-xs",
|
||||
autoRefresh
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
||||
? "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 ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
|
||||
<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="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── 요약 카드 5개 ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<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))
|
||||
}
|
||||
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"
|
||||
"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-xs text-gray-500">{card.label}</p>
|
||||
<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>
|
||||
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||
filterStatus === pill.value
|
||||
? cn(pill.color, "ring-1 ring-white/20")
|
||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
||||
? 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="ml-auto text-sm text-gray-600 self-center">
|
||||
{filteredEquipments.length}대 표시
|
||||
</span>
|
||||
<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="h-8 w-8 animate-spin text-gray-600" />
|
||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
||||
<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="flex flex-col items-center justify-center py-20 text-gray-600">
|
||||
<Inbox className="h-12 w-12 mb-3" />
|
||||
<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))" }}
|
||||
>
|
||||
<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];
|
||||
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
|
||||
<div
|
||||
key={eq.id}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
|
||||
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
|
||||
cfg.border,
|
||||
cfg.cardGlow
|
||||
cfg.cardGlow,
|
||||
)}
|
||||
>
|
||||
{/* 좌측 색상 바 */}
|
||||
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
|
||||
<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">
|
||||
<h3 className="text-base font-semibold text-white truncate">
|
||||
{eq.equipment_name || "이름 없음"}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
||||
{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>
|
||||
<Badge
|
||||
className={cn(
|
||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
||||
cfg.badgeBg,
|
||||
cfg.badgeText
|
||||
)}
|
||||
>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 정보 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">생산수량</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">작업자</span>
|
||||
<p className="text-white font-medium">
|
||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
||||
? eqWIs[0].worker_name
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">설비코드</span>
|
||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 가동률 프로그레스 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500">가동률</span>
|
||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
||||
{utilization !== null ? `${utilization}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
||||
{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-gray-800/80" />
|
||||
|
||||
{/* 현재 작업지시 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</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="text-blue-400 font-mono text-xs shrink-0">
|
||||
{wi.instruction_number || "-"}
|
||||
</span>
|
||||
<span className="text-gray-300 truncate">
|
||||
{wi.item_name || "-"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{eqWIs.length > 2 && (
|
||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
||||
{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-gray-800/80" />
|
||||
<div className="mx-4 ml-5 border-t border-border" />
|
||||
|
||||
{/* 센서 데이터 (PLC 미연동) */}
|
||||
<div className="flex items-center gap-4 px-4 pl-5 py-2.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">온도</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">압력</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">RPM</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
{/* 정보 그리드 */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
|
||||
|
||||
{/* 필터 결과 없음 */}
|
||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
||||
<Inbox className="h-10 w-10 mb-2" />
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,10 @@ 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 type { ProductionDisplayFields } from "@/types/monitoringSettings";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
@@ -16,6 +20,7 @@ import {
|
||||
TrendingUp,
|
||||
Play,
|
||||
Pause,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
|
||||
}
|
||||
|
||||
// 작업지시별 공정현황으로 진행상태 계산
|
||||
function computeProgress(
|
||||
wiId: string,
|
||||
processMap: Map<string, ProcessStep[]>
|
||||
): "대기" | "진행중" | "완료" {
|
||||
function computeProgress(wiId: string, processMap: Map<string, ProcessStep[]>): "대기" | "진행중" | "완료" {
|
||||
const steps = processMap.get(wiId);
|
||||
if (!steps || steps.length === 0) return "대기";
|
||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||
@@ -85,11 +87,15 @@ function computeProgress(
|
||||
|
||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||
export default function ProductionMonitoringPage() {
|
||||
const { settings } = useMonitoringSettings("production");
|
||||
const theme = getMonitoringTheme(settings.theme);
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
|
||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||
|
||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// 작업지시 목록 조회
|
||||
const wiRes = await apiClient.get("/work-instruction/list");
|
||||
const wiRaw: WorkInstruction[] =
|
||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||
const seen = new Set<string>();
|
||||
const wiData = wiRaw.filter((wi) => {
|
||||
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
|
||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||
try {
|
||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
||||
page: 1, size: 1000, autoFilter: true,
|
||||
page: 1,
|
||||
size: 1000,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||
|
||||
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
||||
// ─── 자동갱신 ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const timer = setInterval(fetchData, 30000);
|
||||
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRefresh, fetchData]);
|
||||
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||
|
||||
// ─── 통계 계산 ───────────────────────────────────────────
|
||||
const stats = useMemo(() => {
|
||||
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// ─── 렌더링 ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
|
||||
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
||||
<div className="flex flex-shrink-0 items-center justify-between">
|
||||
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
||||
<Clock className="w-4 h-4" />
|
||||
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
||||
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||
<SummaryCard
|
||||
icon={<Timer className="w-5 h-5" />}
|
||||
icon={<Timer className="h-5 w-5" />}
|
||||
label="대기중"
|
||||
value={stats.waiting}
|
||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<Loader2 className="w-5 h-5" />}
|
||||
icon={<Loader2 className="h-5 w-5" />}
|
||||
label="진행중"
|
||||
value={stats.inProgress}
|
||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
||||
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||
label="완료"
|
||||
value={stats.completed}
|
||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
label="달성율"
|
||||
value={`${stats.achievementRate}%`}
|
||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 탭 필터 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"min-w-[64px]",
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{loading && workInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
|
||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{!loading && filteredInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Inbox className="w-12 h-12 mb-3" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Inbox className="mb-3 h-12 w-12" />
|
||||
<span className="text-sm">
|
||||
{activeTab === "전체"
|
||||
? "등록된 작업지시가 없습니다."
|
||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 작업 카드 그리드 */}
|
||||
{/* 작업 카드 */}
|
||||
{filteredInstructions.length > 0 && (
|
||||
<div
|
||||
className="grid gap-4 flex-1"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
||||
className={cn(
|
||||
"flex-1 gap-4",
|
||||
settings.layout === "grid" && "grid",
|
||||
settings.layout === "list" && "flex flex-col",
|
||||
settings.layout === "split" && "grid grid-cols-2",
|
||||
)}
|
||||
style={
|
||||
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
|
||||
}
|
||||
>
|
||||
{filteredInstructions.map((wi, idx) => (
|
||||
<WorkCard
|
||||
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
|
||||
instruction={wi}
|
||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||
displayFields={settings.displayFields}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -338,11 +354,11 @@ function SummaryCard({
|
||||
colorClass: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
||||
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
|
||||
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<span className="text-foreground text-2xl font-bold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -353,10 +369,12 @@ function WorkCard({
|
||||
instruction: wi,
|
||||
steps,
|
||||
progress,
|
||||
displayFields: df,
|
||||
}: {
|
||||
instruction: WorkInstruction;
|
||||
steps: ProcessStep[];
|
||||
progress: "대기" | "진행중" | "완료";
|
||||
displayFields: ProductionDisplayFields;
|
||||
}) {
|
||||
// API 응답은 flat 구조 (details 배열 아님)
|
||||
const itemName = (wi as any).item_name || "-";
|
||||
@@ -373,36 +391,28 @@ function WorkCard({
|
||||
const currentStep = steps.find((s) => s.status !== "completed");
|
||||
|
||||
// 프로그레스바 색상
|
||||
const barColor =
|
||||
progressPercent >= 100
|
||||
? "bg-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "bg-blue-500"
|
||||
: "bg-amber-500";
|
||||
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
|
||||
|
||||
// 상태 배지 스타일
|
||||
const statusBadge: Record<string, string> = {
|
||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
"진행중": "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
대기: "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
진행중: "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
완료: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
};
|
||||
|
||||
const isUrgent = wi.status === "긴급";
|
||||
|
||||
return (
|
||||
<div className="bg-card border rounded-lg overflow-hidden flex flex-col">
|
||||
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
|
||||
{/* 카드 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">
|
||||
{wi.work_instruction_no}
|
||||
</span>
|
||||
{isUrgent && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{df.workInstructionNo && (
|
||||
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
|
||||
)}
|
||||
{df.priority && isUrgent && (
|
||||
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
긴급
|
||||
</Badge>
|
||||
)}
|
||||
@@ -413,82 +423,88 @@ function WorkCard({
|
||||
</div>
|
||||
|
||||
{/* 카드 본문 - 정보 */}
|
||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
||||
<InfoRow label="품목명" value={itemName} />
|
||||
<InfoRow label="규격" value={spec} />
|
||||
<InfoRow label="거래처" value={customerName} />
|
||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
|
||||
{df.itemName && <InfoRow label="품목명" value={itemName} />}
|
||||
{df.spec && <InfoRow label="규격" value={spec} />}
|
||||
{df.customerName && <InfoRow label="거래처" value={customerName} />}
|
||||
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
|
||||
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
|
||||
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
|
||||
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
|
||||
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
|
||||
</div>
|
||||
|
||||
{/* 공정현황 */}
|
||||
<div className="px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{df.processProgress && (
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "bg-blue-500/20 text-blue-400 animate-pulse",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 프로그레스바 */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500"
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
{df.progressBar && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500",
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted h-2.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -496,7 +512,7 @@ function WorkCard({
|
||||
// ─── 정보 행 ───────────────────────────────────────────────
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="text-muted-foreground shrink-0">{label}:</span>
|
||||
<span className="text-foreground truncate">{value}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,23 +4,12 @@ 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Search,
|
||||
ClipboardCheck,
|
||||
} from "lucide-react";
|
||||
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
||||
|
||||
/* ───── 타입 ───── */
|
||||
interface ProcessRow {
|
||||
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
|
||||
|
||||
/* ───── 유틸 ───── */
|
||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||
const pct = (n: number) =>
|
||||
`${n.toFixed(1)}%`;
|
||||
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||
|
||||
const badgeVariant = (
|
||||
type: "result" | "type" | "defectRate",
|
||||
value: string | number,
|
||||
) => {
|
||||
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
|
||||
if (type === "result") {
|
||||
if (value === "합격")
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||
}
|
||||
if (type === "type") {
|
||||
if (value === "공정검사")
|
||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
}
|
||||
@@ -93,10 +76,15 @@ const badgeVariant = (
|
||||
|
||||
/* ───── 컴포넌트 ───── */
|
||||
export default function QualityMonitoringPage() {
|
||||
const { settings } = useMonitoringSettings("quality");
|
||||
const theme = getMonitoringTheme(settings.theme);
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
const tc = settings.tableColumns;
|
||||
|
||||
const [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
"/table-management/tables/work_order_process/data",
|
||||
{ autoFilter: true },
|
||||
);
|
||||
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||
setProcessData(rows);
|
||||
} catch (err) {
|
||||
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
|
||||
/* ───── 자동 갱신 ───── */
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(fetchData, 30_000);
|
||||
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [autoRefresh, fetchData]);
|
||||
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||
|
||||
/* ───── 검사 행 변환 ───── */
|
||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
|
||||
const goodQty = r.good_qty ?? 0;
|
||||
const defectQty = r.defect_qty ?? 0;
|
||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||
const result: InspectionRow["result"] =
|
||||
r.status !== "completed"
|
||||
? "대기"
|
||||
: defectQty > 0
|
||||
? "불합격"
|
||||
: "합격";
|
||||
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
|
||||
|
||||
return {
|
||||
no: idx + 1,
|
||||
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
|
||||
|
||||
/* ───── 렌더링 ───── */
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
|
||||
<div className={cn("flex items-center justify-between border-b px-6 py-4", theme.header)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
품질점검현황{" "}
|
||||
<span className="text-emerald-600">모니터링</span>
|
||||
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
||||
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className={cn("flex items-center gap-2 text-sm", theme.mutedText)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">
|
||||
{currentTime.toLocaleString("ko-KR", {
|
||||
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
<span className="ml-1">새로고침</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh((p) => !p)}
|
||||
className={cn(
|
||||
autoRefresh &&
|
||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
||||
)}
|
||||
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
<Clock className="mr-1 h-4 w-4" />
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 본문 ── */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||
<div className="flex-1 space-y-6 overflow-auto p-6">
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{summaryCards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className={cn(
|
||||
"rounded-xl bg-gradient-to-br p-5 shadow-md",
|
||||
card.color,
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-white/80">
|
||||
{card.label}
|
||||
</p>
|
||||
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||
{card.value}
|
||||
{card.sub && (
|
||||
<span className="ml-1 text-base font-normal text-white/70">
|
||||
{card.sub}
|
||||
</span>
|
||||
)}
|
||||
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
activeTab === tab.key
|
||||
? "bg-emerald-600 text-white shadow"
|
||||
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border",
|
||||
: "border bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
|
||||
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
|
||||
{/* 입고/출하 준비중 */}
|
||||
{(activeTab === "incoming" || activeTab === "shipping") ? (
|
||||
{activeTab === "incoming" || activeTab === "shipping" ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Search className="h-12 w-12 mb-4 opacity-40" />
|
||||
<Search className="mb-4 h-12 w-12 opacity-40" />
|
||||
<p className="text-lg font-medium">준비중</p>
|
||||
<p className="text-sm mt-1">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
||||
아직 지원되지 않습니다.
|
||||
<p className="mt-1 text-sm">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : loading && filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Loader2 className="h-10 w-10 animate-spin mb-4" />
|
||||
<Loader2 className="mb-4 h-10 w-10 animate-spin" />
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Inbox className="h-12 w-12 mb-4 opacity-40" />
|
||||
<Inbox className="mb-4 h-12 w-12 opacity-40" />
|
||||
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<TableRow className={theme.tableHeader}>
|
||||
<TableHead className="w-[50px] text-center">No</TableHead>
|
||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||
<TableHead className="min-w-[90px] text-center">
|
||||
검사유형
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
검사수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
불합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-right">
|
||||
불량율
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[160px] text-center">
|
||||
검사결과
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-center">
|
||||
판정
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-center">
|
||||
검사자
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
||||
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
||||
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
||||
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
||||
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
||||
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
||||
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
||||
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
||||
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
||||
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
||||
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
||||
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
||||
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
||||
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.map((row) => {
|
||||
const goodPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.goodQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const defectPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.defectQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
||||
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.no}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
||||
>
|
||||
<TableCell className="text-center text-sm text-gray-500">
|
||||
{row.no}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{row.inspectionNo}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("type", row.inspectionType),
|
||||
)}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-medium">
|
||||
{row.itemName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.spec}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{fmt(row.inspectionQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-emerald-600">
|
||||
{fmt(row.goodQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-red-600">
|
||||
{fmt(row.defectQty)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right text-sm",
|
||||
badgeVariant("defectRate", row.defectRate),
|
||||
)}
|
||||
>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
{/* 검사결과 프로그레스바 */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-red-500 transition-all"
|
||||
style={{ width: `${defectPct}%` }}
|
||||
/>
|
||||
<TableRow key={row.no} className={theme.tableRowHover}>
|
||||
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
||||
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
||||
{tc.inspectionType && (
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
||||
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
||||
{tc.inspectionQty && (
|
||||
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
||||
)}
|
||||
{tc.passFailQty && (
|
||||
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
||||
)}
|
||||
{tc.passFailQty && (
|
||||
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
||||
)}
|
||||
{tc.defectRate && (
|
||||
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.resultBar && (
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
||||
</div>
|
||||
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* 판정 배지 */}
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("result", row.result),
|
||||
)}
|
||||
>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{row.inspectorName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString(
|
||||
"ko-KR",
|
||||
{
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.judgment && (
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
||||
{tc.inspectedAt && (
|
||||
<TableCell className={cn("text-sm", theme.mutedText)}>
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
},
|
||||
)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-400">
|
||||
{row.remark || "-"}
|
||||
</TableCell>
|
||||
})
|
||||
: "-"}
|
||||
</TableCell>
|
||||
)}
|
||||
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user