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:
kjs
2026-04-07 12:02:10 +09:00
parent 822f9ac35a
commit 9aa8ca136b
4 changed files with 1577 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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 {