518990171e
- Integrated monitoring settings and theme management into the Equipment, Production, and Quality monitoring pages. - Updated auto-refresh functionality to utilize user-defined settings for refresh intervals. - Improved UI elements with dynamic theming for better visual consistency across COMPANY_10, COMPANY_16, and COMPANY_29. - Added settings button to access monitoring configuration, enhancing user experience in managing monitoring preferences. These changes aim to provide a more customizable and user-friendly interface for monitoring operations across multiple companies.
427 lines
18 KiB
TypeScript
427 lines
18 KiB
TypeScript
"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 { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
|
import { useTabStore } from "@/stores/tabStore";
|
|
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
|
|
|
/* ───── 타입 ───── */
|
|
interface ProcessRow {
|
|
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 { settings } = useMonitoringSettings("quality");
|
|
const theme = getMonitoringTheme(settings.theme);
|
|
const openTab = useTabStore((s) => s.openTab);
|
|
const tc = settings.tableColumns;
|
|
|
|
const [processData, setProcessData] = useState<ProcessRow[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(new Date());
|
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
|
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, settings.refreshInterval * 1000);
|
|
}
|
|
return () => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
};
|
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
|
|
|
/* ───── 검사 행 변환 ───── */
|
|
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={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
|
|
{/* ── 헤더 ── */}
|
|
<div className={cn("flex items-center justify-between border-b px-6 py-4", theme.header)}>
|
|
<div className="flex items-center gap-3">
|
|
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
|
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
|
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
|
</h1>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<div className={cn("flex items-center gap-2 text-sm", theme.mutedText)}>
|
|
<Clock className="h-4 w-4" />
|
|
<span className="font-mono">
|
|
{currentTime.toLocaleString("ko-KR", {
|
|
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 text-white hover:bg-emerald-700")}
|
|
>
|
|
<Clock className="mr-1 h-4 w-4" />
|
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
|
className="gap-1.5"
|
|
>
|
|
<Settings2 className="h-4 w-4" />
|
|
설정
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 본문 ── */}
|
|
<div className="flex-1 space-y-6 overflow-auto p-6">
|
|
{/* 요약 카드 */}
|
|
<div className="grid grid-cols-5 gap-4">
|
|
{summaryCards.map((card) => (
|
|
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
|
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
|
<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(
|
|
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
|
activeTab === tab.key
|
|
? "bg-emerald-600 text-white shadow"
|
|
: "border bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700",
|
|
)}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 테이블 영역 */}
|
|
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
|
|
{/* 입고/출하 준비중 */}
|
|
{activeTab === "incoming" || activeTab === "shipping" ? (
|
|
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
|
<Search className="mb-4 h-12 w-12 opacity-40" />
|
|
<p className="text-lg font-medium">준비중</p>
|
|
<p className="mt-1 text-sm">
|
|
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
|
</p>
|
|
</div>
|
|
) : loading && filteredRows.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
|
<Loader2 className="mb-4 h-10 w-10 animate-spin" />
|
|
<p>데이터를 불러오는 중...</p>
|
|
</div>
|
|
) : filteredRows.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
|
<Inbox className="mb-4 h-12 w-12 opacity-40" />
|
|
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className={theme.tableHeader}>
|
|
<TableHead className="w-[50px] text-center">No</TableHead>
|
|
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
|
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
|
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
|
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
|
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
|
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
|
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
|
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
|
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
|
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
|
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredRows.map((row) => {
|
|
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
|
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
|
|
|
return (
|
|
<TableRow key={row.no} className={theme.tableRowHover}>
|
|
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
|
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
|
{tc.inspectionType && (
|
|
<TableCell className="text-center">
|
|
<Badge
|
|
variant="outline"
|
|
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
|
>
|
|
{row.inspectionType}
|
|
</Badge>
|
|
</TableCell>
|
|
)}
|
|
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
|
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
|
{tc.inspectionQty && (
|
|
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
|
)}
|
|
{tc.passFailQty && (
|
|
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
|
)}
|
|
{tc.passFailQty && (
|
|
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
|
)}
|
|
{tc.defectRate && (
|
|
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
|
{pct(row.defectRate)}
|
|
</TableCell>
|
|
)}
|
|
{tc.resultBar && (
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
|
<div
|
|
className="h-full bg-emerald-500 transition-all"
|
|
style={{ width: `${goodPct}%` }}
|
|
/>
|
|
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
|
</div>
|
|
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
|
{pct(goodPct)}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
)}
|
|
{tc.judgment && (
|
|
<TableCell className="text-center">
|
|
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
|
{row.result}
|
|
</Badge>
|
|
</TableCell>
|
|
)}
|
|
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
|
{tc.inspectedAt && (
|
|
<TableCell className={cn("text-sm", theme.mutedText)}>
|
|
{row.inspectedAt !== "-"
|
|
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
})
|
|
: "-"}
|
|
</TableCell>
|
|
)}
|
|
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|