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 {