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.
This commit is contained in:
kjs
2026-04-09 15:12:36 +09:00
parent 9200c58d2e
commit 518990171e
27 changed files with 4215 additions and 3563 deletions
@@ -12,16 +12,10 @@ 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";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
/* ───── 상태 정의 ───── */
@@ -134,11 +128,16 @@ interface WorkInstruction {
/* ───── 컴포넌트 ───── */
export default function EquipmentMonitoringPage() {
const { settings } = useMonitoringSettings("equipment");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
const df = settings.displayFields;
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 [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
const autoRefreshRef = useRef(autoRefresh);
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
fetchData();
}, [fetchData]);
/* ── 자동 갱신 (30초) ── */
/* ── 자동 갱신 ── */
useEffect(() => {
const interval = setInterval(() => {
if (autoRefreshRef.current) fetchData();
}, 30000);
}, settings.refreshInterval * 1000);
return () => clearInterval(interval);
}, [fetchData]);
}, [fetchData, settings.refreshInterval]);
/* ── 요약 통계 ── */
const stats = useMemo(() => {
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
/* ── 필터 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" },
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-700 hover:bg-emerald-500/30" },
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-700 hover:bg-amber-500/30" },
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-700 hover:bg-red-500/30" },
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-700 hover:bg-gray-500/30" },
];
/* ── 포맷 ── */
@@ -309,20 +308,26 @@ export default function EquipmentMonitoringPage() {
/* ────────────── 렌더 ────────────── */
return (
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
<div className={cn("min-h-screen space-y-5 p-4 md:p-6", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<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>
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}></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">
<div
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm",
theme.mutedText,
theme.cardBorder,
)}
>
<Clock className="h-4 w-4" />
<span className="font-mono">{formatDate(currentTime)}</span>
<span className="font-mono text-white">{formatTime(currentTime)}</span>
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
</div>
{/* 자동갱신 토글 */}
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
variant="outline"
size="sm"
className={cn(
"border-gray-700 text-xs gap-1.5",
"gap-1.5 text-xs",
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"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => setAutoRefresh((v) => !v)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
<span
className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "animate-pulse bg-emerald-400" : "bg-muted-foreground")}
/>
{autoRefresh ? "ON" : "OFF"}
</Button>
{/* 새로고침 */}
<Button variant="outline" size="sm" className="gap-1.5" onClick={fetchData} disabled={loading}>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</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}
className="gap-1.5"
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
<Settings2 className="h-4 w-4" />
</Button>
</div>
</header>
{/* ── 요약 카드 5개 ── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{summaryCards.map((card) => (
<button
key={card.label}
onClick={() =>
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
}
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"
"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="text-muted-foreground text-xs">{card.label}</p>
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
</div>
</button>
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
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"
? cn(pill.color, "ring-1 ring-foreground/10")
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{pill.label}
</button>
))}
<span className="ml-auto text-sm text-gray-600 self-center">
{filteredEquipments.length}
</span>
<span className="text-muted-foreground ml-auto self-center text-sm">{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>
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
<span className="text-muted-foreground ml-3"> ...</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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<p className="text-lg"> .</p>
</div>
)}
{/* ── 설비 카드 그리드 ── */}
{filteredEquipments.length > 0 && (
<div
className="grid gap-4"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
>
<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];
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
<div
key={eq.id}
className={cn(
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
cfg.border,
cfg.cardGlow
cfg.cardGlow,
)}
>
{/* 좌측 색상 바 */}
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
<div className={cn("absolute top-0 bottom-0 left-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 || "-"}
{df.equipmentName && (
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
{eq.equipment_name || "이름 없음"}
</h3>
)}
<p className={cn("mt-0.5 truncate text-xs", theme.mutedText)}>
{df.equipmentType && (eq.equipment_type || "-")}
{df.equipmentType && df.equipmentLocation && " · "}
{df.equipmentLocation && (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>
{df.operationStatus && (
<Badge
className={cn("ml-2 shrink-0 gap-1 border-0 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="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (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 className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
{df.dailyOperationTime && (
<div>
<span className={cn("text-xs", theme.mutedText)}> </span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.dailyProductionQty && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.worker && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>
{eqWIs.length > 0 && eqWIs[0].worker_name ? eqWIs[0].worker_name : "-"}
</p>
</div>
)}
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("truncate font-medium", theme.text)}>{eq.equipment_code || "-"}</p>
</div>
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 가동률 프로그레스 */}
{df.utilizationBar && (
<div className="px-4 py-2.5 pl-5">
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className={theme.mutedText}></span>
<span
className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : theme.mutedText)}
>
{utilization !== null ? `${utilization}%` : "-"}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
{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-border" />
{/* 현재 작업지시 */}
{df.currentWorkInstruction && (
<div className="px-4 py-2.5 pl-5">
<p className={cn("mb-1 text-xs", theme.mutedText)}> </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="shrink-0 font-mono text-xs text-blue-400">
{wi.instruction_number || "-"}
</span>
<span className={cn("truncate", theme.mutedText)}>{wi.item_name || "-"}</span>
</div>
))}
{eqWIs.length > 2 && <p className={cn("text-xs", theme.mutedText)}>+{eqWIs.length - 2} </p>}
</div>
) : (
<p className={cn("text-sm italic", theme.mutedText)}> </p>
)}
</div>
)}
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (PLC 미연동) */}
{df.sensorData && (
<div className="flex items-center gap-4 px-4 py-2.5 pl-5 text-xs">
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}>RPM</span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
</div>
)}
</div>
);
})}
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
{/* 필터 결과 없음 */}
{!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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
<Inbox className="mb-2 h-10 w-10" />
<p> .</p>
</div>
)}
@@ -5,6 +5,10 @@ import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import type { ProductionDisplayFields } from "@/types/monitoringSettings";
import {
RefreshCw,
Clock,
@@ -16,6 +20,7 @@ import {
TrendingUp,
Play,
Pause,
Settings2,
} from "lucide-react";
// ─── 타입 정의 ─────────────────────────────────────────────
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
}
// 작업지시별 공정현황으로 진행상태 계산
function computeProgress(
wiId: string,
processMap: Map<string, ProcessStep[]>
): "대기" | "진행중" | "완료" {
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;
@@ -85,11 +87,15 @@ function computeProgress(
// ─── 메인 컴포넌트 ────────────────────────────────────────
export default function ProductionMonitoringPage() {
const { settings } = useMonitoringSettings("production");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
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 [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
// ─── 실시간 시계 ─────────────────────────────────────────
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
// 작업지시 목록 조회
const wiRes = await apiClient.get("/work-instruction/list");
const wiRaw: WorkInstruction[] =
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
const seen = new Set<string>();
const wiData = wiRaw.filter((wi) => {
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
// 공정현황 조회 (실패해도 작업지시는 표시)
try {
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
page: 1, size: 1000, autoFilter: true,
page: 1,
size: 1000,
autoFilter: true,
});
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
fetchData();
}, [fetchData]);
// ─── 자동갱신 (30초) ─────────────────────────────────────
// ─── 자동갱신 ────────────────────────────────────────────
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(fetchData, 30000);
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
return () => clearInterval(timer);
}, [autoRefresh, fetchData]);
}, [autoRefresh, fetchData, settings.refreshInterval]);
// ─── 통계 계산 ───────────────────────────────────────────
const stats = useMemo(() => {
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
// ─── 렌더링 ──────────────────────────────────────────────
return (
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
{/* 헤더 */}
<div className="flex items-center justify-between flex-shrink-0">
<h1 className="text-2xl font-bold text-foreground"></h1>
<div className="flex flex-shrink-0 items-center justify-between">
<h1 className={cn("text-2xl font-bold", theme.headerText)}></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" />
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
<Clock className="h-4 w-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 variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
<Button
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
onClick={() => setAutoRefresh(!autoRefresh)}
className="gap-1.5"
>
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="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="grid grid-cols-4 gap-4 flex-shrink-0">
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
<SummaryCard
icon={<Timer className="w-5 h-5" />}
icon={<Timer className="h-5 w-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" />}
icon={<Loader2 className="h-5 w-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" />}
icon={<CheckCircle2 className="h-5 w-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" />}
icon={<TrendingUp className="h-5 w-5" />}
label="달성율"
value={`${stats.achievementRate}%`}
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
</div>
{/* 탭 필터 */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex flex-shrink-0 items-center gap-2">
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
<Button
key={tab}
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
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"
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
)}
>
{tab}
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
{/* 로딩 상태 */}
{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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
<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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<span className="text-sm">
{activeTab === "전체"
? "등록된 작업지시가 없습니다."
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
</span>
</div>
)}
{/* 작업 카드 그리드 */}
{/* 작업 카드 */}
{filteredInstructions.length > 0 && (
<div
className="grid gap-4 flex-1"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
className={cn(
"flex-1 gap-4",
settings.layout === "grid" && "grid",
settings.layout === "list" && "flex flex-col",
settings.layout === "split" && "grid grid-cols-2",
)}
style={
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
}
>
{filteredInstructions.map((wi, idx) => (
<WorkCard
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
instruction={wi}
steps={processMap.get(wi.wi_id || wi.id) || []}
progress={computeProgress(wi.wi_id || wi.id, processMap)}
displayFields={settings.displayFields}
/>
))}
</div>
@@ -338,11 +354,11 @@ function SummaryCard({
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="bg-card flex items-center gap-4 rounded-lg border p-4">
<div className={cn("rounded-lg border p-2.5", 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>
<span className="text-muted-foreground text-xs">{label}</span>
<span className="text-foreground text-2xl font-bold">{value}</span>
</div>
</div>
);
@@ -353,10 +369,12 @@ function WorkCard({
instruction: wi,
steps,
progress,
displayFields: df,
}: {
instruction: WorkInstruction;
steps: ProcessStep[];
progress: "대기" | "진행중" | "완료";
displayFields: ProductionDisplayFields;
}) {
// API 응답은 flat 구조 (details 배열 아님)
const itemName = (wi as any).item_name || "-";
@@ -373,36 +391,28 @@ function WorkCard({
const currentStep = steps.find((s) => s.status !== "completed");
// 프로그레스바 색상
const barColor =
progressPercent >= 100
? "bg-emerald-500"
: progressPercent >= 50
? "bg-blue-500"
: "bg-amber-500";
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",
: "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="bg-card flex flex-col overflow-hidden rounded-lg border">
{/* 카드 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
<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" />
{df.workInstructionNo && (
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
)}
{df.priority && isUrgent && (
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
<AlertTriangle className="h-3 w-3" />
</Badge>
)}
@@ -413,82 +423,88 @@ function WorkCard({
</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 className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
{df.itemName && <InfoRow label="품목명" value={itemName} />}
{df.spec && <InfoRow label="규격" value={spec} />}
{df.customerName && <InfoRow label="거래처" value={customerName} />}
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
</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>
{df.processProgress && (
<div className="border-b px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium"></span>
{steps.length > 0 && (
<span className="text-muted-foreground text-xs">
{completedSteps}/{steps.length}
{currentStep && (
<span>
{" "}
· : <span className="text-blue-400">{currentStep.process_name}</span>
</span>
)}
</span>
)}
</div>
{steps.length > 0 ? (
<div className="flex flex-wrap gap-1">
{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(
"rounded px-2 py-0.5 text-xs font-medium transition-all",
isDone && "bg-emerald-500/20 text-emerald-400",
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
!isDone && !isCurrent && "bg-muted text-muted-foreground",
)}
>
{step.process_name}
</span>
);
})}
</div>
) : (
<span className="text-muted-foreground text-xs"> </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">
{completedQty} / {totalQty}
</span>
<span
className={cn(
"text-xs font-bold",
progressPercent >= 100
? "text-emerald-500"
: progressPercent >= 50
? "text-blue-500"
: "text-amber-500"
)}
>
{progressPercent}%
</span>
{df.progressBar && (
<div className="px-4 py-3">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{completedQty} / {totalQty}
</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="bg-muted h-2.5 w-full overflow-hidden rounded-full">
<div
className={cn("h-full rounded-full transition-all duration-500", barColor)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</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>
);
}
@@ -496,7 +512,7 @@ function WorkCard({
// ─── 정보 행 ───────────────────────────────────────────────
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-1.5 min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-muted-foreground shrink-0">{label}:</span>
<span className="text-foreground truncate">{value}</span>
</div>
@@ -4,23 +4,12 @@ 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 { 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";
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 {
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
/* ───── 유틸 ───── */
const fmt = (n: number) => n.toLocaleString("ko-KR");
const pct = (n: number) =>
`${n.toFixed(1)}%`;
const pct = (n: number) => `${n.toFixed(1)}%`;
const badgeVariant = (
type: "result" | "type" | "defectRate",
value: string | number,
) => {
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-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-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";
}
@@ -93,10 +76,15 @@ const badgeVariant = (
/* ───── 컴포넌트 ───── */
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(true);
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.post(
"/table-management/tables/work_order_process/data",
{ autoFilter: true },
);
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) {
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
/* ───── 자동 갱신 ───── */
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(fetchData, 30_000);
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [autoRefresh, fetchData]);
}, [autoRefresh, fetchData, settings.refreshInterval]);
/* ───── 검사 행 변환 ───── */
const inspectionRows: InspectionRow[] = useMemo(() => {
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
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
? "불합격"
: "합격";
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
return {
no: idx + 1,
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
/* ───── 렌더링 ───── */
return (
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
<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="text-xl font-bold text-gray-900 dark:text-white">
{" "}
<span className="text-emerald-600"></span>
<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="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<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", {
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
})}
</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" />
)}
<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",
)}
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
>
<Clock className="h-4 w-4 mr-1" />
<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 overflow-auto p-6 space-y-6">
<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>
<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>
)}
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
</p>
</div>
))}
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
"rounded-full px-4 py-1.5 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",
: "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}
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
</div>
{/* 테이블 영역 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
{/* 입고/출하 준비중 */}
{(activeTab === "incoming" || activeTab === "shipping") ? (
{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" />
<Search className="mb-4 h-12 w-12 opacity-40" />
<p className="text-lg font-medium"></p>
<p className="text-sm mt-1">
{activeTab === "incoming" ? "입고검사" : "출하검사"}
.
<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="h-10 w-10 animate-spin mb-4" />
<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="h-12 w-12 mb-4 opacity-40" />
<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="bg-gray-50 dark:bg-gray-700/50">
<TableRow className={theme.tableHeader}>
<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>
{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;
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}%` }}
/>
<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>
<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",
{
</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>
<TableCell className="text-sm text-gray-400">
{row.remark || "-"}
</TableCell>
})
: "-"}
</TableCell>
)}
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
</TableRow>
);
})}
@@ -12,16 +12,10 @@ 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";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
/* ───── 상태 정의 ───── */
@@ -134,11 +128,16 @@ interface WorkInstruction {
/* ───── 컴포넌트 ───── */
export default function EquipmentMonitoringPage() {
const { settings } = useMonitoringSettings("equipment");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
const df = settings.displayFields;
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 [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
const autoRefreshRef = useRef(autoRefresh);
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
fetchData();
}, [fetchData]);
/* ── 자동 갱신 (30초) ── */
/* ── 자동 갱신 ── */
useEffect(() => {
const interval = setInterval(() => {
if (autoRefreshRef.current) fetchData();
}, 30000);
}, settings.refreshInterval * 1000);
return () => clearInterval(interval);
}, [fetchData]);
}, [fetchData, settings.refreshInterval]);
/* ── 요약 통계 ── */
const stats = useMemo(() => {
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
/* ── 필터 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" },
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-700 hover:bg-emerald-500/30" },
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-700 hover:bg-amber-500/30" },
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-700 hover:bg-red-500/30" },
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-700 hover:bg-gray-500/30" },
];
/* ── 포맷 ── */
@@ -309,20 +308,26 @@ export default function EquipmentMonitoringPage() {
/* ────────────── 렌더 ────────────── */
return (
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
<div className={cn("min-h-screen space-y-5 p-4 md:p-6", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<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>
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}></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">
<div
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm",
theme.mutedText,
theme.cardBorder,
)}
>
<Clock className="h-4 w-4" />
<span className="font-mono">{formatDate(currentTime)}</span>
<span className="font-mono text-white">{formatTime(currentTime)}</span>
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
</div>
{/* 자동갱신 토글 */}
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
variant="outline"
size="sm"
className={cn(
"border-gray-700 text-xs gap-1.5",
"gap-1.5 text-xs",
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"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => setAutoRefresh((v) => !v)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
<span
className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "animate-pulse bg-emerald-400" : "bg-muted-foreground")}
/>
{autoRefresh ? "ON" : "OFF"}
</Button>
{/* 새로고침 */}
<Button variant="outline" size="sm" className="gap-1.5" onClick={fetchData} disabled={loading}>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</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}
className="gap-1.5"
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
<Settings2 className="h-4 w-4" />
</Button>
</div>
</header>
{/* ── 요약 카드 5개 ── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{summaryCards.map((card) => (
<button
key={card.label}
onClick={() =>
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
}
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"
"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="text-muted-foreground text-xs">{card.label}</p>
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
</div>
</button>
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
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"
? cn(pill.color, "ring-1 ring-foreground/10")
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{pill.label}
</button>
))}
<span className="ml-auto text-sm text-gray-600 self-center">
{filteredEquipments.length}
</span>
<span className="text-muted-foreground ml-auto self-center text-sm">{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>
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
<span className="text-muted-foreground ml-3"> ...</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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<p className="text-lg"> .</p>
</div>
)}
{/* ── 설비 카드 그리드 ── */}
{filteredEquipments.length > 0 && (
<div
className="grid gap-4"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
>
<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];
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
<div
key={eq.id}
className={cn(
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
cfg.border,
cfg.cardGlow
cfg.cardGlow,
)}
>
{/* 좌측 색상 바 */}
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
<div className={cn("absolute top-0 bottom-0 left-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 || "-"}
{df.equipmentName && (
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
{eq.equipment_name || "이름 없음"}
</h3>
)}
<p className={cn("mt-0.5 truncate text-xs", theme.mutedText)}>
{df.equipmentType && (eq.equipment_type || "-")}
{df.equipmentType && df.equipmentLocation && " · "}
{df.equipmentLocation && (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>
{df.operationStatus && (
<Badge
className={cn("ml-2 shrink-0 gap-1 border-0 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="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (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 className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
{df.dailyOperationTime && (
<div>
<span className={cn("text-xs", theme.mutedText)}> </span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.dailyProductionQty && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.worker && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>
{eqWIs.length > 0 && eqWIs[0].worker_name ? eqWIs[0].worker_name : "-"}
</p>
</div>
)}
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("truncate font-medium", theme.text)}>{eq.equipment_code || "-"}</p>
</div>
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 가동률 프로그레스 */}
{df.utilizationBar && (
<div className="px-4 py-2.5 pl-5">
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className={theme.mutedText}></span>
<span
className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : theme.mutedText)}
>
{utilization !== null ? `${utilization}%` : "-"}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
{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-border" />
{/* 현재 작업지시 */}
{df.currentWorkInstruction && (
<div className="px-4 py-2.5 pl-5">
<p className={cn("mb-1 text-xs", theme.mutedText)}> </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="shrink-0 font-mono text-xs text-blue-400">
{wi.instruction_number || "-"}
</span>
<span className={cn("truncate", theme.mutedText)}>{wi.item_name || "-"}</span>
</div>
))}
{eqWIs.length > 2 && <p className={cn("text-xs", theme.mutedText)}>+{eqWIs.length - 2} </p>}
</div>
) : (
<p className={cn("text-sm italic", theme.mutedText)}> </p>
)}
</div>
)}
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (PLC 미연동) */}
{df.sensorData && (
<div className="flex items-center gap-4 px-4 py-2.5 pl-5 text-xs">
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}>RPM</span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
</div>
)}
</div>
);
})}
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
{/* 필터 결과 없음 */}
{!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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
<Inbox className="mb-2 h-10 w-10" />
<p> .</p>
</div>
)}
@@ -5,6 +5,10 @@ import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import type { ProductionDisplayFields } from "@/types/monitoringSettings";
import {
RefreshCw,
Clock,
@@ -16,6 +20,7 @@ import {
TrendingUp,
Play,
Pause,
Settings2,
} from "lucide-react";
// ─── 타입 정의 ─────────────────────────────────────────────
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
}
// 작업지시별 공정현황으로 진행상태 계산
function computeProgress(
wiId: string,
processMap: Map<string, ProcessStep[]>
): "대기" | "진행중" | "완료" {
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;
@@ -85,11 +87,15 @@ function computeProgress(
// ─── 메인 컴포넌트 ────────────────────────────────────────
export default function ProductionMonitoringPage() {
const { settings } = useMonitoringSettings("production");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
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 [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
// ─── 실시간 시계 ─────────────────────────────────────────
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
// 작업지시 목록 조회
const wiRes = await apiClient.get("/work-instruction/list");
const wiRaw: WorkInstruction[] =
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
const seen = new Set<string>();
const wiData = wiRaw.filter((wi) => {
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
// 공정현황 조회 (실패해도 작업지시는 표시)
try {
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
page: 1, size: 1000, autoFilter: true,
page: 1,
size: 1000,
autoFilter: true,
});
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
fetchData();
}, [fetchData]);
// ─── 자동갱신 (30초) ─────────────────────────────────────
// ─── 자동갱신 ────────────────────────────────────────────
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(fetchData, 30000);
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
return () => clearInterval(timer);
}, [autoRefresh, fetchData]);
}, [autoRefresh, fetchData, settings.refreshInterval]);
// ─── 통계 계산 ───────────────────────────────────────────
const stats = useMemo(() => {
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
// ─── 렌더링 ──────────────────────────────────────────────
return (
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
{/* 헤더 */}
<div className="flex items-center justify-between flex-shrink-0">
<h1 className="text-2xl font-bold text-foreground"></h1>
<div className="flex flex-shrink-0 items-center justify-between">
<h1 className={cn("text-2xl font-bold", theme.headerText)}></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" />
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
<Clock className="h-4 w-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 variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
<Button
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
onClick={() => setAutoRefresh(!autoRefresh)}
className="gap-1.5"
>
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="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="grid grid-cols-4 gap-4 flex-shrink-0">
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
<SummaryCard
icon={<Timer className="w-5 h-5" />}
icon={<Timer className="h-5 w-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" />}
icon={<Loader2 className="h-5 w-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" />}
icon={<CheckCircle2 className="h-5 w-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" />}
icon={<TrendingUp className="h-5 w-5" />}
label="달성율"
value={`${stats.achievementRate}%`}
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
</div>
{/* 탭 필터 */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex flex-shrink-0 items-center gap-2">
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
<Button
key={tab}
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
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"
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
)}
>
{tab}
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
{/* 로딩 상태 */}
{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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
<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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<span className="text-sm">
{activeTab === "전체"
? "등록된 작업지시가 없습니다."
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
</span>
</div>
)}
{/* 작업 카드 그리드 */}
{/* 작업 카드 */}
{filteredInstructions.length > 0 && (
<div
className="grid gap-4 flex-1"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
className={cn(
"flex-1 gap-4",
settings.layout === "grid" && "grid",
settings.layout === "list" && "flex flex-col",
settings.layout === "split" && "grid grid-cols-2",
)}
style={
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
}
>
{filteredInstructions.map((wi, idx) => (
<WorkCard
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
instruction={wi}
steps={processMap.get(wi.wi_id || wi.id) || []}
progress={computeProgress(wi.wi_id || wi.id, processMap)}
displayFields={settings.displayFields}
/>
))}
</div>
@@ -338,11 +354,11 @@ function SummaryCard({
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="bg-card flex items-center gap-4 rounded-lg border p-4">
<div className={cn("rounded-lg border p-2.5", 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>
<span className="text-muted-foreground text-xs">{label}</span>
<span className="text-foreground text-2xl font-bold">{value}</span>
</div>
</div>
);
@@ -353,10 +369,12 @@ function WorkCard({
instruction: wi,
steps,
progress,
displayFields: df,
}: {
instruction: WorkInstruction;
steps: ProcessStep[];
progress: "대기" | "진행중" | "완료";
displayFields: ProductionDisplayFields;
}) {
// API 응답은 flat 구조 (details 배열 아님)
const itemName = (wi as any).item_name || "-";
@@ -373,36 +391,28 @@ function WorkCard({
const currentStep = steps.find((s) => s.status !== "completed");
// 프로그레스바 색상
const barColor =
progressPercent >= 100
? "bg-emerald-500"
: progressPercent >= 50
? "bg-blue-500"
: "bg-amber-500";
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",
: "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="bg-card flex flex-col overflow-hidden rounded-lg border">
{/* 카드 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
<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" />
{df.workInstructionNo && (
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
)}
{df.priority && isUrgent && (
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
<AlertTriangle className="h-3 w-3" />
</Badge>
)}
@@ -413,82 +423,88 @@ function WorkCard({
</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 className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
{df.itemName && <InfoRow label="품목명" value={itemName} />}
{df.spec && <InfoRow label="규격" value={spec} />}
{df.customerName && <InfoRow label="거래처" value={customerName} />}
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
</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>
{df.processProgress && (
<div className="border-b px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium"></span>
{steps.length > 0 && (
<span className="text-muted-foreground text-xs">
{completedSteps}/{steps.length}
{currentStep && (
<span>
{" "}
· : <span className="text-blue-400">{currentStep.process_name}</span>
</span>
)}
</span>
)}
</div>
{steps.length > 0 ? (
<div className="flex flex-wrap gap-1">
{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(
"rounded px-2 py-0.5 text-xs font-medium transition-all",
isDone && "bg-emerald-500/20 text-emerald-400",
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
!isDone && !isCurrent && "bg-muted text-muted-foreground",
)}
>
{step.process_name}
</span>
);
})}
</div>
) : (
<span className="text-muted-foreground text-xs"> </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">
{completedQty} / {totalQty}
</span>
<span
className={cn(
"text-xs font-bold",
progressPercent >= 100
? "text-emerald-500"
: progressPercent >= 50
? "text-blue-500"
: "text-amber-500"
)}
>
{progressPercent}%
</span>
{df.progressBar && (
<div className="px-4 py-3">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{completedQty} / {totalQty}
</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="bg-muted h-2.5 w-full overflow-hidden rounded-full">
<div
className={cn("h-full rounded-full transition-all duration-500", barColor)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</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>
);
}
@@ -496,7 +512,7 @@ function WorkCard({
// ─── 정보 행 ───────────────────────────────────────────────
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-1.5 min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-muted-foreground shrink-0">{label}:</span>
<span className="text-foreground truncate">{value}</span>
</div>
@@ -4,23 +4,12 @@ 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 { 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";
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 {
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
/* ───── 유틸 ───── */
const fmt = (n: number) => n.toLocaleString("ko-KR");
const pct = (n: number) =>
`${n.toFixed(1)}%`;
const pct = (n: number) => `${n.toFixed(1)}%`;
const badgeVariant = (
type: "result" | "type" | "defectRate",
value: string | number,
) => {
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-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-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";
}
@@ -93,10 +76,15 @@ const badgeVariant = (
/* ───── 컴포넌트 ───── */
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(true);
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.post(
"/table-management/tables/work_order_process/data",
{ autoFilter: true },
);
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) {
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
/* ───── 자동 갱신 ───── */
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(fetchData, 30_000);
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [autoRefresh, fetchData]);
}, [autoRefresh, fetchData, settings.refreshInterval]);
/* ───── 검사 행 변환 ───── */
const inspectionRows: InspectionRow[] = useMemo(() => {
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
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
? "불합격"
: "합격";
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
return {
no: idx + 1,
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
/* ───── 렌더링 ───── */
return (
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
<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="text-xl font-bold text-gray-900 dark:text-white">
{" "}
<span className="text-emerald-600"></span>
<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="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<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", {
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
})}
</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" />
)}
<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",
)}
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
>
<Clock className="h-4 w-4 mr-1" />
<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 overflow-auto p-6 space-y-6">
<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>
<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>
)}
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
</p>
</div>
))}
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
"rounded-full px-4 py-1.5 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",
: "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}
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
</div>
{/* 테이블 영역 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
{/* 입고/출하 준비중 */}
{(activeTab === "incoming" || activeTab === "shipping") ? (
{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" />
<Search className="mb-4 h-12 w-12 opacity-40" />
<p className="text-lg font-medium"></p>
<p className="text-sm mt-1">
{activeTab === "incoming" ? "입고검사" : "출하검사"}
.
<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="h-10 w-10 animate-spin mb-4" />
<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="h-12 w-12 mb-4 opacity-40" />
<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="bg-gray-50 dark:bg-gray-700/50">
<TableRow className={theme.tableHeader}>
<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>
{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;
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}%` }}
/>
<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>
<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",
{
</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>
<TableCell className="text-sm text-gray-400">
{row.remark || "-"}
</TableCell>
})
: "-"}
</TableCell>
)}
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
</TableRow>
);
})}
@@ -12,16 +12,10 @@ 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";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
/* ───── 상태 정의 ───── */
@@ -134,11 +128,16 @@ interface WorkInstruction {
/* ───── 컴포넌트 ───── */
export default function EquipmentMonitoringPage() {
const { settings } = useMonitoringSettings("equipment");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
const df = settings.displayFields;
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 [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
const autoRefreshRef = useRef(autoRefresh);
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
fetchData();
}, [fetchData]);
/* ── 자동 갱신 (30초) ── */
/* ── 자동 갱신 ── */
useEffect(() => {
const interval = setInterval(() => {
if (autoRefreshRef.current) fetchData();
}, 30000);
}, settings.refreshInterval * 1000);
return () => clearInterval(interval);
}, [fetchData]);
}, [fetchData, settings.refreshInterval]);
/* ── 요약 통계 ── */
const stats = useMemo(() => {
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
/* ── 필터 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" },
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-700 hover:bg-emerald-500/30" },
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-700 hover:bg-amber-500/30" },
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-700 hover:bg-red-500/30" },
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-700 hover:bg-gray-500/30" },
];
/* ── 포맷 ── */
@@ -309,20 +308,26 @@ export default function EquipmentMonitoringPage() {
/* ────────────── 렌더 ────────────── */
return (
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
<div className={cn("min-h-screen space-y-5 p-4 md:p-6", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<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>
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}></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">
<div
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm",
theme.mutedText,
theme.cardBorder,
)}
>
<Clock className="h-4 w-4" />
<span className="font-mono">{formatDate(currentTime)}</span>
<span className="font-mono text-white">{formatTime(currentTime)}</span>
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
</div>
{/* 자동갱신 토글 */}
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
variant="outline"
size="sm"
className={cn(
"border-gray-700 text-xs gap-1.5",
"gap-1.5 text-xs",
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"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => setAutoRefresh((v) => !v)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
<span
className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "animate-pulse bg-emerald-400" : "bg-muted-foreground")}
/>
{autoRefresh ? "ON" : "OFF"}
</Button>
{/* 새로고침 */}
<Button variant="outline" size="sm" className="gap-1.5" onClick={fetchData} disabled={loading}>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</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}
className="gap-1.5"
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
<Settings2 className="h-4 w-4" />
</Button>
</div>
</header>
{/* ── 요약 카드 5개 ── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{summaryCards.map((card) => (
<button
key={card.label}
onClick={() =>
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
}
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"
"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="text-muted-foreground text-xs">{card.label}</p>
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
</div>
</button>
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
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"
? cn(pill.color, "ring-1 ring-foreground/10")
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{pill.label}
</button>
))}
<span className="ml-auto text-sm text-gray-600 self-center">
{filteredEquipments.length}
</span>
<span className="text-muted-foreground ml-auto self-center text-sm">{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>
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
<span className="text-muted-foreground ml-3"> ...</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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<p className="text-lg"> .</p>
</div>
)}
{/* ── 설비 카드 그리드 ── */}
{filteredEquipments.length > 0 && (
<div
className="grid gap-4"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
>
<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];
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
<div
key={eq.id}
className={cn(
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
cfg.border,
cfg.cardGlow
cfg.cardGlow,
)}
>
{/* 좌측 색상 바 */}
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
<div className={cn("absolute top-0 bottom-0 left-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 || "-"}
{df.equipmentName && (
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
{eq.equipment_name || "이름 없음"}
</h3>
)}
<p className={cn("mt-0.5 truncate text-xs", theme.mutedText)}>
{df.equipmentType && (eq.equipment_type || "-")}
{df.equipmentType && df.equipmentLocation && " · "}
{df.equipmentLocation && (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>
{df.operationStatus && (
<Badge
className={cn("ml-2 shrink-0 gap-1 border-0 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="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (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 className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
{df.dailyOperationTime && (
<div>
<span className={cn("text-xs", theme.mutedText)}> </span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.dailyProductionQty && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.worker && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>
{eqWIs.length > 0 && eqWIs[0].worker_name ? eqWIs[0].worker_name : "-"}
</p>
</div>
)}
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("truncate font-medium", theme.text)}>{eq.equipment_code || "-"}</p>
</div>
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 가동률 프로그레스 */}
{df.utilizationBar && (
<div className="px-4 py-2.5 pl-5">
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className={theme.mutedText}></span>
<span
className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : theme.mutedText)}
>
{utilization !== null ? `${utilization}%` : "-"}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
{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-border" />
{/* 현재 작업지시 */}
{df.currentWorkInstruction && (
<div className="px-4 py-2.5 pl-5">
<p className={cn("mb-1 text-xs", theme.mutedText)}> </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="shrink-0 font-mono text-xs text-blue-400">
{wi.instruction_number || "-"}
</span>
<span className={cn("truncate", theme.mutedText)}>{wi.item_name || "-"}</span>
</div>
))}
{eqWIs.length > 2 && <p className={cn("text-xs", theme.mutedText)}>+{eqWIs.length - 2} </p>}
</div>
) : (
<p className={cn("text-sm italic", theme.mutedText)}> </p>
)}
</div>
)}
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (PLC 미연동) */}
{df.sensorData && (
<div className="flex items-center gap-4 px-4 py-2.5 pl-5 text-xs">
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}>RPM</span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
</div>
)}
</div>
);
})}
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
{/* 필터 결과 없음 */}
{!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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
<Inbox className="mb-2 h-10 w-10" />
<p> .</p>
</div>
)}
@@ -5,6 +5,10 @@ import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import type { ProductionDisplayFields } from "@/types/monitoringSettings";
import {
RefreshCw,
Clock,
@@ -16,6 +20,7 @@ import {
TrendingUp,
Play,
Pause,
Settings2,
} from "lucide-react";
// ─── 타입 정의 ─────────────────────────────────────────────
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
}
// 작업지시별 공정현황으로 진행상태 계산
function computeProgress(
wiId: string,
processMap: Map<string, ProcessStep[]>
): "대기" | "진행중" | "완료" {
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;
@@ -85,11 +87,15 @@ function computeProgress(
// ─── 메인 컴포넌트 ────────────────────────────────────────
export default function ProductionMonitoringPage() {
const { settings } = useMonitoringSettings("production");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
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 [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
// ─── 실시간 시계 ─────────────────────────────────────────
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
// 작업지시 목록 조회
const wiRes = await apiClient.get("/work-instruction/list");
const wiRaw: WorkInstruction[] =
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
const seen = new Set<string>();
const wiData = wiRaw.filter((wi) => {
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
// 공정현황 조회 (실패해도 작업지시는 표시)
try {
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
page: 1, size: 1000, autoFilter: true,
page: 1,
size: 1000,
autoFilter: true,
});
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
fetchData();
}, [fetchData]);
// ─── 자동갱신 (30초) ─────────────────────────────────────
// ─── 자동갱신 ────────────────────────────────────────────
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(fetchData, 30000);
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
return () => clearInterval(timer);
}, [autoRefresh, fetchData]);
}, [autoRefresh, fetchData, settings.refreshInterval]);
// ─── 통계 계산 ───────────────────────────────────────────
const stats = useMemo(() => {
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
// ─── 렌더링 ──────────────────────────────────────────────
return (
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
{/* 헤더 */}
<div className="flex items-center justify-between flex-shrink-0">
<h1 className="text-2xl font-bold text-foreground"></h1>
<div className="flex flex-shrink-0 items-center justify-between">
<h1 className={cn("text-2xl font-bold", theme.headerText)}></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" />
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
<Clock className="h-4 w-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 variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
<Button
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
onClick={() => setAutoRefresh(!autoRefresh)}
className="gap-1.5"
>
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="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="grid grid-cols-4 gap-4 flex-shrink-0">
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
<SummaryCard
icon={<Timer className="w-5 h-5" />}
icon={<Timer className="h-5 w-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" />}
icon={<Loader2 className="h-5 w-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" />}
icon={<CheckCircle2 className="h-5 w-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" />}
icon={<TrendingUp className="h-5 w-5" />}
label="달성율"
value={`${stats.achievementRate}%`}
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
</div>
{/* 탭 필터 */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex flex-shrink-0 items-center gap-2">
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
<Button
key={tab}
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
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"
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
)}
>
{tab}
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
{/* 로딩 상태 */}
{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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
<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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<span className="text-sm">
{activeTab === "전체"
? "등록된 작업지시가 없습니다."
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
</span>
</div>
)}
{/* 작업 카드 그리드 */}
{/* 작업 카드 */}
{filteredInstructions.length > 0 && (
<div
className="grid gap-4 flex-1"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
className={cn(
"flex-1 gap-4",
settings.layout === "grid" && "grid",
settings.layout === "list" && "flex flex-col",
settings.layout === "split" && "grid grid-cols-2",
)}
style={
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
}
>
{filteredInstructions.map((wi, idx) => (
<WorkCard
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
instruction={wi}
steps={processMap.get(wi.wi_id || wi.id) || []}
progress={computeProgress(wi.wi_id || wi.id, processMap)}
displayFields={settings.displayFields}
/>
))}
</div>
@@ -338,11 +354,11 @@ function SummaryCard({
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="bg-card flex items-center gap-4 rounded-lg border p-4">
<div className={cn("rounded-lg border p-2.5", 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>
<span className="text-muted-foreground text-xs">{label}</span>
<span className="text-foreground text-2xl font-bold">{value}</span>
</div>
</div>
);
@@ -353,10 +369,12 @@ function WorkCard({
instruction: wi,
steps,
progress,
displayFields: df,
}: {
instruction: WorkInstruction;
steps: ProcessStep[];
progress: "대기" | "진행중" | "완료";
displayFields: ProductionDisplayFields;
}) {
// API 응답은 flat 구조 (details 배열 아님)
const itemName = (wi as any).item_name || "-";
@@ -373,36 +391,28 @@ function WorkCard({
const currentStep = steps.find((s) => s.status !== "completed");
// 프로그레스바 색상
const barColor =
progressPercent >= 100
? "bg-emerald-500"
: progressPercent >= 50
? "bg-blue-500"
: "bg-amber-500";
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",
: "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="bg-card flex flex-col overflow-hidden rounded-lg border">
{/* 카드 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
<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" />
{df.workInstructionNo && (
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
)}
{df.priority && isUrgent && (
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
<AlertTriangle className="h-3 w-3" />
</Badge>
)}
@@ -413,82 +423,88 @@ function WorkCard({
</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 className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
{df.itemName && <InfoRow label="품목명" value={itemName} />}
{df.spec && <InfoRow label="규격" value={spec} />}
{df.customerName && <InfoRow label="거래처" value={customerName} />}
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
</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>
{df.processProgress && (
<div className="border-b px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium"></span>
{steps.length > 0 && (
<span className="text-muted-foreground text-xs">
{completedSteps}/{steps.length}
{currentStep && (
<span>
{" "}
· : <span className="text-blue-400">{currentStep.process_name}</span>
</span>
)}
</span>
)}
</div>
{steps.length > 0 ? (
<div className="flex flex-wrap gap-1">
{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(
"rounded px-2 py-0.5 text-xs font-medium transition-all",
isDone && "bg-emerald-500/20 text-emerald-400",
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
!isDone && !isCurrent && "bg-muted text-muted-foreground",
)}
>
{step.process_name}
</span>
);
})}
</div>
) : (
<span className="text-muted-foreground text-xs"> </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">
{completedQty} / {totalQty}
</span>
<span
className={cn(
"text-xs font-bold",
progressPercent >= 100
? "text-emerald-500"
: progressPercent >= 50
? "text-blue-500"
: "text-amber-500"
)}
>
{progressPercent}%
</span>
{df.progressBar && (
<div className="px-4 py-3">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{completedQty} / {totalQty}
</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="bg-muted h-2.5 w-full overflow-hidden rounded-full">
<div
className={cn("h-full rounded-full transition-all duration-500", barColor)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</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>
);
}
@@ -496,7 +512,7 @@ function WorkCard({
// ─── 정보 행 ───────────────────────────────────────────────
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-1.5 min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-muted-foreground shrink-0">{label}:</span>
<span className="text-foreground truncate">{value}</span>
</div>
@@ -4,23 +4,12 @@ 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 { 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";
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 {
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
/* ───── 유틸 ───── */
const fmt = (n: number) => n.toLocaleString("ko-KR");
const pct = (n: number) =>
`${n.toFixed(1)}%`;
const pct = (n: number) => `${n.toFixed(1)}%`;
const badgeVariant = (
type: "result" | "type" | "defectRate",
value: string | number,
) => {
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-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-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";
}
@@ -93,10 +76,15 @@ const badgeVariant = (
/* ───── 컴포넌트 ───── */
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(true);
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.post(
"/table-management/tables/work_order_process/data",
{ autoFilter: true },
);
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) {
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
/* ───── 자동 갱신 ───── */
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(fetchData, 30_000);
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [autoRefresh, fetchData]);
}, [autoRefresh, fetchData, settings.refreshInterval]);
/* ───── 검사 행 변환 ───── */
const inspectionRows: InspectionRow[] = useMemo(() => {
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
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
? "불합격"
: "합격";
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
return {
no: idx + 1,
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
/* ───── 렌더링 ───── */
return (
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
<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="text-xl font-bold text-gray-900 dark:text-white">
{" "}
<span className="text-emerald-600"></span>
<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="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<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", {
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
})}
</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" />
)}
<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",
)}
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
>
<Clock className="h-4 w-4 mr-1" />
<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 overflow-auto p-6 space-y-6">
<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>
<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>
)}
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
</p>
</div>
))}
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
"rounded-full px-4 py-1.5 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",
: "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}
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
</div>
{/* 테이블 영역 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
{/* 입고/출하 준비중 */}
{(activeTab === "incoming" || activeTab === "shipping") ? (
{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" />
<Search className="mb-4 h-12 w-12 opacity-40" />
<p className="text-lg font-medium"></p>
<p className="text-sm mt-1">
{activeTab === "incoming" ? "입고검사" : "출하검사"}
.
<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="h-10 w-10 animate-spin mb-4" />
<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="h-12 w-12 mb-4 opacity-40" />
<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="bg-gray-50 dark:bg-gray-700/50">
<TableRow className={theme.tableHeader}>
<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>
{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;
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}%` }}
/>
<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>
<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",
{
</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>
<TableCell className="text-sm text-gray-400">
{row.remark || "-"}
</TableCell>
})
: "-"}
</TableCell>
)}
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
</TableRow>
);
})}
@@ -12,16 +12,10 @@ 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";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
/* ───── 상태 정의 ───── */
@@ -134,11 +128,16 @@ interface WorkInstruction {
/* ───── 컴포넌트 ───── */
export default function EquipmentMonitoringPage() {
const { settings } = useMonitoringSettings("equipment");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
const df = settings.displayFields;
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 [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
const autoRefreshRef = useRef(autoRefresh);
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
fetchData();
}, [fetchData]);
/* ── 자동 갱신 (30초) ── */
/* ── 자동 갱신 ── */
useEffect(() => {
const interval = setInterval(() => {
if (autoRefreshRef.current) fetchData();
}, 30000);
}, settings.refreshInterval * 1000);
return () => clearInterval(interval);
}, [fetchData]);
}, [fetchData, settings.refreshInterval]);
/* ── 요약 통계 ── */
const stats = useMemo(() => {
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
/* ── 필터 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" },
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-700 hover:bg-emerald-500/30" },
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-700 hover:bg-amber-500/30" },
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-700 hover:bg-red-500/30" },
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-700 hover:bg-gray-500/30" },
];
/* ── 포맷 ── */
@@ -309,20 +308,26 @@ export default function EquipmentMonitoringPage() {
/* ────────────── 렌더 ────────────── */
return (
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
<div className={cn("min-h-screen space-y-5 p-4 md:p-6", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<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>
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}></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">
<div
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm",
theme.mutedText,
theme.cardBorder,
)}
>
<Clock className="h-4 w-4" />
<span className="font-mono">{formatDate(currentTime)}</span>
<span className="font-mono text-white">{formatTime(currentTime)}</span>
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
</div>
{/* 자동갱신 토글 */}
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
variant="outline"
size="sm"
className={cn(
"border-gray-700 text-xs gap-1.5",
"gap-1.5 text-xs",
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"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => setAutoRefresh((v) => !v)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
<span
className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "animate-pulse bg-emerald-400" : "bg-muted-foreground")}
/>
{autoRefresh ? "ON" : "OFF"}
</Button>
{/* 새로고침 */}
<Button variant="outline" size="sm" className="gap-1.5" onClick={fetchData} disabled={loading}>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</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}
className="gap-1.5"
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
<Settings2 className="h-4 w-4" />
</Button>
</div>
</header>
{/* ── 요약 카드 5개 ── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{summaryCards.map((card) => (
<button
key={card.label}
onClick={() =>
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
}
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"
"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="text-muted-foreground text-xs">{card.label}</p>
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
</div>
</button>
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
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"
? cn(pill.color, "ring-1 ring-foreground/10")
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{pill.label}
</button>
))}
<span className="ml-auto text-sm text-gray-600 self-center">
{filteredEquipments.length}
</span>
<span className="text-muted-foreground ml-auto self-center text-sm">{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>
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
<span className="text-muted-foreground ml-3"> ...</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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<p className="text-lg"> .</p>
</div>
)}
{/* ── 설비 카드 그리드 ── */}
{filteredEquipments.length > 0 && (
<div
className="grid gap-4"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
>
<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];
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
<div
key={eq.id}
className={cn(
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
cfg.border,
cfg.cardGlow
cfg.cardGlow,
)}
>
{/* 좌측 색상 바 */}
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
<div className={cn("absolute top-0 bottom-0 left-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 || "-"}
{df.equipmentName && (
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
{eq.equipment_name || "이름 없음"}
</h3>
)}
<p className={cn("mt-0.5 truncate text-xs", theme.mutedText)}>
{df.equipmentType && (eq.equipment_type || "-")}
{df.equipmentType && df.equipmentLocation && " · "}
{df.equipmentLocation && (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>
{df.operationStatus && (
<Badge
className={cn("ml-2 shrink-0 gap-1 border-0 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="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (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 className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
{df.dailyOperationTime && (
<div>
<span className={cn("text-xs", theme.mutedText)}> </span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.dailyProductionQty && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.worker && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>
{eqWIs.length > 0 && eqWIs[0].worker_name ? eqWIs[0].worker_name : "-"}
</p>
</div>
)}
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("truncate font-medium", theme.text)}>{eq.equipment_code || "-"}</p>
</div>
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 가동률 프로그레스 */}
{df.utilizationBar && (
<div className="px-4 py-2.5 pl-5">
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className={theme.mutedText}></span>
<span
className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : theme.mutedText)}
>
{utilization !== null ? `${utilization}%` : "-"}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
{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-border" />
{/* 현재 작업지시 */}
{df.currentWorkInstruction && (
<div className="px-4 py-2.5 pl-5">
<p className={cn("mb-1 text-xs", theme.mutedText)}> </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="shrink-0 font-mono text-xs text-blue-400">
{wi.instruction_number || "-"}
</span>
<span className={cn("truncate", theme.mutedText)}>{wi.item_name || "-"}</span>
</div>
))}
{eqWIs.length > 2 && <p className={cn("text-xs", theme.mutedText)}>+{eqWIs.length - 2} </p>}
</div>
) : (
<p className={cn("text-sm italic", theme.mutedText)}> </p>
)}
</div>
)}
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (PLC 미연동) */}
{df.sensorData && (
<div className="flex items-center gap-4 px-4 py-2.5 pl-5 text-xs">
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}>RPM</span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
</div>
)}
</div>
);
})}
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
{/* 필터 결과 없음 */}
{!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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
<Inbox className="mb-2 h-10 w-10" />
<p> .</p>
</div>
)}
@@ -5,6 +5,10 @@ import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import type { ProductionDisplayFields } from "@/types/monitoringSettings";
import {
RefreshCw,
Clock,
@@ -16,6 +20,7 @@ import {
TrendingUp,
Play,
Pause,
Settings2,
} from "lucide-react";
// ─── 타입 정의 ─────────────────────────────────────────────
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
}
// 작업지시별 공정현황으로 진행상태 계산
function computeProgress(
wiId: string,
processMap: Map<string, ProcessStep[]>
): "대기" | "진행중" | "완료" {
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;
@@ -85,11 +87,15 @@ function computeProgress(
// ─── 메인 컴포넌트 ────────────────────────────────────────
export default function ProductionMonitoringPage() {
const { settings } = useMonitoringSettings("production");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
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 [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
// ─── 실시간 시계 ─────────────────────────────────────────
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
// 작업지시 목록 조회
const wiRes = await apiClient.get("/work-instruction/list");
const wiRaw: WorkInstruction[] =
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
const seen = new Set<string>();
const wiData = wiRaw.filter((wi) => {
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
// 공정현황 조회 (실패해도 작업지시는 표시)
try {
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
page: 1, size: 1000, autoFilter: true,
page: 1,
size: 1000,
autoFilter: true,
});
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
fetchData();
}, [fetchData]);
// ─── 자동갱신 (30초) ─────────────────────────────────────
// ─── 자동갱신 ────────────────────────────────────────────
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(fetchData, 30000);
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
return () => clearInterval(timer);
}, [autoRefresh, fetchData]);
}, [autoRefresh, fetchData, settings.refreshInterval]);
// ─── 통계 계산 ───────────────────────────────────────────
const stats = useMemo(() => {
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
// ─── 렌더링 ──────────────────────────────────────────────
return (
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
{/* 헤더 */}
<div className="flex items-center justify-between flex-shrink-0">
<h1 className="text-2xl font-bold text-foreground"></h1>
<div className="flex flex-shrink-0 items-center justify-between">
<h1 className={cn("text-2xl font-bold", theme.headerText)}></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" />
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
<Clock className="h-4 w-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 variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
<Button
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
onClick={() => setAutoRefresh(!autoRefresh)}
className="gap-1.5"
>
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="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="grid grid-cols-4 gap-4 flex-shrink-0">
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
<SummaryCard
icon={<Timer className="w-5 h-5" />}
icon={<Timer className="h-5 w-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" />}
icon={<Loader2 className="h-5 w-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" />}
icon={<CheckCircle2 className="h-5 w-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" />}
icon={<TrendingUp className="h-5 w-5" />}
label="달성율"
value={`${stats.achievementRate}%`}
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
</div>
{/* 탭 필터 */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex flex-shrink-0 items-center gap-2">
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
<Button
key={tab}
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
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"
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
)}
>
{tab}
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
{/* 로딩 상태 */}
{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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
<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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<span className="text-sm">
{activeTab === "전체"
? "등록된 작업지시가 없습니다."
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
</span>
</div>
)}
{/* 작업 카드 그리드 */}
{/* 작업 카드 */}
{filteredInstructions.length > 0 && (
<div
className="grid gap-4 flex-1"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
className={cn(
"flex-1 gap-4",
settings.layout === "grid" && "grid",
settings.layout === "list" && "flex flex-col",
settings.layout === "split" && "grid grid-cols-2",
)}
style={
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
}
>
{filteredInstructions.map((wi, idx) => (
<WorkCard
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
instruction={wi}
steps={processMap.get(wi.wi_id || wi.id) || []}
progress={computeProgress(wi.wi_id || wi.id, processMap)}
displayFields={settings.displayFields}
/>
))}
</div>
@@ -338,11 +354,11 @@ function SummaryCard({
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="bg-card flex items-center gap-4 rounded-lg border p-4">
<div className={cn("rounded-lg border p-2.5", 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>
<span className="text-muted-foreground text-xs">{label}</span>
<span className="text-foreground text-2xl font-bold">{value}</span>
</div>
</div>
);
@@ -353,10 +369,12 @@ function WorkCard({
instruction: wi,
steps,
progress,
displayFields: df,
}: {
instruction: WorkInstruction;
steps: ProcessStep[];
progress: "대기" | "진행중" | "완료";
displayFields: ProductionDisplayFields;
}) {
// API 응답은 flat 구조 (details 배열 아님)
const itemName = (wi as any).item_name || "-";
@@ -373,36 +391,28 @@ function WorkCard({
const currentStep = steps.find((s) => s.status !== "completed");
// 프로그레스바 색상
const barColor =
progressPercent >= 100
? "bg-emerald-500"
: progressPercent >= 50
? "bg-blue-500"
: "bg-amber-500";
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",
: "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="bg-card flex flex-col overflow-hidden rounded-lg border">
{/* 카드 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
<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" />
{df.workInstructionNo && (
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
)}
{df.priority && isUrgent && (
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
<AlertTriangle className="h-3 w-3" />
</Badge>
)}
@@ -413,82 +423,88 @@ function WorkCard({
</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 className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
{df.itemName && <InfoRow label="품목명" value={itemName} />}
{df.spec && <InfoRow label="규격" value={spec} />}
{df.customerName && <InfoRow label="거래처" value={customerName} />}
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
</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>
{df.processProgress && (
<div className="border-b px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium"></span>
{steps.length > 0 && (
<span className="text-muted-foreground text-xs">
{completedSteps}/{steps.length}
{currentStep && (
<span>
{" "}
· : <span className="text-blue-400">{currentStep.process_name}</span>
</span>
)}
</span>
)}
</div>
{steps.length > 0 ? (
<div className="flex flex-wrap gap-1">
{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(
"rounded px-2 py-0.5 text-xs font-medium transition-all",
isDone && "bg-emerald-500/20 text-emerald-400",
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
!isDone && !isCurrent && "bg-muted text-muted-foreground",
)}
>
{step.process_name}
</span>
);
})}
</div>
) : (
<span className="text-muted-foreground text-xs"> </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">
{completedQty} / {totalQty}
</span>
<span
className={cn(
"text-xs font-bold",
progressPercent >= 100
? "text-emerald-500"
: progressPercent >= 50
? "text-blue-500"
: "text-amber-500"
)}
>
{progressPercent}%
</span>
{df.progressBar && (
<div className="px-4 py-3">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{completedQty} / {totalQty}
</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="bg-muted h-2.5 w-full overflow-hidden rounded-full">
<div
className={cn("h-full rounded-full transition-all duration-500", barColor)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</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>
);
}
@@ -496,7 +512,7 @@ function WorkCard({
// ─── 정보 행 ───────────────────────────────────────────────
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-1.5 min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-muted-foreground shrink-0">{label}:</span>
<span className="text-foreground truncate">{value}</span>
</div>
@@ -4,23 +4,12 @@ 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 { 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";
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 {
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
/* ───── 유틸 ───── */
const fmt = (n: number) => n.toLocaleString("ko-KR");
const pct = (n: number) =>
`${n.toFixed(1)}%`;
const pct = (n: number) => `${n.toFixed(1)}%`;
const badgeVariant = (
type: "result" | "type" | "defectRate",
value: string | number,
) => {
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-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-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";
}
@@ -93,10 +76,15 @@ const badgeVariant = (
/* ───── 컴포넌트 ───── */
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(true);
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.post(
"/table-management/tables/work_order_process/data",
{ autoFilter: true },
);
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) {
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
/* ───── 자동 갱신 ───── */
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(fetchData, 30_000);
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [autoRefresh, fetchData]);
}, [autoRefresh, fetchData, settings.refreshInterval]);
/* ───── 검사 행 변환 ───── */
const inspectionRows: InspectionRow[] = useMemo(() => {
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
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
? "불합격"
: "합격";
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
return {
no: idx + 1,
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
/* ───── 렌더링 ───── */
return (
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
<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="text-xl font-bold text-gray-900 dark:text-white">
{" "}
<span className="text-emerald-600"></span>
<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="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<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", {
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
})}
</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" />
)}
<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",
)}
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
>
<Clock className="h-4 w-4 mr-1" />
<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 overflow-auto p-6 space-y-6">
<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>
<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>
)}
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
</p>
</div>
))}
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
"rounded-full px-4 py-1.5 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",
: "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}
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
</div>
{/* 테이블 영역 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
{/* 입고/출하 준비중 */}
{(activeTab === "incoming" || activeTab === "shipping") ? (
{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" />
<Search className="mb-4 h-12 w-12 opacity-40" />
<p className="text-lg font-medium"></p>
<p className="text-sm mt-1">
{activeTab === "incoming" ? "입고검사" : "출하검사"}
.
<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="h-10 w-10 animate-spin mb-4" />
<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="h-12 w-12 mb-4 opacity-40" />
<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="bg-gray-50 dark:bg-gray-700/50">
<TableRow className={theme.tableHeader}>
<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>
{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;
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}%` }}
/>
<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>
<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",
{
</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>
<TableCell className="text-sm text-gray-400">
{row.remark || "-"}
</TableCell>
})
: "-"}
</TableCell>
)}
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
</TableRow>
);
})}
@@ -12,16 +12,10 @@ 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";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
/* ───── 상태 정의 ───── */
@@ -134,11 +128,16 @@ interface WorkInstruction {
/* ───── 컴포넌트 ───── */
export default function EquipmentMonitoringPage() {
const { settings } = useMonitoringSettings("equipment");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
const df = settings.displayFields;
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 [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
const autoRefreshRef = useRef(autoRefresh);
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
fetchData();
}, [fetchData]);
/* ── 자동 갱신 (30초) ── */
/* ── 자동 갱신 ── */
useEffect(() => {
const interval = setInterval(() => {
if (autoRefreshRef.current) fetchData();
}, 30000);
}, settings.refreshInterval * 1000);
return () => clearInterval(interval);
}, [fetchData]);
}, [fetchData, settings.refreshInterval]);
/* ── 요약 통계 ── */
const stats = useMemo(() => {
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
/* ── 필터 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" },
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-700 hover:bg-emerald-500/30" },
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-700 hover:bg-amber-500/30" },
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-700 hover:bg-red-500/30" },
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-700 hover:bg-gray-500/30" },
];
/* ── 포맷 ── */
@@ -309,20 +308,26 @@ export default function EquipmentMonitoringPage() {
/* ────────────── 렌더 ────────────── */
return (
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
<div className={cn("min-h-screen space-y-5 p-4 md:p-6", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<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>
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}></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">
<div
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm",
theme.mutedText,
theme.cardBorder,
)}
>
<Clock className="h-4 w-4" />
<span className="font-mono">{formatDate(currentTime)}</span>
<span className="font-mono text-white">{formatTime(currentTime)}</span>
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
</div>
{/* 자동갱신 토글 */}
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
variant="outline"
size="sm"
className={cn(
"border-gray-700 text-xs gap-1.5",
"gap-1.5 text-xs",
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"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => setAutoRefresh((v) => !v)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
<span
className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "animate-pulse bg-emerald-400" : "bg-muted-foreground")}
/>
{autoRefresh ? "ON" : "OFF"}
</Button>
{/* 새로고침 */}
<Button variant="outline" size="sm" className="gap-1.5" onClick={fetchData} disabled={loading}>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</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}
className="gap-1.5"
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
<Settings2 className="h-4 w-4" />
</Button>
</div>
</header>
{/* ── 요약 카드 5개 ── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{summaryCards.map((card) => (
<button
key={card.label}
onClick={() =>
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
}
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"
"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="text-muted-foreground text-xs">{card.label}</p>
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
</div>
</button>
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
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"
? cn(pill.color, "ring-1 ring-foreground/10")
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{pill.label}
</button>
))}
<span className="ml-auto text-sm text-gray-600 self-center">
{filteredEquipments.length}
</span>
<span className="text-muted-foreground ml-auto self-center text-sm">{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>
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
<span className="text-muted-foreground ml-3"> ...</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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<p className="text-lg"> .</p>
</div>
)}
{/* ── 설비 카드 그리드 ── */}
{filteredEquipments.length > 0 && (
<div
className="grid gap-4"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
>
<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];
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
<div
key={eq.id}
className={cn(
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
cfg.border,
cfg.cardGlow
cfg.cardGlow,
)}
>
{/* 좌측 색상 바 */}
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
<div className={cn("absolute top-0 bottom-0 left-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 || "-"}
{df.equipmentName && (
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
{eq.equipment_name || "이름 없음"}
</h3>
)}
<p className={cn("mt-0.5 truncate text-xs", theme.mutedText)}>
{df.equipmentType && (eq.equipment_type || "-")}
{df.equipmentType && df.equipmentLocation && " · "}
{df.equipmentLocation && (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>
{df.operationStatus && (
<Badge
className={cn("ml-2 shrink-0 gap-1 border-0 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="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (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 className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
{df.dailyOperationTime && (
<div>
<span className={cn("text-xs", theme.mutedText)}> </span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.dailyProductionQty && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.worker && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>
{eqWIs.length > 0 && eqWIs[0].worker_name ? eqWIs[0].worker_name : "-"}
</p>
</div>
)}
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("truncate font-medium", theme.text)}>{eq.equipment_code || "-"}</p>
</div>
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 가동률 프로그레스 */}
{df.utilizationBar && (
<div className="px-4 py-2.5 pl-5">
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className={theme.mutedText}></span>
<span
className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : theme.mutedText)}
>
{utilization !== null ? `${utilization}%` : "-"}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
{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-border" />
{/* 현재 작업지시 */}
{df.currentWorkInstruction && (
<div className="px-4 py-2.5 pl-5">
<p className={cn("mb-1 text-xs", theme.mutedText)}> </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="shrink-0 font-mono text-xs text-blue-400">
{wi.instruction_number || "-"}
</span>
<span className={cn("truncate", theme.mutedText)}>{wi.item_name || "-"}</span>
</div>
))}
{eqWIs.length > 2 && <p className={cn("text-xs", theme.mutedText)}>+{eqWIs.length - 2} </p>}
</div>
) : (
<p className={cn("text-sm italic", theme.mutedText)}> </p>
)}
</div>
)}
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (PLC 미연동) */}
{df.sensorData && (
<div className="flex items-center gap-4 px-4 py-2.5 pl-5 text-xs">
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}>RPM</span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
</div>
)}
</div>
);
})}
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
{/* 필터 결과 없음 */}
{!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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
<Inbox className="mb-2 h-10 w-10" />
<p> .</p>
</div>
)}
@@ -5,6 +5,10 @@ import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import type { ProductionDisplayFields } from "@/types/monitoringSettings";
import {
RefreshCw,
Clock,
@@ -16,6 +20,7 @@ import {
TrendingUp,
Play,
Pause,
Settings2,
} from "lucide-react";
// ─── 타입 정의 ─────────────────────────────────────────────
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
}
// 작업지시별 공정현황으로 진행상태 계산
function computeProgress(
wiId: string,
processMap: Map<string, ProcessStep[]>
): "대기" | "진행중" | "완료" {
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;
@@ -85,11 +87,15 @@ function computeProgress(
// ─── 메인 컴포넌트 ────────────────────────────────────────
export default function ProductionMonitoringPage() {
const { settings } = useMonitoringSettings("production");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
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 [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
// ─── 실시간 시계 ─────────────────────────────────────────
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
// 작업지시 목록 조회
const wiRes = await apiClient.get("/work-instruction/list");
const wiRaw: WorkInstruction[] =
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
const seen = new Set<string>();
const wiData = wiRaw.filter((wi) => {
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
// 공정현황 조회 (실패해도 작업지시는 표시)
try {
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
page: 1, size: 1000, autoFilter: true,
page: 1,
size: 1000,
autoFilter: true,
});
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
fetchData();
}, [fetchData]);
// ─── 자동갱신 (30초) ─────────────────────────────────────
// ─── 자동갱신 ────────────────────────────────────────────
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(fetchData, 30000);
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
return () => clearInterval(timer);
}, [autoRefresh, fetchData]);
}, [autoRefresh, fetchData, settings.refreshInterval]);
// ─── 통계 계산 ───────────────────────────────────────────
const stats = useMemo(() => {
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
// ─── 렌더링 ──────────────────────────────────────────────
return (
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
{/* 헤더 */}
<div className="flex items-center justify-between flex-shrink-0">
<h1 className="text-2xl font-bold text-foreground"></h1>
<div className="flex flex-shrink-0 items-center justify-between">
<h1 className={cn("text-2xl font-bold", theme.headerText)}></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" />
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
<Clock className="h-4 w-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 variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
<Button
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
onClick={() => setAutoRefresh(!autoRefresh)}
className="gap-1.5"
>
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="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="grid grid-cols-4 gap-4 flex-shrink-0">
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
<SummaryCard
icon={<Timer className="w-5 h-5" />}
icon={<Timer className="h-5 w-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" />}
icon={<Loader2 className="h-5 w-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" />}
icon={<CheckCircle2 className="h-5 w-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" />}
icon={<TrendingUp className="h-5 w-5" />}
label="달성율"
value={`${stats.achievementRate}%`}
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
</div>
{/* 탭 필터 */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex flex-shrink-0 items-center gap-2">
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
<Button
key={tab}
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
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"
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
)}
>
{tab}
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
{/* 로딩 상태 */}
{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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
<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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<span className="text-sm">
{activeTab === "전체"
? "등록된 작업지시가 없습니다."
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
</span>
</div>
)}
{/* 작업 카드 그리드 */}
{/* 작업 카드 */}
{filteredInstructions.length > 0 && (
<div
className="grid gap-4 flex-1"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
className={cn(
"flex-1 gap-4",
settings.layout === "grid" && "grid",
settings.layout === "list" && "flex flex-col",
settings.layout === "split" && "grid grid-cols-2",
)}
style={
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
}
>
{filteredInstructions.map((wi, idx) => (
<WorkCard
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
instruction={wi}
steps={processMap.get(wi.wi_id || wi.id) || []}
progress={computeProgress(wi.wi_id || wi.id, processMap)}
displayFields={settings.displayFields}
/>
))}
</div>
@@ -338,11 +354,11 @@ function SummaryCard({
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="bg-card flex items-center gap-4 rounded-lg border p-4">
<div className={cn("rounded-lg border p-2.5", 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>
<span className="text-muted-foreground text-xs">{label}</span>
<span className="text-foreground text-2xl font-bold">{value}</span>
</div>
</div>
);
@@ -353,10 +369,12 @@ function WorkCard({
instruction: wi,
steps,
progress,
displayFields: df,
}: {
instruction: WorkInstruction;
steps: ProcessStep[];
progress: "대기" | "진행중" | "완료";
displayFields: ProductionDisplayFields;
}) {
// API 응답은 flat 구조 (details 배열 아님)
const itemName = (wi as any).item_name || "-";
@@ -373,36 +391,28 @@ function WorkCard({
const currentStep = steps.find((s) => s.status !== "completed");
// 프로그레스바 색상
const barColor =
progressPercent >= 100
? "bg-emerald-500"
: progressPercent >= 50
? "bg-blue-500"
: "bg-amber-500";
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",
: "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="bg-card flex flex-col overflow-hidden rounded-lg border">
{/* 카드 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
<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" />
{df.workInstructionNo && (
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
)}
{df.priority && isUrgent && (
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
<AlertTriangle className="h-3 w-3" />
</Badge>
)}
@@ -413,82 +423,88 @@ function WorkCard({
</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 className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
{df.itemName && <InfoRow label="품목명" value={itemName} />}
{df.spec && <InfoRow label="규격" value={spec} />}
{df.customerName && <InfoRow label="거래처" value={customerName} />}
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
</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>
{df.processProgress && (
<div className="border-b px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium"></span>
{steps.length > 0 && (
<span className="text-muted-foreground text-xs">
{completedSteps}/{steps.length}
{currentStep && (
<span>
{" "}
· : <span className="text-blue-400">{currentStep.process_name}</span>
</span>
)}
</span>
)}
</div>
{steps.length > 0 ? (
<div className="flex flex-wrap gap-1">
{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(
"rounded px-2 py-0.5 text-xs font-medium transition-all",
isDone && "bg-emerald-500/20 text-emerald-400",
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
!isDone && !isCurrent && "bg-muted text-muted-foreground",
)}
>
{step.process_name}
</span>
);
})}
</div>
) : (
<span className="text-muted-foreground text-xs"> </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">
{completedQty} / {totalQty}
</span>
<span
className={cn(
"text-xs font-bold",
progressPercent >= 100
? "text-emerald-500"
: progressPercent >= 50
? "text-blue-500"
: "text-amber-500"
)}
>
{progressPercent}%
</span>
{df.progressBar && (
<div className="px-4 py-3">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{completedQty} / {totalQty}
</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="bg-muted h-2.5 w-full overflow-hidden rounded-full">
<div
className={cn("h-full rounded-full transition-all duration-500", barColor)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</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>
);
}
@@ -496,7 +512,7 @@ function WorkCard({
// ─── 정보 행 ───────────────────────────────────────────────
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-1.5 min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-muted-foreground shrink-0">{label}:</span>
<span className="text-foreground truncate">{value}</span>
</div>
@@ -4,23 +4,12 @@ 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 { 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";
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 {
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
/* ───── 유틸 ───── */
const fmt = (n: number) => n.toLocaleString("ko-KR");
const pct = (n: number) =>
`${n.toFixed(1)}%`;
const pct = (n: number) => `${n.toFixed(1)}%`;
const badgeVariant = (
type: "result" | "type" | "defectRate",
value: string | number,
) => {
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-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-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";
}
@@ -93,10 +76,15 @@ const badgeVariant = (
/* ───── 컴포넌트 ───── */
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(true);
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.post(
"/table-management/tables/work_order_process/data",
{ autoFilter: true },
);
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) {
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
/* ───── 자동 갱신 ───── */
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(fetchData, 30_000);
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [autoRefresh, fetchData]);
}, [autoRefresh, fetchData, settings.refreshInterval]);
/* ───── 검사 행 변환 ───── */
const inspectionRows: InspectionRow[] = useMemo(() => {
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
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
? "불합격"
: "합격";
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
return {
no: idx + 1,
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
/* ───── 렌더링 ───── */
return (
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
<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="text-xl font-bold text-gray-900 dark:text-white">
{" "}
<span className="text-emerald-600"></span>
<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="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<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", {
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
})}
</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" />
)}
<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",
)}
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
>
<Clock className="h-4 w-4 mr-1" />
<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 overflow-auto p-6 space-y-6">
<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>
<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>
)}
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
</p>
</div>
))}
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
"rounded-full px-4 py-1.5 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",
: "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}
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
</div>
{/* 테이블 영역 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
{/* 입고/출하 준비중 */}
{(activeTab === "incoming" || activeTab === "shipping") ? (
{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" />
<Search className="mb-4 h-12 w-12 opacity-40" />
<p className="text-lg font-medium"></p>
<p className="text-sm mt-1">
{activeTab === "incoming" ? "입고검사" : "출하검사"}
.
<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="h-10 w-10 animate-spin mb-4" />
<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="h-12 w-12 mb-4 opacity-40" />
<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="bg-gray-50 dark:bg-gray-700/50">
<TableRow className={theme.tableHeader}>
<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>
{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;
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}%` }}
/>
<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>
<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",
{
</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>
<TableCell className="text-sm text-gray-400">
{row.remark || "-"}
</TableCell>
})
: "-"}
</TableCell>
)}
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
</TableRow>
);
})}
@@ -12,16 +12,10 @@ 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";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
/* ───── 상태 정의 ───── */
@@ -134,11 +128,16 @@ interface WorkInstruction {
/* ───── 컴포넌트 ───── */
export default function EquipmentMonitoringPage() {
const { settings } = useMonitoringSettings("equipment");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
const df = settings.displayFields;
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 [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
const autoRefreshRef = useRef(autoRefresh);
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
fetchData();
}, [fetchData]);
/* ── 자동 갱신 (30초) ── */
/* ── 자동 갱신 ── */
useEffect(() => {
const interval = setInterval(() => {
if (autoRefreshRef.current) fetchData();
}, 30000);
}, settings.refreshInterval * 1000);
return () => clearInterval(interval);
}, [fetchData]);
}, [fetchData, settings.refreshInterval]);
/* ── 요약 통계 ── */
const stats = useMemo(() => {
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
/* ── 필터 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" },
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-700 hover:bg-emerald-500/30" },
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-700 hover:bg-amber-500/30" },
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-700 hover:bg-red-500/30" },
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-700 hover:bg-gray-500/30" },
];
/* ── 포맷 ── */
@@ -309,20 +308,26 @@ export default function EquipmentMonitoringPage() {
/* ────────────── 렌더 ────────────── */
return (
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
<div className={cn("min-h-screen space-y-5 p-4 md:p-6", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<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>
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}></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">
<div
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm",
theme.mutedText,
theme.cardBorder,
)}
>
<Clock className="h-4 w-4" />
<span className="font-mono">{formatDate(currentTime)}</span>
<span className="font-mono text-white">{formatTime(currentTime)}</span>
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
</div>
{/* 자동갱신 토글 */}
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
variant="outline"
size="sm"
className={cn(
"border-gray-700 text-xs gap-1.5",
"gap-1.5 text-xs",
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"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => setAutoRefresh((v) => !v)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
<span
className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "animate-pulse bg-emerald-400" : "bg-muted-foreground")}
/>
{autoRefresh ? "ON" : "OFF"}
</Button>
{/* 새로고침 */}
<Button variant="outline" size="sm" className="gap-1.5" onClick={fetchData} disabled={loading}>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</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}
className="gap-1.5"
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
<Settings2 className="h-4 w-4" />
</Button>
</div>
</header>
{/* ── 요약 카드 5개 ── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{summaryCards.map((card) => (
<button
key={card.label}
onClick={() =>
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
}
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"
"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="text-muted-foreground text-xs">{card.label}</p>
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
</div>
</button>
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
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"
? cn(pill.color, "ring-1 ring-foreground/10")
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{pill.label}
</button>
))}
<span className="ml-auto text-sm text-gray-600 self-center">
{filteredEquipments.length}
</span>
<span className="text-muted-foreground ml-auto self-center text-sm">{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>
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
<span className="text-muted-foreground ml-3"> ...</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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<p className="text-lg"> .</p>
</div>
)}
{/* ── 설비 카드 그리드 ── */}
{filteredEquipments.length > 0 && (
<div
className="grid gap-4"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
>
<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];
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
<div
key={eq.id}
className={cn(
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
cfg.border,
cfg.cardGlow
cfg.cardGlow,
)}
>
{/* 좌측 색상 바 */}
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
<div className={cn("absolute top-0 bottom-0 left-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 || "-"}
{df.equipmentName && (
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
{eq.equipment_name || "이름 없음"}
</h3>
)}
<p className={cn("mt-0.5 truncate text-xs", theme.mutedText)}>
{df.equipmentType && (eq.equipment_type || "-")}
{df.equipmentType && df.equipmentLocation && " · "}
{df.equipmentLocation && (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>
{df.operationStatus && (
<Badge
className={cn("ml-2 shrink-0 gap-1 border-0 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="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (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 className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
{df.dailyOperationTime && (
<div>
<span className={cn("text-xs", theme.mutedText)}> </span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.dailyProductionQty && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.worker && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>
{eqWIs.length > 0 && eqWIs[0].worker_name ? eqWIs[0].worker_name : "-"}
</p>
</div>
)}
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("truncate font-medium", theme.text)}>{eq.equipment_code || "-"}</p>
</div>
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 가동률 프로그레스 */}
{df.utilizationBar && (
<div className="px-4 py-2.5 pl-5">
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className={theme.mutedText}></span>
<span
className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : theme.mutedText)}
>
{utilization !== null ? `${utilization}%` : "-"}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
{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-border" />
{/* 현재 작업지시 */}
{df.currentWorkInstruction && (
<div className="px-4 py-2.5 pl-5">
<p className={cn("mb-1 text-xs", theme.mutedText)}> </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="shrink-0 font-mono text-xs text-blue-400">
{wi.instruction_number || "-"}
</span>
<span className={cn("truncate", theme.mutedText)}>{wi.item_name || "-"}</span>
</div>
))}
{eqWIs.length > 2 && <p className={cn("text-xs", theme.mutedText)}>+{eqWIs.length - 2} </p>}
</div>
) : (
<p className={cn("text-sm italic", theme.mutedText)}> </p>
)}
</div>
)}
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (PLC 미연동) */}
{df.sensorData && (
<div className="flex items-center gap-4 px-4 py-2.5 pl-5 text-xs">
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}>RPM</span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
</div>
)}
</div>
);
})}
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
{/* 필터 결과 없음 */}
{!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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
<Inbox className="mb-2 h-10 w-10" />
<p> .</p>
</div>
)}
@@ -5,6 +5,10 @@ import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import type { ProductionDisplayFields } from "@/types/monitoringSettings";
import {
RefreshCw,
Clock,
@@ -16,6 +20,7 @@ import {
TrendingUp,
Play,
Pause,
Settings2,
} from "lucide-react";
// ─── 타입 정의 ─────────────────────────────────────────────
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
}
// 작업지시별 공정현황으로 진행상태 계산
function computeProgress(
wiId: string,
processMap: Map<string, ProcessStep[]>
): "대기" | "진행중" | "완료" {
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;
@@ -85,11 +87,15 @@ function computeProgress(
// ─── 메인 컴포넌트 ────────────────────────────────────────
export default function ProductionMonitoringPage() {
const { settings } = useMonitoringSettings("production");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
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 [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
// ─── 실시간 시계 ─────────────────────────────────────────
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
// 작업지시 목록 조회
const wiRes = await apiClient.get("/work-instruction/list");
const wiRaw: WorkInstruction[] =
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
const seen = new Set<string>();
const wiData = wiRaw.filter((wi) => {
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
// 공정현황 조회 (실패해도 작업지시는 표시)
try {
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
page: 1, size: 1000, autoFilter: true,
page: 1,
size: 1000,
autoFilter: true,
});
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
fetchData();
}, [fetchData]);
// ─── 자동갱신 (30초) ─────────────────────────────────────
// ─── 자동갱신 ────────────────────────────────────────────
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(fetchData, 30000);
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
return () => clearInterval(timer);
}, [autoRefresh, fetchData]);
}, [autoRefresh, fetchData, settings.refreshInterval]);
// ─── 통계 계산 ───────────────────────────────────────────
const stats = useMemo(() => {
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
// ─── 렌더링 ──────────────────────────────────────────────
return (
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
{/* 헤더 */}
<div className="flex items-center justify-between flex-shrink-0">
<h1 className="text-2xl font-bold text-foreground"></h1>
<div className="flex flex-shrink-0 items-center justify-between">
<h1 className={cn("text-2xl font-bold", theme.headerText)}></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" />
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
<Clock className="h-4 w-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 variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
<Button
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
onClick={() => setAutoRefresh(!autoRefresh)}
className="gap-1.5"
>
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="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="grid grid-cols-4 gap-4 flex-shrink-0">
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
<SummaryCard
icon={<Timer className="w-5 h-5" />}
icon={<Timer className="h-5 w-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" />}
icon={<Loader2 className="h-5 w-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" />}
icon={<CheckCircle2 className="h-5 w-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" />}
icon={<TrendingUp className="h-5 w-5" />}
label="달성율"
value={`${stats.achievementRate}%`}
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
</div>
{/* 탭 필터 */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex flex-shrink-0 items-center gap-2">
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
<Button
key={tab}
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
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"
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
)}
>
{tab}
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
{/* 로딩 상태 */}
{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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
<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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<span className="text-sm">
{activeTab === "전체"
? "등록된 작업지시가 없습니다."
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
</span>
</div>
)}
{/* 작업 카드 그리드 */}
{/* 작업 카드 */}
{filteredInstructions.length > 0 && (
<div
className="grid gap-4 flex-1"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
className={cn(
"flex-1 gap-4",
settings.layout === "grid" && "grid",
settings.layout === "list" && "flex flex-col",
settings.layout === "split" && "grid grid-cols-2",
)}
style={
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
}
>
{filteredInstructions.map((wi, idx) => (
<WorkCard
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
instruction={wi}
steps={processMap.get(wi.wi_id || wi.id) || []}
progress={computeProgress(wi.wi_id || wi.id, processMap)}
displayFields={settings.displayFields}
/>
))}
</div>
@@ -338,11 +354,11 @@ function SummaryCard({
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="bg-card flex items-center gap-4 rounded-lg border p-4">
<div className={cn("rounded-lg border p-2.5", 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>
<span className="text-muted-foreground text-xs">{label}</span>
<span className="text-foreground text-2xl font-bold">{value}</span>
</div>
</div>
);
@@ -353,10 +369,12 @@ function WorkCard({
instruction: wi,
steps,
progress,
displayFields: df,
}: {
instruction: WorkInstruction;
steps: ProcessStep[];
progress: "대기" | "진행중" | "완료";
displayFields: ProductionDisplayFields;
}) {
// API 응답은 flat 구조 (details 배열 아님)
const itemName = (wi as any).item_name || "-";
@@ -373,36 +391,28 @@ function WorkCard({
const currentStep = steps.find((s) => s.status !== "completed");
// 프로그레스바 색상
const barColor =
progressPercent >= 100
? "bg-emerald-500"
: progressPercent >= 50
? "bg-blue-500"
: "bg-amber-500";
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",
: "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="bg-card flex flex-col overflow-hidden rounded-lg border">
{/* 카드 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
<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" />
{df.workInstructionNo && (
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
)}
{df.priority && isUrgent && (
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
<AlertTriangle className="h-3 w-3" />
</Badge>
)}
@@ -413,82 +423,88 @@ function WorkCard({
</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 className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
{df.itemName && <InfoRow label="품목명" value={itemName} />}
{df.spec && <InfoRow label="규격" value={spec} />}
{df.customerName && <InfoRow label="거래처" value={customerName} />}
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
</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>
{df.processProgress && (
<div className="border-b px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium"></span>
{steps.length > 0 && (
<span className="text-muted-foreground text-xs">
{completedSteps}/{steps.length}
{currentStep && (
<span>
{" "}
· : <span className="text-blue-400">{currentStep.process_name}</span>
</span>
)}
</span>
)}
</div>
{steps.length > 0 ? (
<div className="flex flex-wrap gap-1">
{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(
"rounded px-2 py-0.5 text-xs font-medium transition-all",
isDone && "bg-emerald-500/20 text-emerald-400",
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
!isDone && !isCurrent && "bg-muted text-muted-foreground",
)}
>
{step.process_name}
</span>
);
})}
</div>
) : (
<span className="text-muted-foreground text-xs"> </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">
{completedQty} / {totalQty}
</span>
<span
className={cn(
"text-xs font-bold",
progressPercent >= 100
? "text-emerald-500"
: progressPercent >= 50
? "text-blue-500"
: "text-amber-500"
)}
>
{progressPercent}%
</span>
{df.progressBar && (
<div className="px-4 py-3">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{completedQty} / {totalQty}
</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="bg-muted h-2.5 w-full overflow-hidden rounded-full">
<div
className={cn("h-full rounded-full transition-all duration-500", barColor)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</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>
);
}
@@ -496,7 +512,7 @@ function WorkCard({
// ─── 정보 행 ───────────────────────────────────────────────
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-1.5 min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-muted-foreground shrink-0">{label}:</span>
<span className="text-foreground truncate">{value}</span>
</div>
@@ -4,23 +4,12 @@ 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 { 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";
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 {
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
/* ───── 유틸 ───── */
const fmt = (n: number) => n.toLocaleString("ko-KR");
const pct = (n: number) =>
`${n.toFixed(1)}%`;
const pct = (n: number) => `${n.toFixed(1)}%`;
const badgeVariant = (
type: "result" | "type" | "defectRate",
value: string | number,
) => {
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-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-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";
}
@@ -93,10 +76,15 @@ const badgeVariant = (
/* ───── 컴포넌트 ───── */
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(true);
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.post(
"/table-management/tables/work_order_process/data",
{ autoFilter: true },
);
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) {
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
/* ───── 자동 갱신 ───── */
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(fetchData, 30_000);
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [autoRefresh, fetchData]);
}, [autoRefresh, fetchData, settings.refreshInterval]);
/* ───── 검사 행 변환 ───── */
const inspectionRows: InspectionRow[] = useMemo(() => {
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
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
? "불합격"
: "합격";
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
return {
no: idx + 1,
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
/* ───── 렌더링 ───── */
return (
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
<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="text-xl font-bold text-gray-900 dark:text-white">
{" "}
<span className="text-emerald-600"></span>
<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="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<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", {
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
})}
</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" />
)}
<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",
)}
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
>
<Clock className="h-4 w-4 mr-1" />
<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 overflow-auto p-6 space-y-6">
<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>
<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>
)}
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
</p>
</div>
))}
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
"rounded-full px-4 py-1.5 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",
: "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}
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
</div>
{/* 테이블 영역 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
{/* 입고/출하 준비중 */}
{(activeTab === "incoming" || activeTab === "shipping") ? (
{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" />
<Search className="mb-4 h-12 w-12 opacity-40" />
<p className="text-lg font-medium"></p>
<p className="text-sm mt-1">
{activeTab === "incoming" ? "입고검사" : "출하검사"}
.
<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="h-10 w-10 animate-spin mb-4" />
<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="h-12 w-12 mb-4 opacity-40" />
<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="bg-gray-50 dark:bg-gray-700/50">
<TableRow className={theme.tableHeader}>
<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>
{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;
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}%` }}
/>
<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>
<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",
{
</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>
<TableCell className="text-sm text-gray-400">
{row.remark || "-"}
</TableCell>
})
: "-"}
</TableCell>
)}
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
</TableRow>
);
})}
@@ -12,16 +12,10 @@ 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";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
/* ───── 상태 정의 ───── */
@@ -134,11 +128,16 @@ interface WorkInstruction {
/* ───── 컴포넌트 ───── */
export default function EquipmentMonitoringPage() {
const { settings } = useMonitoringSettings("equipment");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
const df = settings.displayFields;
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 [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
const autoRefreshRef = useRef(autoRefresh);
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
fetchData();
}, [fetchData]);
/* ── 자동 갱신 (30초) ── */
/* ── 자동 갱신 ── */
useEffect(() => {
const interval = setInterval(() => {
if (autoRefreshRef.current) fetchData();
}, 30000);
}, settings.refreshInterval * 1000);
return () => clearInterval(interval);
}, [fetchData]);
}, [fetchData, settings.refreshInterval]);
/* ── 요약 통계 ── */
const stats = useMemo(() => {
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
/* ── 필터 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" },
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-700 hover:bg-emerald-500/30" },
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-700 hover:bg-amber-500/30" },
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-700 hover:bg-red-500/30" },
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-700 hover:bg-gray-500/30" },
];
/* ── 포맷 ── */
@@ -309,20 +308,26 @@ export default function EquipmentMonitoringPage() {
/* ────────────── 렌더 ────────────── */
return (
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
<div className={cn("min-h-screen space-y-5 p-4 md:p-6", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<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>
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}></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">
<div
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm",
theme.mutedText,
theme.cardBorder,
)}
>
<Clock className="h-4 w-4" />
<span className="font-mono">{formatDate(currentTime)}</span>
<span className="font-mono text-white">{formatTime(currentTime)}</span>
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
</div>
{/* 자동갱신 토글 */}
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
variant="outline"
size="sm"
className={cn(
"border-gray-700 text-xs gap-1.5",
"gap-1.5 text-xs",
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"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => setAutoRefresh((v) => !v)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
<span
className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "animate-pulse bg-emerald-400" : "bg-muted-foreground")}
/>
{autoRefresh ? "ON" : "OFF"}
</Button>
{/* 새로고침 */}
<Button variant="outline" size="sm" className="gap-1.5" onClick={fetchData} disabled={loading}>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</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}
className="gap-1.5"
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
<Settings2 className="h-4 w-4" />
</Button>
</div>
</header>
{/* ── 요약 카드 5개 ── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{summaryCards.map((card) => (
<button
key={card.label}
onClick={() =>
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
}
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"
"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="text-muted-foreground text-xs">{card.label}</p>
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
</div>
</button>
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
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"
? cn(pill.color, "ring-1 ring-foreground/10")
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{pill.label}
</button>
))}
<span className="ml-auto text-sm text-gray-600 self-center">
{filteredEquipments.length}
</span>
<span className="text-muted-foreground ml-auto self-center text-sm">{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>
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
<span className="text-muted-foreground ml-3"> ...</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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<p className="text-lg"> .</p>
</div>
)}
{/* ── 설비 카드 그리드 ── */}
{filteredEquipments.length > 0 && (
<div
className="grid gap-4"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
>
<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];
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
<div
key={eq.id}
className={cn(
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
cfg.border,
cfg.cardGlow
cfg.cardGlow,
)}
>
{/* 좌측 색상 바 */}
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
<div className={cn("absolute top-0 bottom-0 left-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 || "-"}
{df.equipmentName && (
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
{eq.equipment_name || "이름 없음"}
</h3>
)}
<p className={cn("mt-0.5 truncate text-xs", theme.mutedText)}>
{df.equipmentType && (eq.equipment_type || "-")}
{df.equipmentType && df.equipmentLocation && " · "}
{df.equipmentLocation && (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>
{df.operationStatus && (
<Badge
className={cn("ml-2 shrink-0 gap-1 border-0 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="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (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 className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
{df.dailyOperationTime && (
<div>
<span className={cn("text-xs", theme.mutedText)}> </span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.dailyProductionQty && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.worker && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>
{eqWIs.length > 0 && eqWIs[0].worker_name ? eqWIs[0].worker_name : "-"}
</p>
</div>
)}
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("truncate font-medium", theme.text)}>{eq.equipment_code || "-"}</p>
</div>
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 가동률 프로그레스 */}
{df.utilizationBar && (
<div className="px-4 py-2.5 pl-5">
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className={theme.mutedText}></span>
<span
className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : theme.mutedText)}
>
{utilization !== null ? `${utilization}%` : "-"}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
{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-border" />
{/* 현재 작업지시 */}
{df.currentWorkInstruction && (
<div className="px-4 py-2.5 pl-5">
<p className={cn("mb-1 text-xs", theme.mutedText)}> </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="shrink-0 font-mono text-xs text-blue-400">
{wi.instruction_number || "-"}
</span>
<span className={cn("truncate", theme.mutedText)}>{wi.item_name || "-"}</span>
</div>
))}
{eqWIs.length > 2 && <p className={cn("text-xs", theme.mutedText)}>+{eqWIs.length - 2} </p>}
</div>
) : (
<p className={cn("text-sm italic", theme.mutedText)}> </p>
)}
</div>
)}
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (PLC 미연동) */}
{df.sensorData && (
<div className="flex items-center gap-4 px-4 py-2.5 pl-5 text-xs">
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}>RPM</span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
</div>
)}
</div>
);
})}
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
{/* 필터 결과 없음 */}
{!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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
<Inbox className="mb-2 h-10 w-10" />
<p> .</p>
</div>
)}
@@ -5,6 +5,10 @@ import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import type { ProductionDisplayFields } from "@/types/monitoringSettings";
import {
RefreshCw,
Clock,
@@ -16,6 +20,7 @@ import {
TrendingUp,
Play,
Pause,
Settings2,
} from "lucide-react";
// ─── 타입 정의 ─────────────────────────────────────────────
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
}
// 작업지시별 공정현황으로 진행상태 계산
function computeProgress(
wiId: string,
processMap: Map<string, ProcessStep[]>
): "대기" | "진행중" | "완료" {
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;
@@ -85,11 +87,15 @@ function computeProgress(
// ─── 메인 컴포넌트 ────────────────────────────────────────
export default function ProductionMonitoringPage() {
const { settings } = useMonitoringSettings("production");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
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 [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
// ─── 실시간 시계 ─────────────────────────────────────────
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
// 작업지시 목록 조회
const wiRes = await apiClient.get("/work-instruction/list");
const wiRaw: WorkInstruction[] =
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
const seen = new Set<string>();
const wiData = wiRaw.filter((wi) => {
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
// 공정현황 조회 (실패해도 작업지시는 표시)
try {
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
page: 1, size: 1000, autoFilter: true,
page: 1,
size: 1000,
autoFilter: true,
});
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
fetchData();
}, [fetchData]);
// ─── 자동갱신 (30초) ─────────────────────────────────────
// ─── 자동갱신 ────────────────────────────────────────────
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(fetchData, 30000);
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
return () => clearInterval(timer);
}, [autoRefresh, fetchData]);
}, [autoRefresh, fetchData, settings.refreshInterval]);
// ─── 통계 계산 ───────────────────────────────────────────
const stats = useMemo(() => {
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
// ─── 렌더링 ──────────────────────────────────────────────
return (
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
{/* 헤더 */}
<div className="flex items-center justify-between flex-shrink-0">
<h1 className="text-2xl font-bold text-foreground"></h1>
<div className="flex flex-shrink-0 items-center justify-between">
<h1 className={cn("text-2xl font-bold", theme.headerText)}></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" />
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
<Clock className="h-4 w-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 variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
<Button
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
onClick={() => setAutoRefresh(!autoRefresh)}
className="gap-1.5"
>
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="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="grid grid-cols-4 gap-4 flex-shrink-0">
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
<SummaryCard
icon={<Timer className="w-5 h-5" />}
icon={<Timer className="h-5 w-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" />}
icon={<Loader2 className="h-5 w-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" />}
icon={<CheckCircle2 className="h-5 w-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" />}
icon={<TrendingUp className="h-5 w-5" />}
label="달성율"
value={`${stats.achievementRate}%`}
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
</div>
{/* 탭 필터 */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex flex-shrink-0 items-center gap-2">
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
<Button
key={tab}
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
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"
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
)}
>
{tab}
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
{/* 로딩 상태 */}
{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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
<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" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<span className="text-sm">
{activeTab === "전체"
? "등록된 작업지시가 없습니다."
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
</span>
</div>
)}
{/* 작업 카드 그리드 */}
{/* 작업 카드 */}
{filteredInstructions.length > 0 && (
<div
className="grid gap-4 flex-1"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
className={cn(
"flex-1 gap-4",
settings.layout === "grid" && "grid",
settings.layout === "list" && "flex flex-col",
settings.layout === "split" && "grid grid-cols-2",
)}
style={
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
}
>
{filteredInstructions.map((wi, idx) => (
<WorkCard
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
instruction={wi}
steps={processMap.get(wi.wi_id || wi.id) || []}
progress={computeProgress(wi.wi_id || wi.id, processMap)}
displayFields={settings.displayFields}
/>
))}
</div>
@@ -338,11 +354,11 @@ function SummaryCard({
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="bg-card flex items-center gap-4 rounded-lg border p-4">
<div className={cn("rounded-lg border p-2.5", 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>
<span className="text-muted-foreground text-xs">{label}</span>
<span className="text-foreground text-2xl font-bold">{value}</span>
</div>
</div>
);
@@ -353,10 +369,12 @@ function WorkCard({
instruction: wi,
steps,
progress,
displayFields: df,
}: {
instruction: WorkInstruction;
steps: ProcessStep[];
progress: "대기" | "진행중" | "완료";
displayFields: ProductionDisplayFields;
}) {
// API 응답은 flat 구조 (details 배열 아님)
const itemName = (wi as any).item_name || "-";
@@ -373,36 +391,28 @@ function WorkCard({
const currentStep = steps.find((s) => s.status !== "completed");
// 프로그레스바 색상
const barColor =
progressPercent >= 100
? "bg-emerald-500"
: progressPercent >= 50
? "bg-blue-500"
: "bg-amber-500";
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",
: "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="bg-card flex flex-col overflow-hidden rounded-lg border">
{/* 카드 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
<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" />
{df.workInstructionNo && (
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
)}
{df.priority && isUrgent && (
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
<AlertTriangle className="h-3 w-3" />
</Badge>
)}
@@ -413,82 +423,88 @@ function WorkCard({
</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 className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
{df.itemName && <InfoRow label="품목명" value={itemName} />}
{df.spec && <InfoRow label="규격" value={spec} />}
{df.customerName && <InfoRow label="거래처" value={customerName} />}
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
</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>
{df.processProgress && (
<div className="border-b px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium"></span>
{steps.length > 0 && (
<span className="text-muted-foreground text-xs">
{completedSteps}/{steps.length}
{currentStep && (
<span>
{" "}
· : <span className="text-blue-400">{currentStep.process_name}</span>
</span>
)}
</span>
)}
</div>
{steps.length > 0 ? (
<div className="flex flex-wrap gap-1">
{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(
"rounded px-2 py-0.5 text-xs font-medium transition-all",
isDone && "bg-emerald-500/20 text-emerald-400",
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
!isDone && !isCurrent && "bg-muted text-muted-foreground",
)}
>
{step.process_name}
</span>
);
})}
</div>
) : (
<span className="text-muted-foreground text-xs"> </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">
{completedQty} / {totalQty}
</span>
<span
className={cn(
"text-xs font-bold",
progressPercent >= 100
? "text-emerald-500"
: progressPercent >= 50
? "text-blue-500"
: "text-amber-500"
)}
>
{progressPercent}%
</span>
{df.progressBar && (
<div className="px-4 py-3">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{completedQty} / {totalQty}
</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="bg-muted h-2.5 w-full overflow-hidden rounded-full">
<div
className={cn("h-full rounded-full transition-all duration-500", barColor)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</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>
);
}
@@ -496,7 +512,7 @@ function WorkCard({
// ─── 정보 행 ───────────────────────────────────────────────
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-1.5 min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-muted-foreground shrink-0">{label}:</span>
<span className="text-foreground truncate">{value}</span>
</div>
@@ -4,23 +4,12 @@ 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 { 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";
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 {
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
/* ───── 유틸 ───── */
const fmt = (n: number) => n.toLocaleString("ko-KR");
const pct = (n: number) =>
`${n.toFixed(1)}%`;
const pct = (n: number) => `${n.toFixed(1)}%`;
const badgeVariant = (
type: "result" | "type" | "defectRate",
value: string | number,
) => {
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-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-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";
}
@@ -93,10 +76,15 @@ const badgeVariant = (
/* ───── 컴포넌트 ───── */
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(true);
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.post(
"/table-management/tables/work_order_process/data",
{ autoFilter: true },
);
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) {
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
/* ───── 자동 갱신 ───── */
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(fetchData, 30_000);
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [autoRefresh, fetchData]);
}, [autoRefresh, fetchData, settings.refreshInterval]);
/* ───── 검사 행 변환 ───── */
const inspectionRows: InspectionRow[] = useMemo(() => {
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
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
? "불합격"
: "합격";
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
return {
no: idx + 1,
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
/* ───── 렌더링 ───── */
return (
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
<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="text-xl font-bold text-gray-900 dark:text-white">
{" "}
<span className="text-emerald-600"></span>
<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="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<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", {
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
})}
</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" />
)}
<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",
)}
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
>
<Clock className="h-4 w-4 mr-1" />
<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 overflow-auto p-6 space-y-6">
<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>
<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>
)}
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
</p>
</div>
))}
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
"rounded-full px-4 py-1.5 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",
: "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}
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
</div>
{/* 테이블 영역 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
{/* 입고/출하 준비중 */}
{(activeTab === "incoming" || activeTab === "shipping") ? (
{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" />
<Search className="mb-4 h-12 w-12 opacity-40" />
<p className="text-lg font-medium"></p>
<p className="text-sm mt-1">
{activeTab === "incoming" ? "입고검사" : "출하검사"}
.
<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="h-10 w-10 animate-spin mb-4" />
<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="h-12 w-12 mb-4 opacity-40" />
<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="bg-gray-50 dark:bg-gray-700/50">
<TableRow className={theme.tableHeader}>
<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>
{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;
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}%` }}
/>
<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>
<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",
{
</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>
<TableCell className="text-sm text-gray-400">
{row.remark || "-"}
</TableCell>
})
: "-"}
</TableCell>
)}
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
</TableRow>
);
})}
@@ -148,9 +148,11 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/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/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_16/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_7/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_7/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_7/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_7/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/purchase/order": dynamic(() => import("@/app/(main)/COMPANY_7/purchase/order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_7/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_7/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
@@ -200,6 +202,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_8/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_8/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_8/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_8/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_8/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_8/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_8/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_8/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_8/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_8/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_8/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
@@ -243,6 +246,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_10/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_10/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_10/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_10/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_10/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_10/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_10/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_10/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_10/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_10/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_10/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
@@ -286,6 +290,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_29/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_29/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_29/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_29/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_29/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
@@ -329,6 +334,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_9/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_9/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_9/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_9/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_9/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
@@ -373,6 +379,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_30/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_30/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_30/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_30/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_30/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_30/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_30/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_30/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_30/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_30/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_30/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
@@ -0,0 +1,594 @@
"use client";
import React, { useState } from "react";
import { useMonitoringSettingsAll } from "@/hooks/useMonitoringSettings";
import type {
MonitoringTheme,
ProductionLayout,
RefreshInterval,
AllMonitoringSettings,
} from "@/types/monitoringSettings";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { Settings2, Save, RotateCcw, Factory, Wrench, ClipboardCheck } from "lucide-react";
// ─── 탭 타입 ─────────────────────────────────────────────────
type MonitorTab = "production" | "equipment" | "quality";
const TABS: { key: MonitorTab; label: string; icon: React.ReactNode; desc: string }[] = [
{ key: "production", label: "생산모니터링", icon: <Factory className="h-6 w-6" />, desc: "작업지시 진행현황" },
{ key: "equipment", label: "설비운영모니터링", icon: <Wrench className="h-6 w-6" />, desc: "설비 가동 현황" },
{ key: "quality", label: "품질점검현황", icon: <ClipboardCheck className="h-6 w-6" />, desc: "검사 현황 모니터링" },
];
// ─── 필드 라벨 매핑 ──────────────────────────────────────────
const PRODUCTION_FIELD_LABELS: Record<string, string> = {
workInstructionNo: "작업지시번호",
itemName: "품목명",
spec: "규격",
customerName: "거래처",
worker: "작업자/작업조",
dueDate: "납기일",
equipment: "사용설비",
processProgress: "공정 진행현황",
progressBar: "진행률 바",
priority: "우선순위 표시",
salesOrderNo: "수주번호",
quantityInfo: "지시수량/완료수량",
};
const EQUIPMENT_FIELD_LABELS: Record<string, string> = {
equipmentName: "설비명",
equipmentType: "설비유형",
equipmentLocation: "설비위치",
operationStatus: "가동상태",
utilizationBar: "가동률 바",
dailyOperationTime: "금일 가동시간",
dailyProductionQty: "금일 생산수량",
worker: "작업자",
currentWorkInstruction: "현재 작업지시",
sensorData: "센서 데이터 (온도/압력/RPM)",
cumulativeOperationTime: "누적 가동시간",
nextInspectionDate: "다음 점검 예정일",
};
const QUALITY_COLUMN_LABELS: Record<string, string> = {
inspectionNo: "검사번호",
inspectionType: "검사유형",
itemName: "품목명",
spec: "규격",
inspectionQty: "검사수량",
passFailQty: "합격/불합격 수량",
defectRate: "불량률",
resultBar: "검사결과 바",
judgment: "판정",
inspector: "검사자",
inspectedAt: "검사일시",
inspectionCriteria: "검사기준",
};
const QUALITY_INSPECTION_LABELS: Record<string, string> = {
incoming: "입고검사",
process: "공정검사",
shipping: "출하검사",
};
// ─── 메인 컴포넌트 ───────────────────────────────────────────
export default function MonitoringSettingsPage() {
const { settings, setSettings, saveAll, resetAll, isLoaded } = useMonitoringSettingsAll();
const [activeTab, setActiveTab] = useState<MonitorTab>("production");
const [saved, setSaved] = useState(false);
const handleSave = () => {
saveAll();
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
const handleReset = () => {
if (!window.confirm("설정을 초기화하시겠습니까?")) return;
resetAll();
};
if (!isLoaded) {
return <div className="text-muted-foreground flex h-64 items-center justify-center"> ...</div>;
}
return (
<div className="bg-background flex h-full min-h-0 flex-col overflow-auto">
<div className="mx-auto w-full max-w-[1200px] space-y-6 p-6">
{/* 헤더 */}
<div>
<h1 className="text-foreground flex items-center gap-2 text-xl font-bold">
<Settings2 className="h-5 w-5" />
</h1>
<p className="text-muted-foreground mt-1 text-sm">
, , .
</p>
</div>
{/* 모니터링 선택 탭 */}
<div className="grid grid-cols-3 gap-3">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
"flex flex-col items-center gap-2 rounded-lg border-2 p-4 text-center transition-all",
activeTab === tab.key ? "border-primary bg-primary/5" : "border-border hover:border-primary/50",
)}
>
<div className="text-primary">{tab.icon}</div>
<div className="text-foreground text-sm font-bold">{tab.label}</div>
<div className="text-muted-foreground text-xs">{tab.desc}</div>
</button>
))}
</div>
{/* 설정 내용 */}
{activeTab === "production" && <ProductionSettings settings={settings} setSettings={setSettings} />}
{activeTab === "equipment" && <EquipmentSettings settings={settings} setSettings={setSettings} />}
{activeTab === "quality" && <QualitySettings settings={settings} setSettings={setSettings} />}
{/* 하단 저장 바 */}
<div className="bg-card border-border sticky bottom-0 flex justify-end gap-3 border-t py-4">
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="mr-1.5 h-4 w-4" />
</Button>
<Button onClick={handleSave}>
<Save className="mr-1.5 h-4 w-4" />
{saved ? "저장 완료!" : "설정 저장"}
</Button>
</div>
</div>
</div>
);
}
// ─── 테마 선택기 ─────────────────────────────────────────────
function ThemeSelector({ value, onChange }: { value: MonitoringTheme; onChange: (theme: MonitoringTheme) => void }) {
const themes: { key: MonitoringTheme; label: string; preview: string; bg: string }[] = [
{ key: "dark", label: "다크 모드", preview: "bg-gray-900", bg: "bg-gray-900" },
{ key: "blue", label: "딥 블루", preview: "bg-slate-800", bg: "bg-slate-800" },
{ key: "light", label: "라이트 모드", preview: "bg-gray-100 border border-gray-200", bg: "bg-gray-100" },
];
return (
<div className="grid grid-cols-3 gap-3">
{themes.map((t) => (
<button
key={t.key}
onClick={() => onChange(t.key)}
className={cn(
"rounded-lg border-2 p-4 text-center transition-all",
value === t.key
? "border-primary ring-primary/15 shadow-sm ring-2"
: "border-border hover:border-primary/50",
)}
>
<div className={cn("mb-2 flex h-16 items-center justify-center rounded-md text-xl", t.preview)}>
{t.key === "dark" ? "🌙" : t.key === "blue" ? "🌊" : "☀️"}
</div>
<div className="text-foreground text-xs font-bold">{t.label}</div>
</button>
))}
</div>
);
}
// ─── 레이아웃 선택기 (생산만) ────────────────────────────────
function LayoutSelector({
value,
onChange,
}: {
value: ProductionLayout;
onChange: (layout: ProductionLayout) => void;
}) {
const layouts: { key: ProductionLayout; label: string }[] = [
{ key: "grid", label: "그리드형" },
{ key: "list", label: "리스트형" },
{ key: "split", label: "분할형" },
];
return (
<div className="grid grid-cols-3 gap-3">
{layouts.map((l) => (
<button
key={l.key}
onClick={() => onChange(l.key)}
className={cn(
"rounded-lg border-2 p-3 text-center transition-all",
value === l.key
? "border-primary ring-primary/15 shadow-sm ring-2"
: "border-border hover:border-primary/50",
)}
>
<div className="bg-muted mb-2 flex h-12 gap-1 rounded-md p-1.5">
{l.key === "grid" && (
<>
<div className="bg-primary/20 flex-1 rounded" />
<div className="bg-primary/20 flex-1 rounded" />
<div className="bg-primary/20 flex-1 rounded" />
</>
)}
{l.key === "list" && (
<div className="flex w-full flex-col gap-1">
<div className="bg-primary/20 h-3 rounded" />
<div className="bg-primary/20 h-3 rounded" />
<div className="bg-primary/20 h-3 rounded" />
</div>
)}
{l.key === "split" && (
<>
<div className="bg-primary/20 w-[40%] rounded" />
<div className="bg-primary/20 flex-1 rounded" />
</>
)}
</div>
<div className="text-foreground text-xs font-semibold">{l.label}</div>
</button>
))}
</div>
);
}
// ─── 갱신 설정 섹션 ──────────────────────────────────────────
function RefreshSettings({
refreshInterval,
autoRefresh,
soundOrAlarm,
soundOrAlarmLabel,
onIntervalChange,
onAutoRefreshChange,
onSoundOrAlarmChange,
}: {
refreshInterval: RefreshInterval;
autoRefresh: boolean;
soundOrAlarm: boolean;
soundOrAlarmLabel: string;
onIntervalChange: (v: RefreshInterval) => void;
onAutoRefreshChange: (v: boolean) => void;
onSoundOrAlarmChange: (v: boolean) => void;
}) {
return (
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label> </Label>
<Select value={String(refreshInterval)} onValueChange={(v) => onIntervalChange(Number(v) as RefreshInterval)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="30">30</SelectItem>
<SelectItem value="60">1</SelectItem>
<SelectItem value="300">5</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<div className="flex items-center gap-2 pt-1">
<Switch checked={autoRefresh} onCheckedChange={onAutoRefreshChange} />
<span className="text-muted-foreground text-sm">{autoRefresh ? "사용" : "사용안함"}</span>
</div>
</div>
<div className="space-y-2">
<Label>{soundOrAlarmLabel}</Label>
<div className="flex items-center gap-2 pt-1">
<Switch checked={soundOrAlarm} onCheckedChange={onSoundOrAlarmChange} />
<span className="text-muted-foreground text-sm">{soundOrAlarm ? "사용" : "사용안함"}</span>
</div>
</div>
</div>
);
}
// ─── 체크박스 그리드 ─────────────────────────────────────────
function FieldCheckboxGrid({
fields,
labels,
onChange,
}: {
fields: Record<string, boolean>;
labels: Record<string, string>;
onChange: (key: string, checked: boolean) => void;
}) {
const allChecked = Object.values(fields).every(Boolean);
const noneChecked = Object.values(fields).every((v) => !v);
return (
<div className="space-y-3">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => Object.keys(fields).forEach((k) => onChange(k, true))}
disabled={allChecked}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => Object.keys(fields).forEach((k) => onChange(k, false))}
disabled={noneChecked}
>
</Button>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{Object.entries(fields).map(([key, checked]) => (
<label
key={key}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2.5 transition-all select-none",
checked ? "bg-primary/5 border-primary/30" : "bg-background border-border hover:border-primary/30",
)}
>
<Checkbox checked={checked} onCheckedChange={(v) => onChange(key, v === true)} />
<span className="text-foreground text-sm font-medium">{labels[key] || key}</span>
</label>
))}
</div>
</div>
);
}
// ─── 생산모니터링 설정 ───────────────────────────────────────
function ProductionSettings({
settings,
setSettings,
}: {
settings: AllMonitoringSettings;
setSettings: React.Dispatch<React.SetStateAction<AllMonitoringSettings>>;
}) {
const prod = settings.production;
const update = (partial: Partial<typeof prod>) => {
setSettings((prev) => ({
...prev,
production: { ...prev.production, ...partial },
}));
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<ThemeSelector value={prod.theme} onChange={(theme) => update({ theme })} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<LayoutSelector value={prod.layout} onChange={(layout) => update({ layout })} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<RefreshSettings
refreshInterval={prod.refreshInterval}
autoRefresh={prod.autoRefresh}
soundOrAlarm={prod.soundEnabled}
soundOrAlarmLabel="알림음"
onIntervalChange={(refreshInterval) => update({ refreshInterval })}
onAutoRefreshChange={(autoRefresh) => update({ autoRefresh })}
onSoundOrAlarmChange={(soundEnabled) => update({ soundEnabled })}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<FieldCheckboxGrid
fields={prod.displayFields}
labels={PRODUCTION_FIELD_LABELS}
onChange={(key, checked) =>
update({
displayFields: { ...prod.displayFields, [key]: checked },
})
}
/>
</CardContent>
</Card>
</div>
);
}
// ─── 설비모니터링 설정 ───────────────────────────────────────
function EquipmentSettings({
settings,
setSettings,
}: {
settings: AllMonitoringSettings;
setSettings: React.Dispatch<React.SetStateAction<AllMonitoringSettings>>;
}) {
const equip = settings.equipment;
const update = (partial: Partial<typeof equip>) => {
setSettings((prev) => ({
...prev,
equipment: { ...prev.equipment, ...partial },
}));
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<ThemeSelector value={equip.theme} onChange={(theme) => update({ theme })} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<RefreshSettings
refreshInterval={equip.refreshInterval}
autoRefresh={equip.autoRefresh}
soundOrAlarm={equip.alarmEnabled}
soundOrAlarmLabel="이상 알림"
onIntervalChange={(refreshInterval) => update({ refreshInterval })}
onAutoRefreshChange={(autoRefresh) => update({ autoRefresh })}
onSoundOrAlarmChange={(alarmEnabled) => update({ alarmEnabled })}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<FieldCheckboxGrid
fields={equip.displayFields}
labels={EQUIPMENT_FIELD_LABELS}
onChange={(key, checked) =>
update({
displayFields: { ...equip.displayFields, [key]: checked },
})
}
/>
</CardContent>
</Card>
</div>
);
}
// ─── 품질모니터링 설정 ───────────────────────────────────────
function QualitySettings({
settings,
setSettings,
}: {
settings: AllMonitoringSettings;
setSettings: React.Dispatch<React.SetStateAction<AllMonitoringSettings>>;
}) {
const qual = settings.quality;
const update = (partial: Partial<typeof qual>) => {
setSettings((prev) => ({
...prev,
quality: { ...prev.quality, ...partial },
}));
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<ThemeSelector value={qual.theme} onChange={(theme) => update({ theme })} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<RefreshSettings
refreshInterval={qual.refreshInterval}
autoRefresh={qual.autoRefresh}
soundOrAlarm={qual.alarmEnabled}
soundOrAlarmLabel="불합격 알림"
onIntervalChange={(refreshInterval) => update({ refreshInterval })}
onAutoRefreshChange={(autoRefresh) => update({ autoRefresh })}
onSoundOrAlarmChange={(alarmEnabled) => update({ alarmEnabled })}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-2">
{Object.entries(qual.inspectionTypes).map(([key, checked]) => (
<label
key={key}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2.5 transition-all select-none",
checked ? "bg-primary/5 border-primary/30" : "bg-background border-border hover:border-primary/30",
)}
>
<Checkbox
checked={checked}
onCheckedChange={(v) =>
update({
inspectionTypes: { ...qual.inspectionTypes, [key]: v === true },
})
}
/>
<span className="text-foreground text-sm font-medium">{QUALITY_INSPECTION_LABELS[key] || key}</span>
</label>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<FieldCheckboxGrid
fields={qual.tableColumns}
labels={QUALITY_COLUMN_LABELS}
onChange={(key, checked) =>
update({
tableColumns: { ...qual.tableColumns, [key]: checked },
})
}
/>
</CardContent>
</Card>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import type { AllMonitoringSettings, MonitoringSettingsMap } from "@/types/monitoringSettings";
import {
DEFAULT_ALL_SETTINGS,
DEFAULT_PRODUCTION_SETTINGS,
DEFAULT_EQUIPMENT_SETTINGS,
DEFAULT_QUALITY_SETTINGS,
} from "@/lib/monitoringSettingsDefaults";
import useAuth from "@/hooks/useAuth";
const STORAGE_KEY_PREFIX = "monitoring_settings_";
function getStorageKey(companyCode: string) {
return `${STORAGE_KEY_PREFIX}${companyCode}`;
}
function loadFromStorage(companyCode: string): AllMonitoringSettings {
try {
const raw = localStorage.getItem(getStorageKey(companyCode));
if (!raw) return structuredClone(DEFAULT_ALL_SETTINGS);
const parsed = JSON.parse(raw);
// 기존 저장값과 기본값 병합 (새 필드 추가 대응)
return {
production: {
...DEFAULT_PRODUCTION_SETTINGS,
...parsed.production,
displayFields: { ...DEFAULT_PRODUCTION_SETTINGS.displayFields, ...parsed.production?.displayFields },
},
equipment: {
...DEFAULT_EQUIPMENT_SETTINGS,
...parsed.equipment,
displayFields: { ...DEFAULT_EQUIPMENT_SETTINGS.displayFields, ...parsed.equipment?.displayFields },
},
quality: {
...DEFAULT_QUALITY_SETTINGS,
...parsed.quality,
inspectionTypes: { ...DEFAULT_QUALITY_SETTINGS.inspectionTypes, ...parsed.quality?.inspectionTypes },
tableColumns: { ...DEFAULT_QUALITY_SETTINGS.tableColumns, ...parsed.quality?.tableColumns },
},
};
} catch {
return structuredClone(DEFAULT_ALL_SETTINGS);
}
}
function saveToStorage(companyCode: string, settings: AllMonitoringSettings) {
try {
localStorage.setItem(getStorageKey(companyCode), JSON.stringify(settings));
} catch {
// localStorage 용량 초과 등 무시
}
}
// ─── 개별 모니터링 페이지용 훅 ──────────────────────────────
export function useMonitoringSettings<T extends keyof MonitoringSettingsMap>(
monitorType: T,
): {
settings: MonitoringSettingsMap[T];
isLoaded: boolean;
} {
const { companyCode } = useAuth();
const defaults = {
production: DEFAULT_PRODUCTION_SETTINGS,
equipment: DEFAULT_EQUIPMENT_SETTINGS,
quality: DEFAULT_QUALITY_SETTINGS,
}[monitorType] as MonitoringSettingsMap[T];
const [settings, setSettings] = useState<MonitoringSettingsMap[T]>(defaults);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
if (!companyCode) return;
const all = loadFromStorage(companyCode);
setSettings(all[monitorType] as MonitoringSettingsMap[T]);
setIsLoaded(true);
}, [companyCode, monitorType]);
// 다른 탭에서 설정 변경 시 동기화
useEffect(() => {
if (!companyCode) return;
const handler = (e: StorageEvent) => {
if (e.key === getStorageKey(companyCode) && e.newValue) {
try {
const parsed = JSON.parse(e.newValue);
setSettings(parsed[monitorType]);
} catch {
// 파싱 실패 무시
}
}
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, [companyCode, monitorType]);
return { settings, isLoaded };
}
// ─── 설정 페이지용 훅 ───────────────────────────────────────
export function useMonitoringSettingsAll(): {
settings: AllMonitoringSettings;
setSettings: React.Dispatch<React.SetStateAction<AllMonitoringSettings>>;
saveAll: () => void;
resetAll: () => void;
isLoaded: boolean;
} {
const { companyCode } = useAuth();
const [settings, setSettings] = useState<AllMonitoringSettings>(structuredClone(DEFAULT_ALL_SETTINGS));
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
if (!companyCode) return;
setSettings(loadFromStorage(companyCode));
setIsLoaded(true);
}, [companyCode]);
const saveAll = useCallback(() => {
if (!companyCode) return;
saveToStorage(companyCode, settings);
}, [companyCode, settings]);
const resetAll = useCallback(() => {
const defaults = structuredClone(DEFAULT_ALL_SETTINGS);
setSettings(defaults);
if (companyCode) {
saveToStorage(companyCode, defaults);
}
}, [companyCode]);
return { settings, setSettings, saveAll, resetAll, isLoaded };
}
+101
View File
@@ -0,0 +1,101 @@
import type {
AllMonitoringSettings,
ProductionMonitoringSettings,
ProductionDisplayFields,
EquipmentMonitoringSettings,
EquipmentDisplayFields,
QualityMonitoringSettings,
QualityInspectionTypes,
QualityTableColumns,
} from "@/types/monitoringSettings";
// ─── 생산모니터링 기본값 ─────────────────────────────────────
export const DEFAULT_PRODUCTION_FIELDS: ProductionDisplayFields = {
workInstructionNo: true,
itemName: true,
spec: true,
customerName: true,
worker: true,
dueDate: true,
equipment: true,
processProgress: true,
progressBar: true,
priority: true,
salesOrderNo: false,
quantityInfo: false,
};
export const DEFAULT_PRODUCTION_SETTINGS: ProductionMonitoringSettings = {
theme: "dark",
layout: "grid",
refreshInterval: 30,
autoRefresh: true,
soundEnabled: false,
displayFields: { ...DEFAULT_PRODUCTION_FIELDS },
};
// ─── 설비모니터링 기본값 ─────────────────────────────────────
export const DEFAULT_EQUIPMENT_FIELDS: EquipmentDisplayFields = {
equipmentName: true,
equipmentType: true,
equipmentLocation: true,
operationStatus: true,
utilizationBar: true,
dailyOperationTime: true,
dailyProductionQty: true,
worker: true,
currentWorkInstruction: true,
sensorData: true,
cumulativeOperationTime: false,
nextInspectionDate: false,
};
export const DEFAULT_EQUIPMENT_SETTINGS: EquipmentMonitoringSettings = {
theme: "dark",
refreshInterval: 30,
autoRefresh: true,
alarmEnabled: true,
displayFields: { ...DEFAULT_EQUIPMENT_FIELDS },
};
// ─── 품질모니터링 기본값 ─────────────────────────────────────
export const DEFAULT_QUALITY_INSPECTION_TYPES: QualityInspectionTypes = {
incoming: true,
process: true,
shipping: true,
};
export const DEFAULT_QUALITY_COLUMNS: QualityTableColumns = {
inspectionNo: true,
inspectionType: true,
itemName: true,
spec: true,
inspectionQty: true,
passFailQty: true,
defectRate: true,
resultBar: true,
judgment: true,
inspector: true,
inspectedAt: true,
inspectionCriteria: false,
};
export const DEFAULT_QUALITY_SETTINGS: QualityMonitoringSettings = {
theme: "dark",
refreshInterval: 30,
autoRefresh: true,
alarmEnabled: true,
inspectionTypes: { ...DEFAULT_QUALITY_INSPECTION_TYPES },
tableColumns: { ...DEFAULT_QUALITY_COLUMNS },
};
// ─── 전체 기본값 ─────────────────────────────────────────────
export const DEFAULT_ALL_SETTINGS: AllMonitoringSettings = {
production: { ...DEFAULT_PRODUCTION_SETTINGS },
equipment: { ...DEFAULT_EQUIPMENT_SETTINGS },
quality: { ...DEFAULT_QUALITY_SETTINGS },
};
+97
View File
@@ -0,0 +1,97 @@
import type { MonitoringTheme } from "@/types/monitoringSettings";
interface ThemeClasses {
root: string;
card: string;
cardBorder: string;
header: string;
headerText: string;
text: string;
mutedText: string;
divider: string;
tableHeader: string;
tableRow: string;
tableRowHover: string;
/** CSS 변수 인라인 오버라이드 (--color-* Tailwind 테마 변수 직접 지정) */
cssVars?: React.CSSProperties;
}
// --color-* 변수를 직접 오버라이드하여 bg-background, bg-card 등이 즉시 반영되도록 함
const DARK_CSS_VARS: React.CSSProperties = {
"--color-background": "hsl(222 47% 6%)",
"--color-foreground": "hsl(210 20% 95%)",
"--color-card": "hsl(220 40% 9%)",
"--color-card-foreground": "hsl(210 20% 95%)",
"--color-popover": "hsl(220 40% 9%)",
"--color-popover-foreground": "hsl(210 20% 95%)",
"--color-primary": "hsl(217 91% 65%)",
"--color-primary-foreground": "hsl(0 0% 100%)",
"--color-secondary": "hsl(220 25% 14%)",
"--color-secondary-foreground": "hsl(210 20% 90%)",
"--color-muted": "hsl(220 20% 13%)",
"--color-muted-foreground": "hsl(215 15% 58%)",
"--color-accent": "hsl(220 25% 16%)",
"--color-accent-foreground": "hsl(210 20% 90%)",
"--color-border": "hsl(220 20% 18%)",
"--color-input": "hsl(220 20% 18%)",
"--color-ring": "hsl(217 91% 65%)",
"--color-destructive": "hsl(0 72% 51%)",
"--color-destructive-foreground": "hsl(0 0% 100%)",
} as React.CSSProperties;
const BLUE_CSS_VARS: React.CSSProperties = {
"--color-background": "hsl(215 50% 8%)",
"--color-foreground": "hsl(210 20% 95%)",
"--color-card": "hsl(215 45% 12%)",
"--color-card-foreground": "hsl(210 20% 95%)",
"--color-popover": "hsl(215 45% 12%)",
"--color-popover-foreground": "hsl(210 20% 95%)",
"--color-primary": "hsl(217 91% 65%)",
"--color-primary-foreground": "hsl(0 0% 100%)",
"--color-secondary": "hsl(215 30% 14%)",
"--color-secondary-foreground": "hsl(210 20% 90%)",
"--color-muted": "hsl(215 35% 16%)",
"--color-muted-foreground": "hsl(215 15% 58%)",
"--color-accent": "hsl(215 35% 18%)",
"--color-accent-foreground": "hsl(210 20% 90%)",
"--color-border": "hsl(215 30% 20%)",
"--color-input": "hsl(215 30% 20%)",
"--color-ring": "hsl(217 91% 65%)",
"--color-destructive": "hsl(0 72% 51%)",
"--color-destructive-foreground": "hsl(0 0% 100%)",
} as React.CSSProperties;
const SHARED_CLASSES = {
card: "bg-card",
cardBorder: "border-border",
header: "bg-card",
headerText: "text-foreground",
text: "text-foreground",
mutedText: "text-muted-foreground",
divider: "border-border",
tableHeader: "bg-muted text-muted-foreground",
tableRow: "bg-card text-card-foreground",
tableRowHover: "hover:bg-muted/50",
};
const THEME_MAP: Record<MonitoringTheme, ThemeClasses> = {
dark: {
root: "bg-background text-foreground",
...SHARED_CLASSES,
cssVars: DARK_CSS_VARS,
},
blue: {
root: "bg-background text-foreground",
...SHARED_CLASSES,
cssVars: BLUE_CSS_VARS,
},
light: {
root: "bg-background text-foreground",
...SHARED_CLASSES,
// cssVars 없음 → 시스템 기본 라이트 변수 사용
},
};
export function getMonitoringTheme(theme: MonitoringTheme): ThemeClasses {
return THEME_MAP[theme] ?? THEME_MAP.dark;
}
+104
View File
@@ -0,0 +1,104 @@
// ─── 모니터링 설정 타입 정의 ─────────────────────────────────
export type MonitoringTheme = "dark" | "blue" | "light";
export type ProductionLayout = "grid" | "list" | "split";
export type RefreshInterval = 10 | 30 | 60 | 300;
// ─── 생산모니터링 ────────────────────────────────────────────
export interface ProductionDisplayFields {
workInstructionNo: boolean; // 작업지시번호
itemName: boolean; // 품목명
spec: boolean; // 규격
customerName: boolean; // 거래처
worker: boolean; // 작업자/작업조
dueDate: boolean; // 납기일
equipment: boolean; // 사용설비
processProgress: boolean; // 공정 진행현황
progressBar: boolean; // 진행률 바
priority: boolean; // 우선순위 표시
salesOrderNo: boolean; // 수주번호
quantityInfo: boolean; // 지시수량/완료수량
}
export interface ProductionMonitoringSettings {
theme: MonitoringTheme;
layout: ProductionLayout;
refreshInterval: RefreshInterval;
autoRefresh: boolean;
soundEnabled: boolean;
displayFields: ProductionDisplayFields;
}
// ─── 설비모니터링 ────────────────────────────────────────────
export interface EquipmentDisplayFields {
equipmentName: boolean; // 설비명
equipmentType: boolean; // 설비유형
equipmentLocation: boolean; // 설비위치
operationStatus: boolean; // 가동상태
utilizationBar: boolean; // 가동률 바
dailyOperationTime: boolean; // 금일 가동시간
dailyProductionQty: boolean; // 금일 생산수량
worker: boolean; // 작업자
currentWorkInstruction: boolean; // 현재 작업지시
sensorData: boolean; // 센서 데이터 (온도/압력/RPM)
cumulativeOperationTime: boolean; // 누적 가동시간
nextInspectionDate: boolean; // 다음 점검 예정일
}
export interface EquipmentMonitoringSettings {
theme: MonitoringTheme;
refreshInterval: RefreshInterval;
autoRefresh: boolean;
alarmEnabled: boolean;
displayFields: EquipmentDisplayFields;
}
// ─── 품질점검현황 ────────────────────────────────────────────
export interface QualityInspectionTypes {
incoming: boolean; // 입고검사
process: boolean; // 공정검사
shipping: boolean; // 출하검사
}
export interface QualityTableColumns {
inspectionNo: boolean; // 검사번호
inspectionType: boolean; // 검사유형
itemName: boolean; // 품목명
spec: boolean; // 규격
inspectionQty: boolean; // 검사수량
passFailQty: boolean; // 합격/불합격 수량
defectRate: boolean; // 불량률
resultBar: boolean; // 검사결과 바
judgment: boolean; // 판정
inspector: boolean; // 검사자
inspectedAt: boolean; // 검사일시
inspectionCriteria: boolean; // 검사기준
}
export interface QualityMonitoringSettings {
theme: MonitoringTheme;
refreshInterval: RefreshInterval;
autoRefresh: boolean;
alarmEnabled: boolean;
inspectionTypes: QualityInspectionTypes;
tableColumns: QualityTableColumns;
}
// ─── 전체 설정 ───────────────────────────────────────────────
export interface AllMonitoringSettings {
production: ProductionMonitoringSettings;
equipment: EquipmentMonitoringSettings;
quality: QualityMonitoringSettings;
}
// ─── 타입 매핑 ───────────────────────────────────────────────
export type MonitoringSettingsMap = {
production: ProductionMonitoringSettings;
equipment: EquipmentMonitoringSettings;
quality: QualityMonitoringSettings;
};