feat: Add monitoring pages for equipment, production, and quality
- Introduced three new pages for monitoring: Equipment Monitoring, Production Monitoring, and Quality Monitoring. - Implemented real-time data fetching and auto-refresh functionality for each monitoring page. - Enhanced user experience with summary statistics and filtering options for work instructions and inspection data. - Integrated various UI components for displaying equipment status, production progress, and quality inspection results. These changes aim to provide comprehensive monitoring capabilities for equipment, production processes, and quality inspections, enhancing operational visibility for users.
This commit is contained in:
575
frontend/app/(main)/COMPANY_16/monitoring/equipment/page.tsx
Normal file
575
frontend/app/(main)/COMPANY_16/monitoring/equipment/page.tsx
Normal file
@@ -0,0 +1,575 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 설비운영모니터링 — 하드코딩 페이지
|
||||
*
|
||||
* 설비(equipment_mng) 목록 + 작업지시(work_instruction) 연결
|
||||
* 실시간 카드 그리드 형태 모니터링 대시보드
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Wrench,
|
||||
Zap,
|
||||
Pause,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ───── 상태 정의 ───── */
|
||||
|
||||
type OperationStatus = "running" | "idle" | "maintenance" | "off" | "unknown";
|
||||
|
||||
interface StatusConfig {
|
||||
label: string;
|
||||
color: string;
|
||||
bg: string;
|
||||
border: string;
|
||||
bar: string;
|
||||
icon: React.ReactNode;
|
||||
badgeBg: string;
|
||||
badgeText: string;
|
||||
cardGlow: string;
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<OperationStatus, StatusConfig> = {
|
||||
running: {
|
||||
label: "가동중",
|
||||
color: "text-emerald-400",
|
||||
bg: "bg-emerald-500/10",
|
||||
border: "border-emerald-500/30",
|
||||
bar: "bg-emerald-400",
|
||||
icon: <Zap className="h-4 w-4" />,
|
||||
badgeBg: "bg-emerald-500/20",
|
||||
badgeText: "text-emerald-300",
|
||||
cardGlow: "shadow-emerald-500/5",
|
||||
},
|
||||
idle: {
|
||||
label: "대기",
|
||||
color: "text-amber-400",
|
||||
bg: "bg-amber-500/10",
|
||||
border: "border-amber-500/30",
|
||||
bar: "bg-amber-400",
|
||||
icon: <Pause className="h-4 w-4" />,
|
||||
badgeBg: "bg-amber-500/20",
|
||||
badgeText: "text-amber-300",
|
||||
cardGlow: "shadow-amber-500/5",
|
||||
},
|
||||
maintenance: {
|
||||
label: "점검/수리",
|
||||
color: "text-red-400",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/30",
|
||||
bar: "bg-red-400",
|
||||
icon: <Wrench className="h-4 w-4" />,
|
||||
badgeBg: "bg-red-500/20",
|
||||
badgeText: "text-red-300",
|
||||
cardGlow: "shadow-red-500/5",
|
||||
},
|
||||
off: {
|
||||
label: "비가동",
|
||||
color: "text-gray-400",
|
||||
bg: "bg-gray-500/10",
|
||||
border: "border-gray-500/30",
|
||||
bar: "bg-gray-500",
|
||||
icon: <Power className="h-4 w-4" />,
|
||||
badgeBg: "bg-gray-500/20",
|
||||
badgeText: "text-gray-400",
|
||||
cardGlow: "shadow-gray-500/5",
|
||||
},
|
||||
unknown: {
|
||||
label: "미설정",
|
||||
color: "text-gray-500",
|
||||
bg: "bg-gray-500/10",
|
||||
border: "border-gray-600/30",
|
||||
bar: "bg-gray-600",
|
||||
icon: <Power className="h-4 w-4" />,
|
||||
badgeBg: "bg-gray-600/20",
|
||||
badgeText: "text-gray-500",
|
||||
cardGlow: "",
|
||||
},
|
||||
};
|
||||
|
||||
/** operation_status 값 → 내부 키 매핑 */
|
||||
function resolveStatus(raw: string | null | undefined): OperationStatus {
|
||||
if (!raw) return "unknown";
|
||||
const v = raw.trim().toLowerCase();
|
||||
if (["running", "가동", "가동중"].includes(v)) return "running";
|
||||
if (["idle", "대기"].includes(v)) return "idle";
|
||||
if (["maintenance", "점검", "수리", "점검/수리", "점검중"].includes(v)) return "maintenance";
|
||||
if (["off", "비가동", "정지"].includes(v)) return "off";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/* ───── 타입 ───── */
|
||||
|
||||
interface Equipment {
|
||||
id: string;
|
||||
equipment_code: string;
|
||||
equipment_name: string;
|
||||
equipment_type: string;
|
||||
installation_location: string;
|
||||
operation_status: string;
|
||||
manufacturer: string;
|
||||
model_name: string;
|
||||
image_path: string;
|
||||
}
|
||||
|
||||
interface WorkInstruction {
|
||||
id: string;
|
||||
instruction_number: string;
|
||||
item_name: string;
|
||||
equipment_id: string;
|
||||
worker_name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/* ───── 컴포넌트 ───── */
|
||||
|
||||
export default function EquipmentMonitoringPage() {
|
||||
const [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 [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||
const autoRefreshRef = useRef(autoRefresh);
|
||||
|
||||
// autoRefreshRef 동기화
|
||||
useEffect(() => {
|
||||
autoRefreshRef.current = autoRefresh;
|
||||
}, [autoRefresh]);
|
||||
|
||||
/* ── 시간 업데이트 ── */
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
/* ── 데이터 fetch ── */
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [equipRes, wiRes] = await Promise.all([
|
||||
apiClient.post("/table-management/tables/equipment_mng/data", {
|
||||
autoFilter: true,
|
||||
page: 1,
|
||||
size: 500,
|
||||
}),
|
||||
apiClient.get("/work-instruction/list").catch(() => ({ data: { data: [] } })),
|
||||
]);
|
||||
|
||||
const eqRows: Equipment[] = equipRes.data?.data?.rows ?? equipRes.data?.rows ?? [];
|
||||
setEquipments(eqRows);
|
||||
|
||||
const wiRows: WorkInstruction[] = wiRes.data?.data ?? wiRes.data?.rows ?? [];
|
||||
setWorkInstructions(wiRows);
|
||||
} catch (err) {
|
||||
console.error("설비 모니터링 데이터 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
/* ── 자동 갱신 (30초) ── */
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (autoRefreshRef.current) fetchData();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
/* ── 요약 통계 ── */
|
||||
const stats = useMemo(() => {
|
||||
const counts: Record<OperationStatus, number> = {
|
||||
running: 0,
|
||||
idle: 0,
|
||||
maintenance: 0,
|
||||
off: 0,
|
||||
unknown: 0,
|
||||
};
|
||||
equipments.forEach((eq) => {
|
||||
const s = resolveStatus(eq.operation_status);
|
||||
counts[s]++;
|
||||
});
|
||||
return { total: equipments.length, ...counts };
|
||||
}, [equipments]);
|
||||
|
||||
/* ── 필터된 설비 ── */
|
||||
const filteredEquipments = useMemo(() => {
|
||||
if (filterStatus === "all") return equipments;
|
||||
return equipments.filter((eq) => resolveStatus(eq.operation_status) === filterStatus);
|
||||
}, [equipments, filterStatus]);
|
||||
|
||||
/* ── 설비별 작업지시 맵 ── */
|
||||
const wiMap = useMemo(() => {
|
||||
const map: Record<string, WorkInstruction[]> = {};
|
||||
workInstructions.forEach((wi) => {
|
||||
if (wi.equipment_id) {
|
||||
if (!map[wi.equipment_id]) map[wi.equipment_id] = [];
|
||||
map[wi.equipment_id].push(wi);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [workInstructions]);
|
||||
|
||||
/* ── 가동률 (모킹 — 센서 미연동) ── */
|
||||
const getUtilization = (eq: Equipment): number | null => {
|
||||
const s = resolveStatus(eq.operation_status);
|
||||
if (s === "running") return 75 + Math.floor(Math.random() * 20); // 75~94
|
||||
if (s === "idle") return 20 + Math.floor(Math.random() * 30); // 20~49
|
||||
if (s === "maintenance") return 0;
|
||||
if (s === "off") return 0;
|
||||
return null;
|
||||
};
|
||||
|
||||
/* ── 요약 카드 배열 ── */
|
||||
const summaryCards: {
|
||||
label: string;
|
||||
count: number;
|
||||
status: OperationStatus | "total";
|
||||
color: string;
|
||||
bg: string;
|
||||
border: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
label: "전체설비",
|
||||
count: stats.total,
|
||||
status: "total",
|
||||
color: "text-blue-400",
|
||||
bg: "bg-blue-500/10",
|
||||
border: "border-blue-500/30",
|
||||
icon: <Inbox className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
label: "가동중",
|
||||
count: stats.running,
|
||||
status: "running",
|
||||
color: "text-emerald-400",
|
||||
bg: "bg-emerald-500/10",
|
||||
border: "border-emerald-500/30",
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
label: "대기",
|
||||
count: stats.idle,
|
||||
status: "idle",
|
||||
color: "text-amber-400",
|
||||
bg: "bg-amber-500/10",
|
||||
border: "border-amber-500/30",
|
||||
icon: <Pause className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
label: "점검/수리",
|
||||
count: stats.maintenance,
|
||||
status: "maintenance",
|
||||
color: "text-red-400",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/30",
|
||||
icon: <Wrench className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
label: "비가동",
|
||||
count: stats.off + stats.unknown,
|
||||
status: "off",
|
||||
color: "text-gray-400",
|
||||
bg: "bg-gray-500/10",
|
||||
border: "border-gray-500/30",
|
||||
icon: <Power className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
/* ── 필터 pill ── */
|
||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
||||
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-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" },
|
||||
];
|
||||
|
||||
/* ── 포맷 ── */
|
||||
const formatTime = (d: Date) =>
|
||||
d.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
|
||||
const formatDate = (d: Date) =>
|
||||
d.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", weekday: "short" });
|
||||
|
||||
/* ────────────── 렌더 ────────────── */
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
|
||||
{/* ── 헤더 ── */}
|
||||
<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>
|
||||
</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">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">{formatDate(currentTime)}</span>
|
||||
<span className="font-mono text-white">{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
|
||||
{/* 자동갱신 토글 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"border-gray-700 text-xs gap-1.5",
|
||||
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"
|
||||
)}
|
||||
onClick={() => setAutoRefresh((v) => !v)}
|
||||
>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</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}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── 요약 카드 5개 ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{summaryCards.map((card) => (
|
||||
<button
|
||||
key={card.label}
|
||||
onClick={() =>
|
||||
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
|
||||
}
|
||||
className={cn(
|
||||
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
||||
card.bg,
|
||||
card.border,
|
||||
"hover:shadow-lg"
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
||||
{card.icon}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-xs text-gray-500">{card.label}</p>
|
||||
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 필터 pill ── */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filterPills.map((pill) => (
|
||||
<button
|
||||
key={pill.value}
|
||||
onClick={() => setFilterStatus(pill.value)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||
filterStatus === pill.value
|
||||
? cn(pill.color, "ring-1 ring-white/20")
|
||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
||||
)}
|
||||
>
|
||||
{pill.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto text-sm text-gray-600 self-center">
|
||||
{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>
|
||||
</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" />
|
||||
<p className="text-lg">등록된 설비가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 설비 카드 그리드 ── */}
|
||||
{filteredEquipments.length > 0 && (
|
||||
<div
|
||||
className="grid gap-4"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
|
||||
>
|
||||
{filteredEquipments.map((eq) => {
|
||||
const status = resolveStatus(eq.operation_status);
|
||||
const cfg = STATUS_MAP[status];
|
||||
const utilization = getUtilization(eq);
|
||||
const eqWIs = wiMap[eq.id] ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={eq.id}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
|
||||
cfg.border,
|
||||
cfg.cardGlow
|
||||
)}
|
||||
>
|
||||
{/* 좌측 색상 바 */}
|
||||
<div className={cn("absolute left-0 top-0 bottom-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 || "-"}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 센서 데이터 (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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 결과 없음 */}
|
||||
{!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" />
|
||||
<p>해당 상태의 설비가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
486
frontend/app/(main)/COMPANY_16/monitoring/production/page.tsx
Normal file
486
frontend/app/(main)/COMPANY_16/monitoring/production/page.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Timer,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
TrendingUp,
|
||||
Play,
|
||||
Pause,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||
interface WorkInstructionDetail {
|
||||
item_name?: string;
|
||||
spec?: string;
|
||||
customer_name?: string;
|
||||
}
|
||||
|
||||
interface WorkInstruction {
|
||||
id: string;
|
||||
work_instruction_no: string;
|
||||
status: string; // 일반 / 긴급
|
||||
qty: number;
|
||||
completed_qty: number;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
worker: string | null;
|
||||
equipment_id: string | null;
|
||||
equipment_name?: string | null;
|
||||
details?: WorkInstructionDetail[];
|
||||
}
|
||||
|
||||
interface ProcessStep {
|
||||
wo_id: string;
|
||||
process_name: string;
|
||||
status: string; // acceptable / completed
|
||||
seq_no: number;
|
||||
}
|
||||
|
||||
type FilterTab = "전체" | "대기" | "진행중" | "완료";
|
||||
|
||||
// ─── 유틸리티 ──────────────────────────────────────────────
|
||||
function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return "-";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
const h = String(date.getHours()).padStart(2, "0");
|
||||
const m = String(date.getMinutes()).padStart(2, "0");
|
||||
const s = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
// 작업지시별 공정현황으로 진행상태 계산
|
||||
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;
|
||||
if (completedCount === 0) return "대기";
|
||||
if (completedCount === steps.length) return "완료";
|
||||
return "진행중";
|
||||
}
|
||||
|
||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||
export default function ProductionMonitoringPage() {
|
||||
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 [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||
|
||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// ─── 데이터 로드 ─────────────────────────────────────────
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 작업지시 목록 조회
|
||||
const wiRes = await apiClient.get("/work-instruction/list");
|
||||
const wiData: WorkInstruction[] =
|
||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
setWorkInstructions(wiData);
|
||||
|
||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||
try {
|
||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows: ProcessStep[] = Array.isArray(procRes.data?.rows)
|
||||
? procRes.data.rows
|
||||
: Array.isArray(procRes.data?.data)
|
||||
? procRes.data.data
|
||||
: [];
|
||||
|
||||
const map = new Map<string, ProcessStep[]>();
|
||||
rows.forEach((row) => {
|
||||
const key = String(row.wo_id);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(row);
|
||||
});
|
||||
// seq_no 기준 정렬
|
||||
map.forEach((steps) => steps.sort((a, b) => (a.seq_no ?? 0) - (b.seq_no ?? 0)));
|
||||
setProcessMap(map);
|
||||
} catch {
|
||||
// 공정현황 조회 실패 → 빈 맵 유지
|
||||
setProcessMap(new Map());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("생산모니터링 데이터 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const timer = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRefresh, fetchData]);
|
||||
|
||||
// ─── 통계 계산 ───────────────────────────────────────────
|
||||
const stats = useMemo(() => {
|
||||
let waiting = 0;
|
||||
let inProgress = 0;
|
||||
let completed = 0;
|
||||
let totalQty = 0;
|
||||
let completedQty = 0;
|
||||
|
||||
workInstructions.forEach((wi) => {
|
||||
const progress = computeProgress(wi.id, processMap);
|
||||
if (progress === "대기") waiting++;
|
||||
else if (progress === "진행중") inProgress++;
|
||||
else completed++;
|
||||
totalQty += Number(wi.qty) || 0;
|
||||
completedQty += Number(wi.completed_qty) || 0;
|
||||
});
|
||||
|
||||
const achievementRate = totalQty > 0 ? Math.round((completedQty / totalQty) * 100) : 0;
|
||||
|
||||
return { waiting, inProgress, completed, achievementRate };
|
||||
}, [workInstructions, processMap]);
|
||||
|
||||
// ─── 필터링된 작업 목록 ──────────────────────────────────
|
||||
const filteredInstructions = useMemo(() => {
|
||||
return workInstructions.filter((wi) => {
|
||||
if (activeTab === "전체") return true;
|
||||
const progress = computeProgress(wi.id, processMap);
|
||||
return progress === activeTab;
|
||||
});
|
||||
}, [workInstructions, processMap, activeTab]);
|
||||
|
||||
// ─── 렌더링 ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</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" />
|
||||
<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>
|
||||
<Button
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
||||
<SummaryCard
|
||||
icon={<Timer className="w-5 h-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" />}
|
||||
label="진행중"
|
||||
value={stats.inProgress}
|
||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<CheckCircle2 className="w-5 h-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" />}
|
||||
label="달성율"
|
||||
value={`${stats.achievementRate}%`}
|
||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 탭 필터 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
variant={activeTab === tab ? "default" : "outline"}
|
||||
size="sm"
|
||||
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"
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
{tab === "전체" && ` (${workInstructions.length})`}
|
||||
{tab === "대기" && ` (${stats.waiting})`}
|
||||
{tab === "진행중" && ` (${stats.inProgress})`}
|
||||
{tab === "완료" && ` (${stats.completed})`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{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" />
|
||||
<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" />
|
||||
<span className="text-sm">
|
||||
{activeTab === "전체"
|
||||
? "등록된 작업지시가 없습니다."
|
||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 작업 카드 그리드 */}
|
||||
{filteredInstructions.length > 0 && (
|
||||
<div
|
||||
className="grid gap-4 flex-1"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
||||
>
|
||||
{filteredInstructions.map((wi, idx) => (
|
||||
<WorkCard
|
||||
key={wi.id || `wi-${idx}`}
|
||||
instruction={wi}
|
||||
steps={processMap.get(wi.id) || []}
|
||||
progress={computeProgress(wi.id, processMap)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 요약 카드 ─────────────────────────────────────────────
|
||||
function SummaryCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
colorClass,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: number | string;
|
||||
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="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 작업 카드 ─────────────────────────────────────────────
|
||||
function WorkCard({
|
||||
instruction: wi,
|
||||
steps,
|
||||
progress,
|
||||
}: {
|
||||
instruction: WorkInstruction;
|
||||
steps: ProcessStep[];
|
||||
progress: "대기" | "진행중" | "완료";
|
||||
}) {
|
||||
const detail = wi.details?.[0];
|
||||
const itemName = detail?.item_name || "-";
|
||||
const spec = detail?.spec || "-";
|
||||
const customerName = detail?.customer_name || "-";
|
||||
|
||||
// 진척률
|
||||
const progressPercent =
|
||||
Number(wi.qty) > 0
|
||||
? Math.min(100, Math.round((Number(wi.completed_qty || 0) / Number(wi.qty)) * 100))
|
||||
: 0;
|
||||
|
||||
// 공정 현황 계산
|
||||
const completedSteps = steps.filter((s) => s.status === "completed").length;
|
||||
const currentStep = steps.find((s) => s.status !== "completed");
|
||||
|
||||
// 프로그레스바 색상
|
||||
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",
|
||||
};
|
||||
|
||||
const isUrgent = wi.status === "긴급";
|
||||
|
||||
return (
|
||||
<div className="bg-card border rounded-lg overflow-hidden flex flex-col">
|
||||
{/* 카드 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<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" />
|
||||
긴급
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className={cn("text-xs", statusBadge[progress])}>
|
||||
{progress}
|
||||
</Badge>
|
||||
</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>
|
||||
|
||||
{/* 공정현황 */}
|
||||
<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>
|
||||
)}
|
||||
</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">
|
||||
{wi.completed_qty ?? 0} / {wi.qty ?? 0}
|
||||
</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="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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 정보 행 ───────────────────────────────────────────────
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{label}:</span>
|
||||
<span className="text-foreground truncate">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
512
frontend/app/(main)/COMPANY_16/monitoring/quality/page.tsx
Normal file
512
frontend/app/(main)/COMPANY_16/monitoring/quality/page.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
"use client";
|
||||
|
||||
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 { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Search,
|
||||
ClipboardCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ───── 타입 ───── */
|
||||
interface ProcessRow {
|
||||
id: number;
|
||||
wo_id: number;
|
||||
process_code: string;
|
||||
process_name: string;
|
||||
status: string;
|
||||
plan_qty: number;
|
||||
input_qty: number;
|
||||
good_qty: number;
|
||||
defect_qty: number;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
worker_name: string;
|
||||
}
|
||||
|
||||
interface InspectionRow {
|
||||
no: number;
|
||||
inspectionNo: string;
|
||||
inspectionType: string;
|
||||
itemName: string;
|
||||
spec: string;
|
||||
inspectionQty: number;
|
||||
goodQty: number;
|
||||
defectQty: number;
|
||||
defectRate: number;
|
||||
result: "합격" | "불합격" | "대기";
|
||||
inspectorName: string;
|
||||
inspectedAt: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
/* ───── 탭 정의 ───── */
|
||||
const TABS = [
|
||||
{ key: "all", label: "전체" },
|
||||
{ key: "process", label: "공정검사" },
|
||||
{ key: "incoming", label: "입고검사" },
|
||||
{ key: "shipping", label: "출하검사" },
|
||||
] as const;
|
||||
type TabKey = (typeof TABS)[number]["key"];
|
||||
|
||||
/* ───── 유틸 ───── */
|
||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||
const pct = (n: number) =>
|
||||
`${n.toFixed(1)}%`;
|
||||
|
||||
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-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-blue-100 text-blue-700 border-blue-200";
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
}
|
||||
// defectRate
|
||||
const rate = typeof value === "number" ? value : parseFloat(String(value));
|
||||
if (rate > 3) return "text-red-600 font-semibold";
|
||||
if (rate >= 1) return "text-amber-600 font-semibold";
|
||||
return "text-emerald-600";
|
||||
};
|
||||
|
||||
/* ───── 컴포넌트 ───── */
|
||||
export default function QualityMonitoringPage() {
|
||||
const [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
/* ───── 시계 ───── */
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
/* ───── 데이터 조회 ───── */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
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) {
|
||||
console.error("품질점검현황 데이터 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
/* ───── 자동 갱신 ───── */
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(fetchData, 30_000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [autoRefresh, fetchData]);
|
||||
|
||||
/* ───── 검사 행 변환 ───── */
|
||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
return processData
|
||||
.filter((r) => {
|
||||
// 금일 데이터만
|
||||
const dt = r.completed_at || r.started_at || "";
|
||||
return dt.slice(0, 10) === today;
|
||||
})
|
||||
.map((r, idx) => {
|
||||
const inspQty = r.input_qty || r.plan_qty || 0;
|
||||
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
|
||||
? "불합격"
|
||||
: "합격";
|
||||
|
||||
return {
|
||||
no: idx + 1,
|
||||
inspectionNo: `QC-${String(r.id).padStart(8, "0").slice(0, 8)}`,
|
||||
inspectionType: "공정검사",
|
||||
itemName: r.process_name || "-",
|
||||
spec: r.process_code || "-",
|
||||
inspectionQty: inspQty,
|
||||
goodQty,
|
||||
defectQty,
|
||||
defectRate,
|
||||
result,
|
||||
inspectorName: r.worker_name || "-",
|
||||
inspectedAt: r.completed_at || r.started_at || "-",
|
||||
remark: "",
|
||||
};
|
||||
});
|
||||
}, [processData]);
|
||||
|
||||
/* ───── 탭 필터링 ───── */
|
||||
const filteredRows = useMemo(() => {
|
||||
if (activeTab === "all" || activeTab === "process") return inspectionRows;
|
||||
// 입고/출하는 데이터 없음
|
||||
return [];
|
||||
}, [activeTab, inspectionRows]);
|
||||
|
||||
/* ───── 요약 통계 ───── */
|
||||
const summary = useMemo(() => {
|
||||
const total = inspectionRows.length;
|
||||
const passed = inspectionRows.filter((r) => r.result === "합격").length;
|
||||
const failed = inspectionRows.filter((r) => r.result === "불합격").length;
|
||||
const pending = inspectionRows.filter((r) => r.result === "대기").length;
|
||||
const passRate = total > 0 ? (passed / total) * 100 : 0;
|
||||
return { total, passed, failed, pending, passRate };
|
||||
}, [inspectionRows]);
|
||||
|
||||
/* ───── 요약 카드 정의 ───── */
|
||||
const summaryCards = [
|
||||
{
|
||||
label: "금일 검사건수",
|
||||
value: fmt(summary.total),
|
||||
sub: "건",
|
||||
color: "from-slate-500 to-slate-600",
|
||||
textColor: "text-white",
|
||||
},
|
||||
{
|
||||
label: "합격",
|
||||
value: fmt(summary.passed),
|
||||
sub: "건",
|
||||
color: "from-emerald-500 to-emerald-600",
|
||||
textColor: "text-white",
|
||||
},
|
||||
{
|
||||
label: "불합격",
|
||||
value: fmt(summary.failed),
|
||||
sub: "건",
|
||||
color: "from-red-500 to-red-600",
|
||||
textColor: "text-white",
|
||||
},
|
||||
{
|
||||
label: "검사대기",
|
||||
value: fmt(summary.pending),
|
||||
sub: "건",
|
||||
color: "from-amber-500 to-amber-600",
|
||||
textColor: "text-white",
|
||||
},
|
||||
{
|
||||
label: "합격률",
|
||||
value: pct(summary.passRate),
|
||||
sub: "",
|
||||
color: "from-purple-500 to-purple-600",
|
||||
textColor: "text-white",
|
||||
},
|
||||
];
|
||||
|
||||
/* ───── 렌더링 ───── */
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">
|
||||
{currentTime.toLocaleString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
})}
|
||||
</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" />
|
||||
)}
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 본문 ── */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-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>
|
||||
<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>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 검사유형 탭 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-full 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",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
|
||||
{/* 입고/출하 준비중 */}
|
||||
{(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" />
|
||||
<p className="text-lg font-medium">준비중</p>
|
||||
<p className="text-sm mt-1">
|
||||
{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" />
|
||||
<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" />
|
||||
<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">
|
||||
<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>
|
||||
</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;
|
||||
|
||||
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}%` }}
|
||||
/>
|
||||
</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",
|
||||
{
|
||||
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>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,6 +134,9 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_16/production/bom": dynamic(() => import("@/app/(main)/COMPANY_16/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_16/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_16/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_16/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_16/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_16/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -480,6 +483,7 @@ const COMPANY_PAGE_PREFIXES = [
|
||||
"/purchase/",
|
||||
"/quality/",
|
||||
"/mold/",
|
||||
"/monitoring/",
|
||||
];
|
||||
|
||||
function isCompanyPage(url: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user