From 9aa8ca136b3e95cfd870e1b5a7528ac62bbfe071 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 7 Apr 2026 12:02:10 +0900 Subject: [PATCH] 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. --- .../COMPANY_16/monitoring/equipment/page.tsx | 575 ++++++++++++++++++ .../COMPANY_16/monitoring/production/page.tsx | 486 +++++++++++++++ .../COMPANY_16/monitoring/quality/page.tsx | 512 ++++++++++++++++ .../components/layout/AdminPageRenderer.tsx | 4 + 4 files changed, 1577 insertions(+) create mode 100644 frontend/app/(main)/COMPANY_16/monitoring/equipment/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/monitoring/production/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/monitoring/quality/page.tsx diff --git a/frontend/app/(main)/COMPANY_16/monitoring/equipment/page.tsx b/frontend/app/(main)/COMPANY_16/monitoring/equipment/page.tsx new file mode 100644 index 00000000..ccb5aaf6 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/monitoring/equipment/page.tsx @@ -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 = { + running: { + label: "가동중", + color: "text-emerald-400", + bg: "bg-emerald-500/10", + border: "border-emerald-500/30", + bar: "bg-emerald-400", + icon: , + 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: , + 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: , + 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: , + 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: , + 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([]); + const [workInstructions, setWorkInstructions] = useState([]); + const [loading, setLoading] = useState(true); + const [currentTime, setCurrentTime] = useState(new Date()); + const [autoRefresh, setAutoRefresh] = useState(true); + const [filterStatus, setFilterStatus] = useState("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 = { + 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 = {}; + 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: , + }, + { + label: "가동중", + count: stats.running, + status: "running", + color: "text-emerald-400", + bg: "bg-emerald-500/10", + border: "border-emerald-500/30", + icon: , + }, + { + label: "대기", + count: stats.idle, + status: "idle", + color: "text-amber-400", + bg: "bg-amber-500/10", + border: "border-amber-500/30", + icon: , + }, + { + label: "점검/수리", + count: stats.maintenance, + status: "maintenance", + color: "text-red-400", + bg: "bg-red-500/10", + border: "border-red-500/30", + icon: , + }, + { + label: "비가동", + count: stats.off + stats.unknown, + status: "off", + color: "text-gray-400", + bg: "bg-gray-500/10", + border: "border-gray-500/30", + icon: , + }, + ]; + + /* ── 필터 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 ( +
+ {/* ── 헤더 ── */} +
+
+
+

설비운영모니터링

+
+ +
+ {/* 현재 시간 */} +
+ + {formatDate(currentTime)} + {formatTime(currentTime)} +
+ + {/* 자동갱신 토글 */} + + + {/* 새로고침 */} + +
+
+ + {/* ── 요약 카드 5개 ── */} +
+ {summaryCards.map((card) => ( + + ))} +
+ + {/* ── 필터 pill ── */} +
+ {filterPills.map((pill) => ( + + ))} + + {filteredEquipments.length}대 표시 + +
+ + {/* ── 로딩 ── */} + {loading && equipments.length === 0 && ( +
+ + 설비 데이터를 불러오는 중... +
+ )} + + {/* ── 데이터 없음 ── */} + {!loading && equipments.length === 0 && ( +
+ +

등록된 설비가 없습니다.

+
+ )} + + {/* ── 설비 카드 그리드 ── */} + {filteredEquipments.length > 0 && ( +
+ {filteredEquipments.map((eq) => { + const status = resolveStatus(eq.operation_status); + const cfg = STATUS_MAP[status]; + const utilization = getUtilization(eq); + const eqWIs = wiMap[eq.id] ?? []; + + return ( +
+ {/* 좌측 색상 바 */} +
+ + {/* 상단: 설비명 + 상태 배지 */} +
+
+

+ {eq.equipment_name || "이름 없음"} +

+

+ {eq.equipment_type || "-"} · {eq.installation_location || "-"} +

+
+ + {cfg.icon} + {cfg.label} + +
+ + {/* 구분선 */} +
+ + {/* 정보 그리드 */} +
+
+ 금일 가동시간 +

-

+
+
+ 생산수량 +

-

+
+
+ 작업자 +

+ {eqWIs.length > 0 && eqWIs[0].worker_name + ? eqWIs[0].worker_name + : "-"} +

+
+
+ 설비코드 +

{eq.equipment_code || "-"}

+
+
+ + {/* 구분선 */} +
+ + {/* 가동률 프로그레스 */} +
+
+ 가동률 + + {utilization !== null ? `${utilization}%` : "-"} + +
+
+ {utilization !== null && ( +
+ )} +
+
+ + {/* 구분선 */} +
+ + {/* 현재 작업지시 */} +
+

현재 작업지시

+ {eqWIs.length > 0 ? ( +
+ {eqWIs.slice(0, 2).map((wi) => ( +
+ + {wi.instruction_number || "-"} + + + {wi.item_name || "-"} + +
+ ))} + {eqWIs.length > 2 && ( +

+{eqWIs.length - 2}건 더

+ )} +
+ ) : ( +

배정된 작업 없음

+ )} +
+ + {/* 구분선 */} +
+ + {/* 센서 데이터 (PLC 미연동) */} +
+
+ 온도 + - +
+
+ 압력 + - +
+
+ RPM + - +
+
+
+ ); + })} +
+ )} + + {/* 필터 결과 없음 */} + {!loading && equipments.length > 0 && filteredEquipments.length === 0 && ( +
+ +

해당 상태의 설비가 없습니다.

+
+ )} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/monitoring/production/page.tsx b/frontend/app/(main)/COMPANY_16/monitoring/production/page.tsx new file mode 100644 index 00000000..d8b865ee --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/monitoring/production/page.tsx @@ -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 +): "대기" | "진행중" | "완료" { + 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([]); + const [processMap, setProcessMap] = useState>(new Map()); + const [loading, setLoading] = useState(true); + const [currentTime, setCurrentTime] = useState(new Date()); + const [autoRefresh, setAutoRefresh] = useState(true); + const [activeTab, setActiveTab] = useState("전체"); + + // ─── 실시간 시계 ───────────────────────────────────────── + 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(); + 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 ( +
+ {/* 헤더 */} +
+

생산모니터링

+
+
+ + {formatTime(currentTime)} +
+ + +
+
+ + {/* 요약 카드 */} +
+ } + label="대기중" + value={stats.waiting} + colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20" + /> + } + label="진행중" + value={stats.inProgress} + colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20" + /> + } + label="완료" + value={stats.completed} + colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20" + /> + } + label="달성율" + value={`${stats.achievementRate}%`} + colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20" + /> +
+ + {/* 탭 필터 */} +
+ {(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => ( + + ))} +
+ + {/* 로딩 상태 */} + {loading && workInstructions.length === 0 && ( +
+ + 데이터를 불러오는 중입니다... +
+ )} + + {/* 빈 상태 */} + {!loading && filteredInstructions.length === 0 && ( +
+ + + {activeTab === "전체" + ? "등록된 작업지시가 없습니다." + : `"${activeTab}" 상태의 작업지시가 없습니다.`} + +
+ )} + + {/* 작업 카드 그리드 */} + {filteredInstructions.length > 0 && ( +
+ {filteredInstructions.map((wi, idx) => ( + + ))} +
+ )} +
+ ); +} + +// ─── 요약 카드 ───────────────────────────────────────────── +function SummaryCard({ + icon, + label, + value, + colorClass, +}: { + icon: React.ReactNode; + label: string; + value: number | string; + colorClass: string; +}) { + return ( +
+
{icon}
+
+ {label} + {value} +
+
+ ); +} + +// ─── 작업 카드 ───────────────────────────────────────────── +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 = { + "대기": "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 ( +
+ {/* 카드 헤더 */} +
+
+ + {wi.work_instruction_no} + + {isUrgent && ( + + + 긴급 + + )} +
+ + {progress} + +
+ + {/* 카드 본문 - 정보 */} +
+ + + + + + +
+ + {/* 공정현황 */} +
+
+ 공정현황 + {steps.length > 0 && ( + + 완료 {completedSteps}/{steps.length} + {currentStep && ( + + {" "} + · 현재: {currentStep.process_name} + + )} + + )} +
+ {steps.length > 0 ? ( +
+ {steps.map((step, idx) => { + const isDone = step.status === "completed"; + const isCurrent = !isDone && idx === completedSteps; + return ( + + {step.process_name} + + ); + })} +
+ ) : ( + 공정 정보 없음 + )} +
+ + {/* 프로그레스바 */} +
+
+ + {wi.completed_qty ?? 0} / {wi.qty ?? 0} + + = 100 + ? "text-emerald-500" + : progressPercent >= 50 + ? "text-blue-500" + : "text-amber-500" + )} + > + {progressPercent}% + +
+
+
+
+
+
+ ); +} + +// ─── 정보 행 ─────────────────────────────────────────────── +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label}: + {value} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/monitoring/quality/page.tsx b/frontend/app/(main)/COMPANY_16/monitoring/quality/page.tsx new file mode 100644 index 00000000..b0135bbe --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/monitoring/quality/page.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [currentTime, setCurrentTime] = useState(new Date()); + const [autoRefresh, setAutoRefresh] = useState(true); + const [activeTab, setActiveTab] = useState("all"); + const intervalRef = useRef | 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 ( +
+ {/* ── 헤더 ── */} +
+
+ +

+ 품질점검현황{" "} + 모니터링 +

+
+
+
+ + + {currentTime.toLocaleString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + })} + +
+ + +
+
+ + {/* ── 본문 ── */} +
+ {/* 요약 카드 */} +
+ {summaryCards.map((card) => ( +
+

+ {card.label} +

+

+ {card.value} + {card.sub && ( + + {card.sub} + + )} +

+
+ ))} +
+ + {/* 검사유형 탭 */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* 테이블 영역 */} +
+ {/* 입고/출하 준비중 */} + {(activeTab === "incoming" || activeTab === "shipping") ? ( +
+ +

준비중

+

+ {activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 + 아직 지원되지 않습니다. +

+
+ ) : loading && filteredRows.length === 0 ? ( +
+ +

데이터를 불러오는 중...

+
+ ) : filteredRows.length === 0 ? ( +
+ +

금일 검사 데이터가 없습니다

+
+ ) : ( +
+ + + + No + 검사번호 + + 검사유형 + + 품목명 + 규격 + + 검사수량 + + + 합격수량 + + + 불합격수량 + + + 불량율 + + + 검사결과 + + + 판정 + + + 검사자 + + 검사일시 + 비고 + + + + {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 ( + + + {row.no} + + + {row.inspectionNo} + + + + {row.inspectionType} + + + + {row.itemName} + + + {row.spec} + + + {fmt(row.inspectionQty)} + + + {fmt(row.goodQty)} + + + {fmt(row.defectQty)} + + + {pct(row.defectRate)} + + {/* 검사결과 프로그레스바 */} + +
+
+
+
+
+ + {pct(goodPct)} + +
+ + {/* 판정 배지 */} + + + {row.result} + + + + {row.inspectorName} + + + {row.inspectedAt !== "-" + ? new Date(row.inspectedAt).toLocaleString( + "ko-KR", + { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }, + ) + : "-"} + + + {row.remark || "-"} + + + ); + })} + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index e5411d96..165f7014 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -134,6 +134,9 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/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 {