Files
pipeline/frontend/app/(main)/COMPANY_9/monitoring/quality/page.tsx
T
kjs 518990171e feat: Enhance monitoring pages with dynamic settings and themes
- 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.
2026-04-09 15:12:36 +09:00

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