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:
@@ -12,16 +12,10 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||||
RefreshCw,
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||||
Clock,
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
Loader2,
|
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
|
||||||
Inbox,
|
|
||||||
Wrench,
|
|
||||||
Zap,
|
|
||||||
Pause,
|
|
||||||
Power,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
/* ───── 상태 정의 ───── */
|
/* ───── 상태 정의 ───── */
|
||||||
|
|
||||||
@@ -134,11 +128,16 @@ interface WorkInstruction {
|
|||||||
/* ───── 컴포넌트 ───── */
|
/* ───── 컴포넌트 ───── */
|
||||||
|
|
||||||
export default function EquipmentMonitoringPage() {
|
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 [equipments, setEquipments] = useState<Equipment[]>([]);
|
||||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
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 [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||||
const autoRefreshRef = useRef(autoRefresh);
|
const autoRefreshRef = useRef(autoRefresh);
|
||||||
|
|
||||||
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
/* ── 자동 갱신 (30초) ── */
|
/* ── 자동 갱신 ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (autoRefreshRef.current) fetchData();
|
if (autoRefreshRef.current) fetchData();
|
||||||
}, 30000);
|
}, settings.refreshInterval * 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchData]);
|
}, [fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
/* ── 요약 통계 ── */
|
/* ── 요약 통계 ── */
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
|
|||||||
|
|
||||||
/* ── 필터 pill ── */
|
/* ── 필터 pill ── */
|
||||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
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: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
|
||||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-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-300 hover:bg-amber-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-300 hover:bg-red-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-300 hover:bg-gray-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 (
|
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">
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-8 w-1.5 rounded-full bg-amber-400" />
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">{formatDate(currentTime)}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 자동갱신 토글 */}
|
{/* 자동갱신 토글 */}
|
||||||
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-gray-700 text-xs gap-1.5",
|
"gap-1.5 text-xs",
|
||||||
autoRefresh
|
autoRefresh
|
||||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
|
||||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={() => setAutoRefresh((v) => !v)}
|
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"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
className="gap-1.5"
|
||||||
onClick={fetchData}
|
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
<Settings2 className="h-4 w-4" />
|
||||||
새로고침
|
설정
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── 요약 카드 5개 ── */}
|
{/* ── 요약 카드 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) => (
|
{summaryCards.map((card) => (
|
||||||
<button
|
<button
|
||||||
key={card.label}
|
key={card.label}
|
||||||
onClick={() =>
|
onClick={() => setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))}
|
||||||
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
|
|
||||||
}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
||||||
card.bg,
|
card.bg,
|
||||||
card.border,
|
card.border,
|
||||||
"hover:shadow-lg"
|
"hover:shadow-lg",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
||||||
{card.icon}
|
{card.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<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>
|
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||||
filterStatus === pill.value
|
filterStatus === pill.value
|
||||||
? cn(pill.color, "ring-1 ring-white/20")
|
? cn(pill.color, "ring-1 ring-foreground/10")
|
||||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pill.label}
|
{pill.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<span className="ml-auto text-sm text-gray-600 self-center">
|
<span className="text-muted-foreground ml-auto self-center text-sm">{filteredEquipments.length}대 표시</span>
|
||||||
{filteredEquipments.length}대 표시
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 로딩 ── */}
|
{/* ── 로딩 ── */}
|
||||||
{loading && equipments.length === 0 && (
|
{loading && equipments.length === 0 && (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-gray-600" />
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
<span className="text-muted-foreground ml-3">설비 데이터를 불러오는 중...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 데이터 없음 ── */}
|
{/* ── 데이터 없음 ── */}
|
||||||
{!loading && equipments.length === 0 && (
|
{!loading && equipments.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-gray-600">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Inbox className="h-12 w-12 mb-3" />
|
<Inbox className="mb-3 h-12 w-12" />
|
||||||
<p className="text-lg">등록된 설비가 없습니다.</p>
|
<p className="text-lg">등록된 설비가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 설비 카드 그리드 ── */}
|
{/* ── 설비 카드 그리드 ── */}
|
||||||
{filteredEquipments.length > 0 && (
|
{filteredEquipments.length > 0 && (
|
||||||
<div
|
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}>
|
||||||
className="grid gap-4"
|
|
||||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
|
|
||||||
>
|
|
||||||
{filteredEquipments.map((eq) => {
|
{filteredEquipments.map((eq) => {
|
||||||
const status = resolveStatus(eq.operation_status);
|
const status = resolveStatus(eq.operation_status);
|
||||||
const cfg = STATUS_MAP[status];
|
const cfg = STATUS_MAP[status];
|
||||||
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
|
|||||||
<div
|
<div
|
||||||
key={eq.id}
|
key={eq.id}
|
||||||
className={cn(
|
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.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="flex items-start justify-between px-4 pt-3 pb-2 pl-5">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-base font-semibold text-white truncate">
|
{df.equipmentName && (
|
||||||
{eq.equipment_name || "이름 없음"}
|
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
|
||||||
</h3>
|
{eq.equipment_name || "이름 없음"}
|
||||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
</h3>
|
||||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
)}
|
||||||
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
{df.operationStatus && (
|
||||||
className={cn(
|
<Badge
|
||||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
className={cn("ml-2 shrink-0 gap-1 border-0 text-xs font-medium", cfg.badgeBg, cfg.badgeText)}
|
||||||
cfg.badgeBg,
|
>
|
||||||
cfg.badgeText
|
{cfg.icon}
|
||||||
)}
|
{cfg.label}
|
||||||
>
|
</Badge>
|
||||||
{cfg.icon}
|
|
||||||
{cfg.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 정보 그리드 */}
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
|
||||||
<p className="text-white font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">생산수량</span>
|
|
||||||
<p className="text-white font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">작업자</span>
|
|
||||||
<p className="text-white font-medium">
|
|
||||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
|
||||||
? eqWIs[0].worker_name
|
|
||||||
: "-"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">설비코드</span>
|
|
||||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 가동률 프로그레스 */}
|
|
||||||
<div className="px-4 pl-5 py-2.5">
|
|
||||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
|
||||||
<span className="text-gray-500">가동률</span>
|
|
||||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
|
||||||
{utilization !== null ? `${utilization}%` : "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
|
||||||
{utilization !== null && (
|
|
||||||
<div
|
|
||||||
className={cn("h-full rounded-full transition-all duration-700", cfg.bar)}
|
|
||||||
style={{ width: `${utilization}%` }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 현재 작업지시 */}
|
|
||||||
<div className="px-4 pl-5 py-2.5">
|
|
||||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</p>
|
|
||||||
{eqWIs.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{eqWIs.slice(0, 2).map((wi) => (
|
|
||||||
<div key={wi.id} className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-blue-400 font-mono text-xs shrink-0">
|
|
||||||
{wi.instruction_number || "-"}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-300 truncate">
|
|
||||||
{wi.item_name || "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{eqWIs.length > 2 && (
|
|
||||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 구분선 */}
|
{/* 구분선 */}
|
||||||
<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="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
{df.dailyOperationTime && (
|
||||||
<span className="text-gray-600">온도</span>
|
<div>
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
<span className={cn("text-xs", theme.mutedText)}>금일 가동시간</span>
|
||||||
</div>
|
<p className={cn("font-medium", theme.text)}>-</p>
|
||||||
<div className="flex items-center gap-1.5">
|
</div>
|
||||||
<span className="text-gray-600">압력</span>
|
)}
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
{df.dailyProductionQty && (
|
||||||
</div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5">
|
<span className={cn("text-xs", theme.mutedText)}>생산수량</span>
|
||||||
<span className="text-gray-600">RPM</span>
|
<p className={cn("font-medium", theme.text)}>-</p>
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
|
|||||||
|
|
||||||
{/* 필터 결과 없음 */}
|
{/* 필터 결과 없음 */}
|
||||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
|
||||||
<Inbox className="h-10 w-10 mb-2" />
|
<Inbox className="mb-2 h-10 w-10" />
|
||||||
<p>해당 상태의 설비가 없습니다.</p>
|
<p>해당 상태의 설비가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -16,6 +20,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||||
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 작업지시별 공정현황으로 진행상태 계산
|
// 작업지시별 공정현황으로 진행상태 계산
|
||||||
function computeProgress(
|
function computeProgress(wiId: string, processMap: Map<string, ProcessStep[]>): "대기" | "진행중" | "완료" {
|
||||||
wiId: string,
|
|
||||||
processMap: Map<string, ProcessStep[]>
|
|
||||||
): "대기" | "진행중" | "완료" {
|
|
||||||
const steps = processMap.get(wiId);
|
const steps = processMap.get(wiId);
|
||||||
if (!steps || steps.length === 0) return "대기";
|
if (!steps || steps.length === 0) return "대기";
|
||||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||||
@@ -85,11 +87,15 @@ function computeProgress(
|
|||||||
|
|
||||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||||
export default function ProductionMonitoringPage() {
|
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 [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||||
|
|
||||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||||
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
// 작업지시 목록 조회
|
// 작업지시 목록 조회
|
||||||
const wiRes = await apiClient.get("/work-instruction/list");
|
const wiRes = await apiClient.get("/work-instruction/list");
|
||||||
const wiRaw: WorkInstruction[] =
|
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
|
||||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const wiData = wiRaw.filter((wi) => {
|
const wiData = wiRaw.filter((wi) => {
|
||||||
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
|
|||||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||||
try {
|
try {
|
||||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
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 || [];
|
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||||
|
|
||||||
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
// ─── 자동갱신 ────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh) return;
|
if (!autoRefresh) return;
|
||||||
const timer = setInterval(fetchData, 30000);
|
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [autoRefresh, fetchData]);
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
// ─── 통계 계산 ───────────────────────────────────────────
|
// ─── 통계 계산 ───────────────────────────────────────────
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
// ─── 렌더링 ──────────────────────────────────────────────
|
// ─── 렌더링 ──────────────────────────────────────────────
|
||||||
return (
|
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">
|
<div className="flex flex-shrink-0 items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
|
||||||
variant="outline"
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||||
size="sm"
|
|
||||||
onClick={fetchData}
|
|
||||||
disabled={loading}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
|
|||||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
className="gap-1.5"
|
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"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 요약 카드 */}
|
{/* 요약 카드 */}
|
||||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Timer className="w-5 h-5" />}
|
icon={<Timer className="h-5 w-5" />}
|
||||||
label="대기중"
|
label="대기중"
|
||||||
value={stats.waiting}
|
value={stats.waiting}
|
||||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Loader2 className="w-5 h-5" />}
|
icon={<Loader2 className="h-5 w-5" />}
|
||||||
label="진행중"
|
label="진행중"
|
||||||
value={stats.inProgress}
|
value={stats.inProgress}
|
||||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
label="완료"
|
label="완료"
|
||||||
value={stats.completed}
|
value={stats.completed}
|
||||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<TrendingUp className="w-5 h-5" />}
|
icon={<TrendingUp className="h-5 w-5" />}
|
||||||
label="달성율"
|
label="달성율"
|
||||||
value={`${stats.achievementRate}%`}
|
value={`${stats.achievementRate}%`}
|
||||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||||
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
</div>
|
</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) => (
|
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||||
<Button
|
<Button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
|
|||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[64px]",
|
"min-w-[64px]",
|
||||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
|
||||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
|
||||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
{/* 로딩 상태 */}
|
{/* 로딩 상태 */}
|
||||||
{loading && workInstructions.length === 0 && (
|
{loading && workInstructions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
|
||||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 빈 상태 */}
|
{/* 빈 상태 */}
|
||||||
{!loading && filteredInstructions.length === 0 && (
|
{!loading && filteredInstructions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Inbox className="w-12 h-12 mb-3" />
|
<Inbox className="mb-3 h-12 w-12" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{activeTab === "전체"
|
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||||
? "등록된 작업지시가 없습니다."
|
|
||||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 작업 카드 그리드 */}
|
{/* 작업 카드 */}
|
||||||
{filteredInstructions.length > 0 && (
|
{filteredInstructions.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="grid gap-4 flex-1"
|
className={cn(
|
||||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
"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) => (
|
{filteredInstructions.map((wi, idx) => (
|
||||||
<WorkCard
|
<WorkCard
|
||||||
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
instruction={wi}
|
instruction={wi}
|
||||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||||
|
displayFields={settings.displayFields}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -338,11 +354,11 @@ function SummaryCard({
|
|||||||
colorClass: string;
|
colorClass: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
|
||||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
<span className="text-muted-foreground text-xs">{label}</span>
|
||||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
<span className="text-foreground text-2xl font-bold">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -353,10 +369,12 @@ function WorkCard({
|
|||||||
instruction: wi,
|
instruction: wi,
|
||||||
steps,
|
steps,
|
||||||
progress,
|
progress,
|
||||||
|
displayFields: df,
|
||||||
}: {
|
}: {
|
||||||
instruction: WorkInstruction;
|
instruction: WorkInstruction;
|
||||||
steps: ProcessStep[];
|
steps: ProcessStep[];
|
||||||
progress: "대기" | "진행중" | "완료";
|
progress: "대기" | "진행중" | "완료";
|
||||||
|
displayFields: ProductionDisplayFields;
|
||||||
}) {
|
}) {
|
||||||
// API 응답은 flat 구조 (details 배열 아님)
|
// API 응답은 flat 구조 (details 배열 아님)
|
||||||
const itemName = (wi as any).item_name || "-";
|
const itemName = (wi as any).item_name || "-";
|
||||||
@@ -373,36 +391,28 @@ function WorkCard({
|
|||||||
const currentStep = steps.find((s) => s.status !== "completed");
|
const currentStep = steps.find((s) => s.status !== "completed");
|
||||||
|
|
||||||
// 프로그레스바 색상
|
// 프로그레스바 색상
|
||||||
const barColor =
|
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
|
||||||
progressPercent >= 100
|
|
||||||
? "bg-emerald-500"
|
|
||||||
: progressPercent >= 50
|
|
||||||
? "bg-blue-500"
|
|
||||||
: "bg-amber-500";
|
|
||||||
|
|
||||||
// 상태 배지 스타일
|
// 상태 배지 스타일
|
||||||
const statusBadge: Record<string, string> = {
|
const statusBadge: Record<string, string> = {
|
||||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-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-blue-500/10 text-blue-500 border-blue-500/30",
|
||||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
완료: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
const isUrgent = wi.status === "긴급";
|
const isUrgent = wi.status === "긴급";
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-sm text-foreground">
|
{df.workInstructionNo && (
|
||||||
{wi.work_instruction_no}
|
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
|
||||||
</span>
|
)}
|
||||||
{isUrgent && (
|
{df.priority && isUrgent && (
|
||||||
<Badge
|
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
|
||||||
variant="outline"
|
<AlertTriangle className="h-3 w-3" />
|
||||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="w-3 h-3" />
|
|
||||||
긴급
|
긴급
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -413,82 +423,88 @@ function WorkCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카드 본문 - 정보 */}
|
{/* 카드 본문 - 정보 */}
|
||||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
|
||||||
<InfoRow label="품목명" value={itemName} />
|
{df.itemName && <InfoRow label="품목명" value={itemName} />}
|
||||||
<InfoRow label="규격" value={spec} />
|
{df.spec && <InfoRow label="규격" value={spec} />}
|
||||||
<InfoRow label="거래처" value={customerName} />
|
{df.customerName && <InfoRow label="거래처" value={customerName} />}
|
||||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
|
||||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
|
||||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
{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>
|
||||||
|
|
||||||
{/* 공정현황 */}
|
{/* 공정현황 */}
|
||||||
<div className="px-4 py-3 border-b">
|
{df.processProgress && (
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="border-b px-4 py-3">
|
||||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
{steps.length > 0 && (
|
<span className="text-muted-foreground text-xs font-medium">공정현황</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
{steps.length > 0 && (
|
||||||
완료 {completedSteps}/{steps.length}
|
<span className="text-muted-foreground text-xs">
|
||||||
{currentStep && (
|
완료 {completedSteps}/{steps.length}
|
||||||
<span>
|
{currentStep && (
|
||||||
{" "}
|
<span>
|
||||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
{" "}
|
||||||
</span>
|
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||||
)}
|
</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>
|
</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">
|
{df.progressBar && (
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="px-4 py-3">
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
{completedQty} / {totalQty}
|
<span className="text-muted-foreground text-xs">
|
||||||
</span>
|
{completedQty} / {totalQty}
|
||||||
<span
|
</span>
|
||||||
className={cn(
|
<span
|
||||||
"text-xs font-bold",
|
className={cn(
|
||||||
progressPercent >= 100
|
"text-xs font-bold",
|
||||||
? "text-emerald-500"
|
progressPercent >= 100
|
||||||
: progressPercent >= 50
|
? "text-emerald-500"
|
||||||
? "text-blue-500"
|
: progressPercent >= 50
|
||||||
: "text-amber-500"
|
? "text-blue-500"
|
||||||
)}
|
: "text-amber-500",
|
||||||
>
|
)}
|
||||||
{progressPercent}%
|
>
|
||||||
</span>
|
{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>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -496,7 +512,7 @@ function WorkCard({
|
|||||||
// ─── 정보 행 ───────────────────────────────────────────────
|
// ─── 정보 행 ───────────────────────────────────────────────
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
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-muted-foreground shrink-0">{label}:</span>
|
||||||
<span className="text-foreground truncate">{value}</span>
|
<span className="text-foreground truncate">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,23 +4,12 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"
|
|||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||||
RefreshCw,
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||||
Clock,
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
Loader2,
|
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
||||||
Inbox,
|
|
||||||
Search,
|
|
||||||
ClipboardCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
/* ───── 타입 ───── */
|
/* ───── 타입 ───── */
|
||||||
interface ProcessRow {
|
interface ProcessRow {
|
||||||
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
|
|||||||
|
|
||||||
/* ───── 유틸 ───── */
|
/* ───── 유틸 ───── */
|
||||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||||
const pct = (n: number) =>
|
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||||
`${n.toFixed(1)}%`;
|
|
||||||
|
|
||||||
const badgeVariant = (
|
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
|
||||||
type: "result" | "type" | "defectRate",
|
|
||||||
value: string | number,
|
|
||||||
) => {
|
|
||||||
if (type === "result") {
|
if (type === "result") {
|
||||||
if (value === "합격")
|
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
|
||||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||||
}
|
}
|
||||||
if (type === "type") {
|
if (type === "type") {
|
||||||
if (value === "공정검사")
|
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
|
||||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
|
||||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
}
|
}
|
||||||
@@ -93,10 +76,15 @@ const badgeVariant = (
|
|||||||
|
|
||||||
/* ───── 컴포넌트 ───── */
|
/* ───── 컴포넌트 ───── */
|
||||||
export default function QualityMonitoringPage() {
|
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 [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
|
|||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.post(
|
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||||
"/table-management/tables/work_order_process/data",
|
|
||||||
{ autoFilter: true },
|
|
||||||
);
|
|
||||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||||
setProcessData(rows);
|
setProcessData(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
|
|||||||
/* ───── 자동 갱신 ───── */
|
/* ───── 자동 갱신 ───── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRefresh) {
|
if (autoRefresh) {
|
||||||
intervalRef.current = setInterval(fetchData, 30_000);
|
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
};
|
};
|
||||||
}, [autoRefresh, fetchData]);
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
/* ───── 검사 행 변환 ───── */
|
/* ───── 검사 행 변환 ───── */
|
||||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||||
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
|
|||||||
const goodQty = r.good_qty ?? 0;
|
const goodQty = r.good_qty ?? 0;
|
||||||
const defectQty = r.defect_qty ?? 0;
|
const defectQty = r.defect_qty ?? 0;
|
||||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||||
const result: InspectionRow["result"] =
|
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
|
||||||
r.status !== "completed"
|
|
||||||
? "대기"
|
|
||||||
: defectQty > 0
|
|
||||||
? "불합격"
|
|
||||||
: "합격";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
no: idx + 1,
|
no: idx + 1,
|
||||||
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
|
|||||||
|
|
||||||
/* ───── 렌더링 ───── */
|
/* ───── 렌더링 ───── */
|
||||||
return (
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
||||||
품질점검현황{" "}
|
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
||||||
<span className="text-emerald-600">모니터링</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{currentTime.toLocaleString("ko-KR", {
|
{currentTime.toLocaleString("ko-KR", {
|
||||||
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||||
variant="outline"
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
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>
|
<span className="ml-1">새로고침</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={autoRefresh ? "default" : "outline"}
|
variant={autoRefresh ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setAutoRefresh((p) => !p)}
|
onClick={() => setAutoRefresh((p) => !p)}
|
||||||
className={cn(
|
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
|
||||||
autoRefresh &&
|
|
||||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Clock className="h-4 w-4 mr-1" />
|
<Clock className="mr-1 h-4 w-4" />
|
||||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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>
|
</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">
|
<div className="grid grid-cols-5 gap-4">
|
||||||
{summaryCards.map((card) => (
|
{summaryCards.map((card) => (
|
||||||
<div
|
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||||
key={card.label}
|
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||||
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)}>
|
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||||
{card.value}
|
{card.value}
|
||||||
{card.sub && (
|
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
|
||||||
<span className="ml-1 text-base font-normal text-white/70">
|
|
||||||
{card.sub}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
|
|||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={cn(
|
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
|
activeTab === tab.key
|
||||||
? "bg-emerald-600 text-white shadow"
|
? "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}
|
{tab.label}
|
||||||
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
|
|||||||
</div>
|
</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">
|
<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-lg font-medium">준비중</p>
|
||||||
<p className="text-sm mt-1">
|
<p className="mt-1 text-sm">
|
||||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
||||||
아직 지원되지 않습니다.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : loading && filteredRows.length === 0 ? (
|
) : loading && filteredRows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
<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>
|
<p>데이터를 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredRows.length === 0 ? (
|
) : filteredRows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
<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>
|
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<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="w-[50px] text-center">No</TableHead>
|
||||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
||||||
<TableHead className="min-w-[90px] text-center">
|
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
||||||
검사유형
|
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
||||||
</TableHead>
|
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
||||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
||||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
||||||
검사수량
|
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
||||||
</TableHead>
|
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
||||||
합격수량
|
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
||||||
</TableHead>
|
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
||||||
불합격수량
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[70px] text-right">
|
|
||||||
불량율
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[160px] text-center">
|
|
||||||
검사결과
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[70px] text-center">
|
|
||||||
판정
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[80px] text-center">
|
|
||||||
검사자
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
|
||||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredRows.map((row) => {
|
{filteredRows.map((row) => {
|
||||||
const goodPct =
|
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
||||||
row.inspectionQty > 0
|
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
||||||
? (row.goodQty / row.inspectionQty) * 100
|
|
||||||
: 0;
|
|
||||||
const defectPct =
|
|
||||||
row.inspectionQty > 0
|
|
||||||
? (row.defectQty / row.inspectionQty) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow key={row.no} className={theme.tableRowHover}>
|
||||||
key={row.no}
|
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
||||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
||||||
>
|
{tc.inspectionType && (
|
||||||
<TableCell className="text-center text-sm text-gray-500">
|
<TableCell className="text-center">
|
||||||
{row.no}
|
<Badge
|
||||||
</TableCell>
|
variant="outline"
|
||||||
<TableCell className="font-mono text-sm">
|
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
||||||
{row.inspectionNo}
|
>
|
||||||
</TableCell>
|
{row.inspectionType}
|
||||||
<TableCell className="text-center">
|
</Badge>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant="outline"
|
)}
|
||||||
className={cn(
|
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
||||||
"text-xs",
|
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
||||||
badgeVariant("type", row.inspectionType),
|
{tc.inspectionQty && (
|
||||||
)}
|
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
||||||
>
|
)}
|
||||||
{row.inspectionType}
|
{tc.passFailQty && (
|
||||||
</Badge>
|
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm font-medium">
|
{tc.passFailQty && (
|
||||||
{row.itemName}
|
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm text-gray-500">
|
{tc.defectRate && (
|
||||||
{row.spec}
|
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
||||||
</TableCell>
|
{pct(row.defectRate)}
|
||||||
<TableCell className="text-right text-sm">
|
</TableCell>
|
||||||
{fmt(row.inspectionQty)}
|
)}
|
||||||
</TableCell>
|
{tc.resultBar && (
|
||||||
<TableCell className="text-right text-sm text-emerald-600">
|
<TableCell>
|
||||||
{fmt(row.goodQty)}
|
<div className="flex items-center gap-2">
|
||||||
</TableCell>
|
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
||||||
<TableCell className="text-right text-sm text-red-600">
|
<div
|
||||||
{fmt(row.defectQty)}
|
className="h-full bg-emerald-500 transition-all"
|
||||||
</TableCell>
|
style={{ width: `${goodPct}%` }}
|
||||||
<TableCell
|
/>
|
||||||
className={cn(
|
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
||||||
"text-right text-sm",
|
</div>
|
||||||
badgeVariant("defectRate", row.defectRate),
|
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
||||||
)}
|
{pct(goodPct)}
|
||||||
>
|
</span>
|
||||||
{pct(row.defectRate)}
|
|
||||||
</TableCell>
|
|
||||||
{/* 검사결과 프로그레스바 */}
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
|
||||||
<div
|
|
||||||
className="h-full bg-emerald-500 transition-all"
|
|
||||||
style={{ width: `${goodPct}%` }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="h-full bg-red-500 transition-all"
|
|
||||||
style={{ width: `${defectPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
</TableCell>
|
||||||
{pct(goodPct)}
|
)}
|
||||||
</span>
|
{tc.judgment && (
|
||||||
</div>
|
<TableCell className="text-center">
|
||||||
</TableCell>
|
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
||||||
{/* 판정 배지 */}
|
{row.result}
|
||||||
<TableCell className="text-center">
|
</Badge>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant="outline"
|
)}
|
||||||
className={cn(
|
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
||||||
"text-xs",
|
{tc.inspectedAt && (
|
||||||
badgeVariant("result", row.result),
|
<TableCell className={cn("text-sm", theme.mutedText)}>
|
||||||
)}
|
{row.inspectedAt !== "-"
|
||||||
>
|
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
||||||
{row.result}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center text-sm">
|
|
||||||
{row.inspectorName}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-gray-500">
|
|
||||||
{row.inspectedAt !== "-"
|
|
||||||
? new Date(row.inspectedAt).toLocaleString(
|
|
||||||
"ko-KR",
|
|
||||||
{
|
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
},
|
})
|
||||||
)
|
: "-"}
|
||||||
: "-"}
|
</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm text-gray-400">
|
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
||||||
{row.remark || "-"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -12,16 +12,10 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||||
RefreshCw,
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||||
Clock,
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
Loader2,
|
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
|
||||||
Inbox,
|
|
||||||
Wrench,
|
|
||||||
Zap,
|
|
||||||
Pause,
|
|
||||||
Power,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
/* ───── 상태 정의 ───── */
|
/* ───── 상태 정의 ───── */
|
||||||
|
|
||||||
@@ -134,11 +128,16 @@ interface WorkInstruction {
|
|||||||
/* ───── 컴포넌트 ───── */
|
/* ───── 컴포넌트 ───── */
|
||||||
|
|
||||||
export default function EquipmentMonitoringPage() {
|
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 [equipments, setEquipments] = useState<Equipment[]>([]);
|
||||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
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 [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||||
const autoRefreshRef = useRef(autoRefresh);
|
const autoRefreshRef = useRef(autoRefresh);
|
||||||
|
|
||||||
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
/* ── 자동 갱신 (30초) ── */
|
/* ── 자동 갱신 ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (autoRefreshRef.current) fetchData();
|
if (autoRefreshRef.current) fetchData();
|
||||||
}, 30000);
|
}, settings.refreshInterval * 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchData]);
|
}, [fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
/* ── 요약 통계 ── */
|
/* ── 요약 통계 ── */
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
|
|||||||
|
|
||||||
/* ── 필터 pill ── */
|
/* ── 필터 pill ── */
|
||||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
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: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
|
||||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-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-300 hover:bg-amber-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-300 hover:bg-red-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-300 hover:bg-gray-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 (
|
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">
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-8 w-1.5 rounded-full bg-amber-400" />
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">{formatDate(currentTime)}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 자동갱신 토글 */}
|
{/* 자동갱신 토글 */}
|
||||||
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-gray-700 text-xs gap-1.5",
|
"gap-1.5 text-xs",
|
||||||
autoRefresh
|
autoRefresh
|
||||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
|
||||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={() => setAutoRefresh((v) => !v)}
|
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"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
className="gap-1.5"
|
||||||
onClick={fetchData}
|
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
<Settings2 className="h-4 w-4" />
|
||||||
새로고침
|
설정
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── 요약 카드 5개 ── */}
|
{/* ── 요약 카드 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) => (
|
{summaryCards.map((card) => (
|
||||||
<button
|
<button
|
||||||
key={card.label}
|
key={card.label}
|
||||||
onClick={() =>
|
onClick={() => setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))}
|
||||||
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
|
|
||||||
}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
||||||
card.bg,
|
card.bg,
|
||||||
card.border,
|
card.border,
|
||||||
"hover:shadow-lg"
|
"hover:shadow-lg",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
||||||
{card.icon}
|
{card.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<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>
|
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||||
filterStatus === pill.value
|
filterStatus === pill.value
|
||||||
? cn(pill.color, "ring-1 ring-white/20")
|
? cn(pill.color, "ring-1 ring-foreground/10")
|
||||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pill.label}
|
{pill.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<span className="ml-auto text-sm text-gray-600 self-center">
|
<span className="text-muted-foreground ml-auto self-center text-sm">{filteredEquipments.length}대 표시</span>
|
||||||
{filteredEquipments.length}대 표시
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 로딩 ── */}
|
{/* ── 로딩 ── */}
|
||||||
{loading && equipments.length === 0 && (
|
{loading && equipments.length === 0 && (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-gray-600" />
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
<span className="text-muted-foreground ml-3">설비 데이터를 불러오는 중...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 데이터 없음 ── */}
|
{/* ── 데이터 없음 ── */}
|
||||||
{!loading && equipments.length === 0 && (
|
{!loading && equipments.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-gray-600">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Inbox className="h-12 w-12 mb-3" />
|
<Inbox className="mb-3 h-12 w-12" />
|
||||||
<p className="text-lg">등록된 설비가 없습니다.</p>
|
<p className="text-lg">등록된 설비가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 설비 카드 그리드 ── */}
|
{/* ── 설비 카드 그리드 ── */}
|
||||||
{filteredEquipments.length > 0 && (
|
{filteredEquipments.length > 0 && (
|
||||||
<div
|
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}>
|
||||||
className="grid gap-4"
|
|
||||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
|
|
||||||
>
|
|
||||||
{filteredEquipments.map((eq) => {
|
{filteredEquipments.map((eq) => {
|
||||||
const status = resolveStatus(eq.operation_status);
|
const status = resolveStatus(eq.operation_status);
|
||||||
const cfg = STATUS_MAP[status];
|
const cfg = STATUS_MAP[status];
|
||||||
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
|
|||||||
<div
|
<div
|
||||||
key={eq.id}
|
key={eq.id}
|
||||||
className={cn(
|
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.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="flex items-start justify-between px-4 pt-3 pb-2 pl-5">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-base font-semibold text-white truncate">
|
{df.equipmentName && (
|
||||||
{eq.equipment_name || "이름 없음"}
|
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
|
||||||
</h3>
|
{eq.equipment_name || "이름 없음"}
|
||||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
</h3>
|
||||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
)}
|
||||||
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
{df.operationStatus && (
|
||||||
className={cn(
|
<Badge
|
||||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
className={cn("ml-2 shrink-0 gap-1 border-0 text-xs font-medium", cfg.badgeBg, cfg.badgeText)}
|
||||||
cfg.badgeBg,
|
>
|
||||||
cfg.badgeText
|
{cfg.icon}
|
||||||
)}
|
{cfg.label}
|
||||||
>
|
</Badge>
|
||||||
{cfg.icon}
|
|
||||||
{cfg.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 정보 그리드 */}
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
|
||||||
<p className="text-white font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">생산수량</span>
|
|
||||||
<p className="text-white font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">작업자</span>
|
|
||||||
<p className="text-white font-medium">
|
|
||||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
|
||||||
? eqWIs[0].worker_name
|
|
||||||
: "-"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">설비코드</span>
|
|
||||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 가동률 프로그레스 */}
|
|
||||||
<div className="px-4 pl-5 py-2.5">
|
|
||||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
|
||||||
<span className="text-gray-500">가동률</span>
|
|
||||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
|
||||||
{utilization !== null ? `${utilization}%` : "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
|
||||||
{utilization !== null && (
|
|
||||||
<div
|
|
||||||
className={cn("h-full rounded-full transition-all duration-700", cfg.bar)}
|
|
||||||
style={{ width: `${utilization}%` }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 현재 작업지시 */}
|
|
||||||
<div className="px-4 pl-5 py-2.5">
|
|
||||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</p>
|
|
||||||
{eqWIs.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{eqWIs.slice(0, 2).map((wi) => (
|
|
||||||
<div key={wi.id} className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-blue-400 font-mono text-xs shrink-0">
|
|
||||||
{wi.instruction_number || "-"}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-300 truncate">
|
|
||||||
{wi.item_name || "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{eqWIs.length > 2 && (
|
|
||||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 구분선 */}
|
{/* 구분선 */}
|
||||||
<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="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
{df.dailyOperationTime && (
|
||||||
<span className="text-gray-600">온도</span>
|
<div>
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
<span className={cn("text-xs", theme.mutedText)}>금일 가동시간</span>
|
||||||
</div>
|
<p className={cn("font-medium", theme.text)}>-</p>
|
||||||
<div className="flex items-center gap-1.5">
|
</div>
|
||||||
<span className="text-gray-600">압력</span>
|
)}
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
{df.dailyProductionQty && (
|
||||||
</div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5">
|
<span className={cn("text-xs", theme.mutedText)}>생산수량</span>
|
||||||
<span className="text-gray-600">RPM</span>
|
<p className={cn("font-medium", theme.text)}>-</p>
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
|
|||||||
|
|
||||||
{/* 필터 결과 없음 */}
|
{/* 필터 결과 없음 */}
|
||||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
|
||||||
<Inbox className="h-10 w-10 mb-2" />
|
<Inbox className="mb-2 h-10 w-10" />
|
||||||
<p>해당 상태의 설비가 없습니다.</p>
|
<p>해당 상태의 설비가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -16,6 +20,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||||
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 작업지시별 공정현황으로 진행상태 계산
|
// 작업지시별 공정현황으로 진행상태 계산
|
||||||
function computeProgress(
|
function computeProgress(wiId: string, processMap: Map<string, ProcessStep[]>): "대기" | "진행중" | "완료" {
|
||||||
wiId: string,
|
|
||||||
processMap: Map<string, ProcessStep[]>
|
|
||||||
): "대기" | "진행중" | "완료" {
|
|
||||||
const steps = processMap.get(wiId);
|
const steps = processMap.get(wiId);
|
||||||
if (!steps || steps.length === 0) return "대기";
|
if (!steps || steps.length === 0) return "대기";
|
||||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||||
@@ -85,11 +87,15 @@ function computeProgress(
|
|||||||
|
|
||||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||||
export default function ProductionMonitoringPage() {
|
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 [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||||
|
|
||||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||||
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
// 작업지시 목록 조회
|
// 작업지시 목록 조회
|
||||||
const wiRes = await apiClient.get("/work-instruction/list");
|
const wiRes = await apiClient.get("/work-instruction/list");
|
||||||
const wiRaw: WorkInstruction[] =
|
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
|
||||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const wiData = wiRaw.filter((wi) => {
|
const wiData = wiRaw.filter((wi) => {
|
||||||
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
|
|||||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||||
try {
|
try {
|
||||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
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 || [];
|
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||||
|
|
||||||
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
// ─── 자동갱신 ────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh) return;
|
if (!autoRefresh) return;
|
||||||
const timer = setInterval(fetchData, 30000);
|
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [autoRefresh, fetchData]);
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
// ─── 통계 계산 ───────────────────────────────────────────
|
// ─── 통계 계산 ───────────────────────────────────────────
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
// ─── 렌더링 ──────────────────────────────────────────────
|
// ─── 렌더링 ──────────────────────────────────────────────
|
||||||
return (
|
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">
|
<div className="flex flex-shrink-0 items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
|
||||||
variant="outline"
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||||
size="sm"
|
|
||||||
onClick={fetchData}
|
|
||||||
disabled={loading}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
|
|||||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
className="gap-1.5"
|
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"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 요약 카드 */}
|
{/* 요약 카드 */}
|
||||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Timer className="w-5 h-5" />}
|
icon={<Timer className="h-5 w-5" />}
|
||||||
label="대기중"
|
label="대기중"
|
||||||
value={stats.waiting}
|
value={stats.waiting}
|
||||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Loader2 className="w-5 h-5" />}
|
icon={<Loader2 className="h-5 w-5" />}
|
||||||
label="진행중"
|
label="진행중"
|
||||||
value={stats.inProgress}
|
value={stats.inProgress}
|
||||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
label="완료"
|
label="완료"
|
||||||
value={stats.completed}
|
value={stats.completed}
|
||||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<TrendingUp className="w-5 h-5" />}
|
icon={<TrendingUp className="h-5 w-5" />}
|
||||||
label="달성율"
|
label="달성율"
|
||||||
value={`${stats.achievementRate}%`}
|
value={`${stats.achievementRate}%`}
|
||||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||||
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
</div>
|
</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) => (
|
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||||
<Button
|
<Button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
|
|||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[64px]",
|
"min-w-[64px]",
|
||||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
|
||||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
|
||||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
{/* 로딩 상태 */}
|
{/* 로딩 상태 */}
|
||||||
{loading && workInstructions.length === 0 && (
|
{loading && workInstructions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
|
||||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 빈 상태 */}
|
{/* 빈 상태 */}
|
||||||
{!loading && filteredInstructions.length === 0 && (
|
{!loading && filteredInstructions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Inbox className="w-12 h-12 mb-3" />
|
<Inbox className="mb-3 h-12 w-12" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{activeTab === "전체"
|
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||||
? "등록된 작업지시가 없습니다."
|
|
||||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 작업 카드 그리드 */}
|
{/* 작업 카드 */}
|
||||||
{filteredInstructions.length > 0 && (
|
{filteredInstructions.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="grid gap-4 flex-1"
|
className={cn(
|
||||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
"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) => (
|
{filteredInstructions.map((wi, idx) => (
|
||||||
<WorkCard
|
<WorkCard
|
||||||
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
instruction={wi}
|
instruction={wi}
|
||||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||||
|
displayFields={settings.displayFields}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -338,11 +354,11 @@ function SummaryCard({
|
|||||||
colorClass: string;
|
colorClass: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
|
||||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
<span className="text-muted-foreground text-xs">{label}</span>
|
||||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
<span className="text-foreground text-2xl font-bold">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -353,10 +369,12 @@ function WorkCard({
|
|||||||
instruction: wi,
|
instruction: wi,
|
||||||
steps,
|
steps,
|
||||||
progress,
|
progress,
|
||||||
|
displayFields: df,
|
||||||
}: {
|
}: {
|
||||||
instruction: WorkInstruction;
|
instruction: WorkInstruction;
|
||||||
steps: ProcessStep[];
|
steps: ProcessStep[];
|
||||||
progress: "대기" | "진행중" | "완료";
|
progress: "대기" | "진행중" | "완료";
|
||||||
|
displayFields: ProductionDisplayFields;
|
||||||
}) {
|
}) {
|
||||||
// API 응답은 flat 구조 (details 배열 아님)
|
// API 응답은 flat 구조 (details 배열 아님)
|
||||||
const itemName = (wi as any).item_name || "-";
|
const itemName = (wi as any).item_name || "-";
|
||||||
@@ -373,36 +391,28 @@ function WorkCard({
|
|||||||
const currentStep = steps.find((s) => s.status !== "completed");
|
const currentStep = steps.find((s) => s.status !== "completed");
|
||||||
|
|
||||||
// 프로그레스바 색상
|
// 프로그레스바 색상
|
||||||
const barColor =
|
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
|
||||||
progressPercent >= 100
|
|
||||||
? "bg-emerald-500"
|
|
||||||
: progressPercent >= 50
|
|
||||||
? "bg-blue-500"
|
|
||||||
: "bg-amber-500";
|
|
||||||
|
|
||||||
// 상태 배지 스타일
|
// 상태 배지 스타일
|
||||||
const statusBadge: Record<string, string> = {
|
const statusBadge: Record<string, string> = {
|
||||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-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-blue-500/10 text-blue-500 border-blue-500/30",
|
||||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
완료: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
const isUrgent = wi.status === "긴급";
|
const isUrgent = wi.status === "긴급";
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-sm text-foreground">
|
{df.workInstructionNo && (
|
||||||
{wi.work_instruction_no}
|
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
|
||||||
</span>
|
)}
|
||||||
{isUrgent && (
|
{df.priority && isUrgent && (
|
||||||
<Badge
|
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
|
||||||
variant="outline"
|
<AlertTriangle className="h-3 w-3" />
|
||||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="w-3 h-3" />
|
|
||||||
긴급
|
긴급
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -413,82 +423,88 @@ function WorkCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카드 본문 - 정보 */}
|
{/* 카드 본문 - 정보 */}
|
||||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
|
||||||
<InfoRow label="품목명" value={itemName} />
|
{df.itemName && <InfoRow label="품목명" value={itemName} />}
|
||||||
<InfoRow label="규격" value={spec} />
|
{df.spec && <InfoRow label="규격" value={spec} />}
|
||||||
<InfoRow label="거래처" value={customerName} />
|
{df.customerName && <InfoRow label="거래처" value={customerName} />}
|
||||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
|
||||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
|
||||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
{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>
|
||||||
|
|
||||||
{/* 공정현황 */}
|
{/* 공정현황 */}
|
||||||
<div className="px-4 py-3 border-b">
|
{df.processProgress && (
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="border-b px-4 py-3">
|
||||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
{steps.length > 0 && (
|
<span className="text-muted-foreground text-xs font-medium">공정현황</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
{steps.length > 0 && (
|
||||||
완료 {completedSteps}/{steps.length}
|
<span className="text-muted-foreground text-xs">
|
||||||
{currentStep && (
|
완료 {completedSteps}/{steps.length}
|
||||||
<span>
|
{currentStep && (
|
||||||
{" "}
|
<span>
|
||||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
{" "}
|
||||||
</span>
|
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||||
)}
|
</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>
|
</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">
|
{df.progressBar && (
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="px-4 py-3">
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
{completedQty} / {totalQty}
|
<span className="text-muted-foreground text-xs">
|
||||||
</span>
|
{completedQty} / {totalQty}
|
||||||
<span
|
</span>
|
||||||
className={cn(
|
<span
|
||||||
"text-xs font-bold",
|
className={cn(
|
||||||
progressPercent >= 100
|
"text-xs font-bold",
|
||||||
? "text-emerald-500"
|
progressPercent >= 100
|
||||||
: progressPercent >= 50
|
? "text-emerald-500"
|
||||||
? "text-blue-500"
|
: progressPercent >= 50
|
||||||
: "text-amber-500"
|
? "text-blue-500"
|
||||||
)}
|
: "text-amber-500",
|
||||||
>
|
)}
|
||||||
{progressPercent}%
|
>
|
||||||
</span>
|
{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>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -496,7 +512,7 @@ function WorkCard({
|
|||||||
// ─── 정보 행 ───────────────────────────────────────────────
|
// ─── 정보 행 ───────────────────────────────────────────────
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
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-muted-foreground shrink-0">{label}:</span>
|
||||||
<span className="text-foreground truncate">{value}</span>
|
<span className="text-foreground truncate">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,23 +4,12 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"
|
|||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||||
RefreshCw,
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||||
Clock,
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
Loader2,
|
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
||||||
Inbox,
|
|
||||||
Search,
|
|
||||||
ClipboardCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
/* ───── 타입 ───── */
|
/* ───── 타입 ───── */
|
||||||
interface ProcessRow {
|
interface ProcessRow {
|
||||||
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
|
|||||||
|
|
||||||
/* ───── 유틸 ───── */
|
/* ───── 유틸 ───── */
|
||||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||||
const pct = (n: number) =>
|
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||||
`${n.toFixed(1)}%`;
|
|
||||||
|
|
||||||
const badgeVariant = (
|
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
|
||||||
type: "result" | "type" | "defectRate",
|
|
||||||
value: string | number,
|
|
||||||
) => {
|
|
||||||
if (type === "result") {
|
if (type === "result") {
|
||||||
if (value === "합격")
|
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
|
||||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||||
}
|
}
|
||||||
if (type === "type") {
|
if (type === "type") {
|
||||||
if (value === "공정검사")
|
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
|
||||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
|
||||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
}
|
}
|
||||||
@@ -93,10 +76,15 @@ const badgeVariant = (
|
|||||||
|
|
||||||
/* ───── 컴포넌트 ───── */
|
/* ───── 컴포넌트 ───── */
|
||||||
export default function QualityMonitoringPage() {
|
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 [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
|
|||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.post(
|
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||||
"/table-management/tables/work_order_process/data",
|
|
||||||
{ autoFilter: true },
|
|
||||||
);
|
|
||||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||||
setProcessData(rows);
|
setProcessData(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
|
|||||||
/* ───── 자동 갱신 ───── */
|
/* ───── 자동 갱신 ───── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRefresh) {
|
if (autoRefresh) {
|
||||||
intervalRef.current = setInterval(fetchData, 30_000);
|
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
};
|
};
|
||||||
}, [autoRefresh, fetchData]);
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
/* ───── 검사 행 변환 ───── */
|
/* ───── 검사 행 변환 ───── */
|
||||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||||
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
|
|||||||
const goodQty = r.good_qty ?? 0;
|
const goodQty = r.good_qty ?? 0;
|
||||||
const defectQty = r.defect_qty ?? 0;
|
const defectQty = r.defect_qty ?? 0;
|
||||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||||
const result: InspectionRow["result"] =
|
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
|
||||||
r.status !== "completed"
|
|
||||||
? "대기"
|
|
||||||
: defectQty > 0
|
|
||||||
? "불합격"
|
|
||||||
: "합격";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
no: idx + 1,
|
no: idx + 1,
|
||||||
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
|
|||||||
|
|
||||||
/* ───── 렌더링 ───── */
|
/* ───── 렌더링 ───── */
|
||||||
return (
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
||||||
품질점검현황{" "}
|
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
||||||
<span className="text-emerald-600">모니터링</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{currentTime.toLocaleString("ko-KR", {
|
{currentTime.toLocaleString("ko-KR", {
|
||||||
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||||
variant="outline"
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
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>
|
<span className="ml-1">새로고침</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={autoRefresh ? "default" : "outline"}
|
variant={autoRefresh ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setAutoRefresh((p) => !p)}
|
onClick={() => setAutoRefresh((p) => !p)}
|
||||||
className={cn(
|
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
|
||||||
autoRefresh &&
|
|
||||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Clock className="h-4 w-4 mr-1" />
|
<Clock className="mr-1 h-4 w-4" />
|
||||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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>
|
</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">
|
<div className="grid grid-cols-5 gap-4">
|
||||||
{summaryCards.map((card) => (
|
{summaryCards.map((card) => (
|
||||||
<div
|
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||||
key={card.label}
|
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||||
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)}>
|
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||||
{card.value}
|
{card.value}
|
||||||
{card.sub && (
|
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
|
||||||
<span className="ml-1 text-base font-normal text-white/70">
|
|
||||||
{card.sub}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
|
|||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={cn(
|
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
|
activeTab === tab.key
|
||||||
? "bg-emerald-600 text-white shadow"
|
? "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}
|
{tab.label}
|
||||||
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
|
|||||||
</div>
|
</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">
|
<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-lg font-medium">준비중</p>
|
||||||
<p className="text-sm mt-1">
|
<p className="mt-1 text-sm">
|
||||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
||||||
아직 지원되지 않습니다.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : loading && filteredRows.length === 0 ? (
|
) : loading && filteredRows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
<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>
|
<p>데이터를 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredRows.length === 0 ? (
|
) : filteredRows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
<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>
|
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<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="w-[50px] text-center">No</TableHead>
|
||||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
||||||
<TableHead className="min-w-[90px] text-center">
|
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
||||||
검사유형
|
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
||||||
</TableHead>
|
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
||||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
||||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
||||||
검사수량
|
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
||||||
</TableHead>
|
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
||||||
합격수량
|
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
||||||
</TableHead>
|
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
||||||
불합격수량
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[70px] text-right">
|
|
||||||
불량율
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[160px] text-center">
|
|
||||||
검사결과
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[70px] text-center">
|
|
||||||
판정
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[80px] text-center">
|
|
||||||
검사자
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
|
||||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredRows.map((row) => {
|
{filteredRows.map((row) => {
|
||||||
const goodPct =
|
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
||||||
row.inspectionQty > 0
|
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
||||||
? (row.goodQty / row.inspectionQty) * 100
|
|
||||||
: 0;
|
|
||||||
const defectPct =
|
|
||||||
row.inspectionQty > 0
|
|
||||||
? (row.defectQty / row.inspectionQty) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow key={row.no} className={theme.tableRowHover}>
|
||||||
key={row.no}
|
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
||||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
||||||
>
|
{tc.inspectionType && (
|
||||||
<TableCell className="text-center text-sm text-gray-500">
|
<TableCell className="text-center">
|
||||||
{row.no}
|
<Badge
|
||||||
</TableCell>
|
variant="outline"
|
||||||
<TableCell className="font-mono text-sm">
|
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
||||||
{row.inspectionNo}
|
>
|
||||||
</TableCell>
|
{row.inspectionType}
|
||||||
<TableCell className="text-center">
|
</Badge>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant="outline"
|
)}
|
||||||
className={cn(
|
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
||||||
"text-xs",
|
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
||||||
badgeVariant("type", row.inspectionType),
|
{tc.inspectionQty && (
|
||||||
)}
|
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
||||||
>
|
)}
|
||||||
{row.inspectionType}
|
{tc.passFailQty && (
|
||||||
</Badge>
|
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm font-medium">
|
{tc.passFailQty && (
|
||||||
{row.itemName}
|
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm text-gray-500">
|
{tc.defectRate && (
|
||||||
{row.spec}
|
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
||||||
</TableCell>
|
{pct(row.defectRate)}
|
||||||
<TableCell className="text-right text-sm">
|
</TableCell>
|
||||||
{fmt(row.inspectionQty)}
|
)}
|
||||||
</TableCell>
|
{tc.resultBar && (
|
||||||
<TableCell className="text-right text-sm text-emerald-600">
|
<TableCell>
|
||||||
{fmt(row.goodQty)}
|
<div className="flex items-center gap-2">
|
||||||
</TableCell>
|
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
||||||
<TableCell className="text-right text-sm text-red-600">
|
<div
|
||||||
{fmt(row.defectQty)}
|
className="h-full bg-emerald-500 transition-all"
|
||||||
</TableCell>
|
style={{ width: `${goodPct}%` }}
|
||||||
<TableCell
|
/>
|
||||||
className={cn(
|
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
||||||
"text-right text-sm",
|
</div>
|
||||||
badgeVariant("defectRate", row.defectRate),
|
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
||||||
)}
|
{pct(goodPct)}
|
||||||
>
|
</span>
|
||||||
{pct(row.defectRate)}
|
|
||||||
</TableCell>
|
|
||||||
{/* 검사결과 프로그레스바 */}
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
|
||||||
<div
|
|
||||||
className="h-full bg-emerald-500 transition-all"
|
|
||||||
style={{ width: `${goodPct}%` }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="h-full bg-red-500 transition-all"
|
|
||||||
style={{ width: `${defectPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
</TableCell>
|
||||||
{pct(goodPct)}
|
)}
|
||||||
</span>
|
{tc.judgment && (
|
||||||
</div>
|
<TableCell className="text-center">
|
||||||
</TableCell>
|
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
||||||
{/* 판정 배지 */}
|
{row.result}
|
||||||
<TableCell className="text-center">
|
</Badge>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant="outline"
|
)}
|
||||||
className={cn(
|
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
||||||
"text-xs",
|
{tc.inspectedAt && (
|
||||||
badgeVariant("result", row.result),
|
<TableCell className={cn("text-sm", theme.mutedText)}>
|
||||||
)}
|
{row.inspectedAt !== "-"
|
||||||
>
|
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
||||||
{row.result}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center text-sm">
|
|
||||||
{row.inspectorName}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-gray-500">
|
|
||||||
{row.inspectedAt !== "-"
|
|
||||||
? new Date(row.inspectedAt).toLocaleString(
|
|
||||||
"ko-KR",
|
|
||||||
{
|
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
},
|
})
|
||||||
)
|
: "-"}
|
||||||
: "-"}
|
</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm text-gray-400">
|
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
||||||
{row.remark || "-"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -12,16 +12,10 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||||
RefreshCw,
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||||
Clock,
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
Loader2,
|
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
|
||||||
Inbox,
|
|
||||||
Wrench,
|
|
||||||
Zap,
|
|
||||||
Pause,
|
|
||||||
Power,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
/* ───── 상태 정의 ───── */
|
/* ───── 상태 정의 ───── */
|
||||||
|
|
||||||
@@ -134,11 +128,16 @@ interface WorkInstruction {
|
|||||||
/* ───── 컴포넌트 ───── */
|
/* ───── 컴포넌트 ───── */
|
||||||
|
|
||||||
export default function EquipmentMonitoringPage() {
|
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 [equipments, setEquipments] = useState<Equipment[]>([]);
|
||||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
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 [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||||
const autoRefreshRef = useRef(autoRefresh);
|
const autoRefreshRef = useRef(autoRefresh);
|
||||||
|
|
||||||
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
/* ── 자동 갱신 (30초) ── */
|
/* ── 자동 갱신 ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (autoRefreshRef.current) fetchData();
|
if (autoRefreshRef.current) fetchData();
|
||||||
}, 30000);
|
}, settings.refreshInterval * 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchData]);
|
}, [fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
/* ── 요약 통계 ── */
|
/* ── 요약 통계 ── */
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
|
|||||||
|
|
||||||
/* ── 필터 pill ── */
|
/* ── 필터 pill ── */
|
||||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
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: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
|
||||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-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-300 hover:bg-amber-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-300 hover:bg-red-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-300 hover:bg-gray-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 (
|
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">
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-8 w-1.5 rounded-full bg-amber-400" />
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">{formatDate(currentTime)}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 자동갱신 토글 */}
|
{/* 자동갱신 토글 */}
|
||||||
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-gray-700 text-xs gap-1.5",
|
"gap-1.5 text-xs",
|
||||||
autoRefresh
|
autoRefresh
|
||||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
|
||||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={() => setAutoRefresh((v) => !v)}
|
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"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
className="gap-1.5"
|
||||||
onClick={fetchData}
|
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
<Settings2 className="h-4 w-4" />
|
||||||
새로고침
|
설정
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── 요약 카드 5개 ── */}
|
{/* ── 요약 카드 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) => (
|
{summaryCards.map((card) => (
|
||||||
<button
|
<button
|
||||||
key={card.label}
|
key={card.label}
|
||||||
onClick={() =>
|
onClick={() => setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))}
|
||||||
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
|
|
||||||
}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
||||||
card.bg,
|
card.bg,
|
||||||
card.border,
|
card.border,
|
||||||
"hover:shadow-lg"
|
"hover:shadow-lg",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
||||||
{card.icon}
|
{card.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<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>
|
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||||
filterStatus === pill.value
|
filterStatus === pill.value
|
||||||
? cn(pill.color, "ring-1 ring-white/20")
|
? cn(pill.color, "ring-1 ring-foreground/10")
|
||||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pill.label}
|
{pill.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<span className="ml-auto text-sm text-gray-600 self-center">
|
<span className="text-muted-foreground ml-auto self-center text-sm">{filteredEquipments.length}대 표시</span>
|
||||||
{filteredEquipments.length}대 표시
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 로딩 ── */}
|
{/* ── 로딩 ── */}
|
||||||
{loading && equipments.length === 0 && (
|
{loading && equipments.length === 0 && (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-gray-600" />
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
<span className="text-muted-foreground ml-3">설비 데이터를 불러오는 중...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 데이터 없음 ── */}
|
{/* ── 데이터 없음 ── */}
|
||||||
{!loading && equipments.length === 0 && (
|
{!loading && equipments.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-gray-600">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Inbox className="h-12 w-12 mb-3" />
|
<Inbox className="mb-3 h-12 w-12" />
|
||||||
<p className="text-lg">등록된 설비가 없습니다.</p>
|
<p className="text-lg">등록된 설비가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 설비 카드 그리드 ── */}
|
{/* ── 설비 카드 그리드 ── */}
|
||||||
{filteredEquipments.length > 0 && (
|
{filteredEquipments.length > 0 && (
|
||||||
<div
|
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}>
|
||||||
className="grid gap-4"
|
|
||||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
|
|
||||||
>
|
|
||||||
{filteredEquipments.map((eq) => {
|
{filteredEquipments.map((eq) => {
|
||||||
const status = resolveStatus(eq.operation_status);
|
const status = resolveStatus(eq.operation_status);
|
||||||
const cfg = STATUS_MAP[status];
|
const cfg = STATUS_MAP[status];
|
||||||
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
|
|||||||
<div
|
<div
|
||||||
key={eq.id}
|
key={eq.id}
|
||||||
className={cn(
|
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.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="flex items-start justify-between px-4 pt-3 pb-2 pl-5">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-base font-semibold text-white truncate">
|
{df.equipmentName && (
|
||||||
{eq.equipment_name || "이름 없음"}
|
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
|
||||||
</h3>
|
{eq.equipment_name || "이름 없음"}
|
||||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
</h3>
|
||||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
)}
|
||||||
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
{df.operationStatus && (
|
||||||
className={cn(
|
<Badge
|
||||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
className={cn("ml-2 shrink-0 gap-1 border-0 text-xs font-medium", cfg.badgeBg, cfg.badgeText)}
|
||||||
cfg.badgeBg,
|
>
|
||||||
cfg.badgeText
|
{cfg.icon}
|
||||||
)}
|
{cfg.label}
|
||||||
>
|
</Badge>
|
||||||
{cfg.icon}
|
|
||||||
{cfg.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 정보 그리드 */}
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
|
||||||
<p className="text-white font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">생산수량</span>
|
|
||||||
<p className="text-white font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">작업자</span>
|
|
||||||
<p className="text-white font-medium">
|
|
||||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
|
||||||
? eqWIs[0].worker_name
|
|
||||||
: "-"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">설비코드</span>
|
|
||||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 가동률 프로그레스 */}
|
|
||||||
<div className="px-4 pl-5 py-2.5">
|
|
||||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
|
||||||
<span className="text-gray-500">가동률</span>
|
|
||||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
|
||||||
{utilization !== null ? `${utilization}%` : "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
|
||||||
{utilization !== null && (
|
|
||||||
<div
|
|
||||||
className={cn("h-full rounded-full transition-all duration-700", cfg.bar)}
|
|
||||||
style={{ width: `${utilization}%` }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 현재 작업지시 */}
|
|
||||||
<div className="px-4 pl-5 py-2.5">
|
|
||||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</p>
|
|
||||||
{eqWIs.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{eqWIs.slice(0, 2).map((wi) => (
|
|
||||||
<div key={wi.id} className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-blue-400 font-mono text-xs shrink-0">
|
|
||||||
{wi.instruction_number || "-"}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-300 truncate">
|
|
||||||
{wi.item_name || "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{eqWIs.length > 2 && (
|
|
||||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 구분선 */}
|
{/* 구분선 */}
|
||||||
<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="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
{df.dailyOperationTime && (
|
||||||
<span className="text-gray-600">온도</span>
|
<div>
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
<span className={cn("text-xs", theme.mutedText)}>금일 가동시간</span>
|
||||||
</div>
|
<p className={cn("font-medium", theme.text)}>-</p>
|
||||||
<div className="flex items-center gap-1.5">
|
</div>
|
||||||
<span className="text-gray-600">압력</span>
|
)}
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
{df.dailyProductionQty && (
|
||||||
</div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5">
|
<span className={cn("text-xs", theme.mutedText)}>생산수량</span>
|
||||||
<span className="text-gray-600">RPM</span>
|
<p className={cn("font-medium", theme.text)}>-</p>
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
|
|||||||
|
|
||||||
{/* 필터 결과 없음 */}
|
{/* 필터 결과 없음 */}
|
||||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
|
||||||
<Inbox className="h-10 w-10 mb-2" />
|
<Inbox className="mb-2 h-10 w-10" />
|
||||||
<p>해당 상태의 설비가 없습니다.</p>
|
<p>해당 상태의 설비가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -16,6 +20,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||||
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 작업지시별 공정현황으로 진행상태 계산
|
// 작업지시별 공정현황으로 진행상태 계산
|
||||||
function computeProgress(
|
function computeProgress(wiId: string, processMap: Map<string, ProcessStep[]>): "대기" | "진행중" | "완료" {
|
||||||
wiId: string,
|
|
||||||
processMap: Map<string, ProcessStep[]>
|
|
||||||
): "대기" | "진행중" | "완료" {
|
|
||||||
const steps = processMap.get(wiId);
|
const steps = processMap.get(wiId);
|
||||||
if (!steps || steps.length === 0) return "대기";
|
if (!steps || steps.length === 0) return "대기";
|
||||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||||
@@ -85,11 +87,15 @@ function computeProgress(
|
|||||||
|
|
||||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||||
export default function ProductionMonitoringPage() {
|
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 [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||||
|
|
||||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||||
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
// 작업지시 목록 조회
|
// 작업지시 목록 조회
|
||||||
const wiRes = await apiClient.get("/work-instruction/list");
|
const wiRes = await apiClient.get("/work-instruction/list");
|
||||||
const wiRaw: WorkInstruction[] =
|
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
|
||||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const wiData = wiRaw.filter((wi) => {
|
const wiData = wiRaw.filter((wi) => {
|
||||||
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
|
|||||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||||
try {
|
try {
|
||||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
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 || [];
|
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||||
|
|
||||||
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
// ─── 자동갱신 ────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh) return;
|
if (!autoRefresh) return;
|
||||||
const timer = setInterval(fetchData, 30000);
|
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [autoRefresh, fetchData]);
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
// ─── 통계 계산 ───────────────────────────────────────────
|
// ─── 통계 계산 ───────────────────────────────────────────
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
// ─── 렌더링 ──────────────────────────────────────────────
|
// ─── 렌더링 ──────────────────────────────────────────────
|
||||||
return (
|
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">
|
<div className="flex flex-shrink-0 items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
|
||||||
variant="outline"
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||||
size="sm"
|
|
||||||
onClick={fetchData}
|
|
||||||
disabled={loading}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
|
|||||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
className="gap-1.5"
|
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"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 요약 카드 */}
|
{/* 요약 카드 */}
|
||||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Timer className="w-5 h-5" />}
|
icon={<Timer className="h-5 w-5" />}
|
||||||
label="대기중"
|
label="대기중"
|
||||||
value={stats.waiting}
|
value={stats.waiting}
|
||||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Loader2 className="w-5 h-5" />}
|
icon={<Loader2 className="h-5 w-5" />}
|
||||||
label="진행중"
|
label="진행중"
|
||||||
value={stats.inProgress}
|
value={stats.inProgress}
|
||||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
label="완료"
|
label="완료"
|
||||||
value={stats.completed}
|
value={stats.completed}
|
||||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<TrendingUp className="w-5 h-5" />}
|
icon={<TrendingUp className="h-5 w-5" />}
|
||||||
label="달성율"
|
label="달성율"
|
||||||
value={`${stats.achievementRate}%`}
|
value={`${stats.achievementRate}%`}
|
||||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||||
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
</div>
|
</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) => (
|
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||||
<Button
|
<Button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
|
|||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[64px]",
|
"min-w-[64px]",
|
||||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
|
||||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
|
||||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
{/* 로딩 상태 */}
|
{/* 로딩 상태 */}
|
||||||
{loading && workInstructions.length === 0 && (
|
{loading && workInstructions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
|
||||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 빈 상태 */}
|
{/* 빈 상태 */}
|
||||||
{!loading && filteredInstructions.length === 0 && (
|
{!loading && filteredInstructions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Inbox className="w-12 h-12 mb-3" />
|
<Inbox className="mb-3 h-12 w-12" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{activeTab === "전체"
|
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||||
? "등록된 작업지시가 없습니다."
|
|
||||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 작업 카드 그리드 */}
|
{/* 작업 카드 */}
|
||||||
{filteredInstructions.length > 0 && (
|
{filteredInstructions.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="grid gap-4 flex-1"
|
className={cn(
|
||||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
"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) => (
|
{filteredInstructions.map((wi, idx) => (
|
||||||
<WorkCard
|
<WorkCard
|
||||||
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
instruction={wi}
|
instruction={wi}
|
||||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||||
|
displayFields={settings.displayFields}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -338,11 +354,11 @@ function SummaryCard({
|
|||||||
colorClass: string;
|
colorClass: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
|
||||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
<span className="text-muted-foreground text-xs">{label}</span>
|
||||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
<span className="text-foreground text-2xl font-bold">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -353,10 +369,12 @@ function WorkCard({
|
|||||||
instruction: wi,
|
instruction: wi,
|
||||||
steps,
|
steps,
|
||||||
progress,
|
progress,
|
||||||
|
displayFields: df,
|
||||||
}: {
|
}: {
|
||||||
instruction: WorkInstruction;
|
instruction: WorkInstruction;
|
||||||
steps: ProcessStep[];
|
steps: ProcessStep[];
|
||||||
progress: "대기" | "진행중" | "완료";
|
progress: "대기" | "진행중" | "완료";
|
||||||
|
displayFields: ProductionDisplayFields;
|
||||||
}) {
|
}) {
|
||||||
// API 응답은 flat 구조 (details 배열 아님)
|
// API 응답은 flat 구조 (details 배열 아님)
|
||||||
const itemName = (wi as any).item_name || "-";
|
const itemName = (wi as any).item_name || "-";
|
||||||
@@ -373,36 +391,28 @@ function WorkCard({
|
|||||||
const currentStep = steps.find((s) => s.status !== "completed");
|
const currentStep = steps.find((s) => s.status !== "completed");
|
||||||
|
|
||||||
// 프로그레스바 색상
|
// 프로그레스바 색상
|
||||||
const barColor =
|
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
|
||||||
progressPercent >= 100
|
|
||||||
? "bg-emerald-500"
|
|
||||||
: progressPercent >= 50
|
|
||||||
? "bg-blue-500"
|
|
||||||
: "bg-amber-500";
|
|
||||||
|
|
||||||
// 상태 배지 스타일
|
// 상태 배지 스타일
|
||||||
const statusBadge: Record<string, string> = {
|
const statusBadge: Record<string, string> = {
|
||||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-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-blue-500/10 text-blue-500 border-blue-500/30",
|
||||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
완료: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
const isUrgent = wi.status === "긴급";
|
const isUrgent = wi.status === "긴급";
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-sm text-foreground">
|
{df.workInstructionNo && (
|
||||||
{wi.work_instruction_no}
|
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
|
||||||
</span>
|
)}
|
||||||
{isUrgent && (
|
{df.priority && isUrgent && (
|
||||||
<Badge
|
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
|
||||||
variant="outline"
|
<AlertTriangle className="h-3 w-3" />
|
||||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="w-3 h-3" />
|
|
||||||
긴급
|
긴급
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -413,82 +423,88 @@ function WorkCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카드 본문 - 정보 */}
|
{/* 카드 본문 - 정보 */}
|
||||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
|
||||||
<InfoRow label="품목명" value={itemName} />
|
{df.itemName && <InfoRow label="품목명" value={itemName} />}
|
||||||
<InfoRow label="규격" value={spec} />
|
{df.spec && <InfoRow label="규격" value={spec} />}
|
||||||
<InfoRow label="거래처" value={customerName} />
|
{df.customerName && <InfoRow label="거래처" value={customerName} />}
|
||||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
|
||||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
|
||||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
{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>
|
||||||
|
|
||||||
{/* 공정현황 */}
|
{/* 공정현황 */}
|
||||||
<div className="px-4 py-3 border-b">
|
{df.processProgress && (
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="border-b px-4 py-3">
|
||||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
{steps.length > 0 && (
|
<span className="text-muted-foreground text-xs font-medium">공정현황</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
{steps.length > 0 && (
|
||||||
완료 {completedSteps}/{steps.length}
|
<span className="text-muted-foreground text-xs">
|
||||||
{currentStep && (
|
완료 {completedSteps}/{steps.length}
|
||||||
<span>
|
{currentStep && (
|
||||||
{" "}
|
<span>
|
||||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
{" "}
|
||||||
</span>
|
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||||
)}
|
</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>
|
</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">
|
{df.progressBar && (
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="px-4 py-3">
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
{completedQty} / {totalQty}
|
<span className="text-muted-foreground text-xs">
|
||||||
</span>
|
{completedQty} / {totalQty}
|
||||||
<span
|
</span>
|
||||||
className={cn(
|
<span
|
||||||
"text-xs font-bold",
|
className={cn(
|
||||||
progressPercent >= 100
|
"text-xs font-bold",
|
||||||
? "text-emerald-500"
|
progressPercent >= 100
|
||||||
: progressPercent >= 50
|
? "text-emerald-500"
|
||||||
? "text-blue-500"
|
: progressPercent >= 50
|
||||||
: "text-amber-500"
|
? "text-blue-500"
|
||||||
)}
|
: "text-amber-500",
|
||||||
>
|
)}
|
||||||
{progressPercent}%
|
>
|
||||||
</span>
|
{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>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -496,7 +512,7 @@ function WorkCard({
|
|||||||
// ─── 정보 행 ───────────────────────────────────────────────
|
// ─── 정보 행 ───────────────────────────────────────────────
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
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-muted-foreground shrink-0">{label}:</span>
|
||||||
<span className="text-foreground truncate">{value}</span>
|
<span className="text-foreground truncate">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,23 +4,12 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"
|
|||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||||
RefreshCw,
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||||
Clock,
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
Loader2,
|
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
||||||
Inbox,
|
|
||||||
Search,
|
|
||||||
ClipboardCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
/* ───── 타입 ───── */
|
/* ───── 타입 ───── */
|
||||||
interface ProcessRow {
|
interface ProcessRow {
|
||||||
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
|
|||||||
|
|
||||||
/* ───── 유틸 ───── */
|
/* ───── 유틸 ───── */
|
||||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||||
const pct = (n: number) =>
|
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||||
`${n.toFixed(1)}%`;
|
|
||||||
|
|
||||||
const badgeVariant = (
|
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
|
||||||
type: "result" | "type" | "defectRate",
|
|
||||||
value: string | number,
|
|
||||||
) => {
|
|
||||||
if (type === "result") {
|
if (type === "result") {
|
||||||
if (value === "합격")
|
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
|
||||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||||
}
|
}
|
||||||
if (type === "type") {
|
if (type === "type") {
|
||||||
if (value === "공정검사")
|
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
|
||||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
|
||||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
}
|
}
|
||||||
@@ -93,10 +76,15 @@ const badgeVariant = (
|
|||||||
|
|
||||||
/* ───── 컴포넌트 ───── */
|
/* ───── 컴포넌트 ───── */
|
||||||
export default function QualityMonitoringPage() {
|
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 [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
|
|||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.post(
|
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||||
"/table-management/tables/work_order_process/data",
|
|
||||||
{ autoFilter: true },
|
|
||||||
);
|
|
||||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||||
setProcessData(rows);
|
setProcessData(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
|
|||||||
/* ───── 자동 갱신 ───── */
|
/* ───── 자동 갱신 ───── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRefresh) {
|
if (autoRefresh) {
|
||||||
intervalRef.current = setInterval(fetchData, 30_000);
|
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
};
|
};
|
||||||
}, [autoRefresh, fetchData]);
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
/* ───── 검사 행 변환 ───── */
|
/* ───── 검사 행 변환 ───── */
|
||||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||||
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
|
|||||||
const goodQty = r.good_qty ?? 0;
|
const goodQty = r.good_qty ?? 0;
|
||||||
const defectQty = r.defect_qty ?? 0;
|
const defectQty = r.defect_qty ?? 0;
|
||||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||||
const result: InspectionRow["result"] =
|
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
|
||||||
r.status !== "completed"
|
|
||||||
? "대기"
|
|
||||||
: defectQty > 0
|
|
||||||
? "불합격"
|
|
||||||
: "합격";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
no: idx + 1,
|
no: idx + 1,
|
||||||
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
|
|||||||
|
|
||||||
/* ───── 렌더링 ───── */
|
/* ───── 렌더링 ───── */
|
||||||
return (
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
||||||
품질점검현황{" "}
|
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
||||||
<span className="text-emerald-600">모니터링</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{currentTime.toLocaleString("ko-KR", {
|
{currentTime.toLocaleString("ko-KR", {
|
||||||
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||||
variant="outline"
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
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>
|
<span className="ml-1">새로고침</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={autoRefresh ? "default" : "outline"}
|
variant={autoRefresh ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setAutoRefresh((p) => !p)}
|
onClick={() => setAutoRefresh((p) => !p)}
|
||||||
className={cn(
|
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
|
||||||
autoRefresh &&
|
|
||||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Clock className="h-4 w-4 mr-1" />
|
<Clock className="mr-1 h-4 w-4" />
|
||||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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>
|
</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">
|
<div className="grid grid-cols-5 gap-4">
|
||||||
{summaryCards.map((card) => (
|
{summaryCards.map((card) => (
|
||||||
<div
|
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||||
key={card.label}
|
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||||
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)}>
|
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||||
{card.value}
|
{card.value}
|
||||||
{card.sub && (
|
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
|
||||||
<span className="ml-1 text-base font-normal text-white/70">
|
|
||||||
{card.sub}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
|
|||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={cn(
|
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
|
activeTab === tab.key
|
||||||
? "bg-emerald-600 text-white shadow"
|
? "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}
|
{tab.label}
|
||||||
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
|
|||||||
</div>
|
</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">
|
<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-lg font-medium">준비중</p>
|
||||||
<p className="text-sm mt-1">
|
<p className="mt-1 text-sm">
|
||||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
||||||
아직 지원되지 않습니다.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : loading && filteredRows.length === 0 ? (
|
) : loading && filteredRows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
<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>
|
<p>데이터를 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredRows.length === 0 ? (
|
) : filteredRows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
<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>
|
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<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="w-[50px] text-center">No</TableHead>
|
||||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
||||||
<TableHead className="min-w-[90px] text-center">
|
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
||||||
검사유형
|
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
||||||
</TableHead>
|
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
||||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
||||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
||||||
검사수량
|
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
||||||
</TableHead>
|
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
||||||
합격수량
|
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
||||||
</TableHead>
|
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
||||||
불합격수량
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[70px] text-right">
|
|
||||||
불량율
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[160px] text-center">
|
|
||||||
검사결과
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[70px] text-center">
|
|
||||||
판정
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[80px] text-center">
|
|
||||||
검사자
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
|
||||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredRows.map((row) => {
|
{filteredRows.map((row) => {
|
||||||
const goodPct =
|
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
||||||
row.inspectionQty > 0
|
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
||||||
? (row.goodQty / row.inspectionQty) * 100
|
|
||||||
: 0;
|
|
||||||
const defectPct =
|
|
||||||
row.inspectionQty > 0
|
|
||||||
? (row.defectQty / row.inspectionQty) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow key={row.no} className={theme.tableRowHover}>
|
||||||
key={row.no}
|
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
||||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
||||||
>
|
{tc.inspectionType && (
|
||||||
<TableCell className="text-center text-sm text-gray-500">
|
<TableCell className="text-center">
|
||||||
{row.no}
|
<Badge
|
||||||
</TableCell>
|
variant="outline"
|
||||||
<TableCell className="font-mono text-sm">
|
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
||||||
{row.inspectionNo}
|
>
|
||||||
</TableCell>
|
{row.inspectionType}
|
||||||
<TableCell className="text-center">
|
</Badge>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant="outline"
|
)}
|
||||||
className={cn(
|
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
||||||
"text-xs",
|
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
||||||
badgeVariant("type", row.inspectionType),
|
{tc.inspectionQty && (
|
||||||
)}
|
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
||||||
>
|
)}
|
||||||
{row.inspectionType}
|
{tc.passFailQty && (
|
||||||
</Badge>
|
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm font-medium">
|
{tc.passFailQty && (
|
||||||
{row.itemName}
|
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm text-gray-500">
|
{tc.defectRate && (
|
||||||
{row.spec}
|
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
||||||
</TableCell>
|
{pct(row.defectRate)}
|
||||||
<TableCell className="text-right text-sm">
|
</TableCell>
|
||||||
{fmt(row.inspectionQty)}
|
)}
|
||||||
</TableCell>
|
{tc.resultBar && (
|
||||||
<TableCell className="text-right text-sm text-emerald-600">
|
<TableCell>
|
||||||
{fmt(row.goodQty)}
|
<div className="flex items-center gap-2">
|
||||||
</TableCell>
|
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
||||||
<TableCell className="text-right text-sm text-red-600">
|
<div
|
||||||
{fmt(row.defectQty)}
|
className="h-full bg-emerald-500 transition-all"
|
||||||
</TableCell>
|
style={{ width: `${goodPct}%` }}
|
||||||
<TableCell
|
/>
|
||||||
className={cn(
|
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
||||||
"text-right text-sm",
|
</div>
|
||||||
badgeVariant("defectRate", row.defectRate),
|
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
||||||
)}
|
{pct(goodPct)}
|
||||||
>
|
</span>
|
||||||
{pct(row.defectRate)}
|
|
||||||
</TableCell>
|
|
||||||
{/* 검사결과 프로그레스바 */}
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
|
||||||
<div
|
|
||||||
className="h-full bg-emerald-500 transition-all"
|
|
||||||
style={{ width: `${goodPct}%` }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="h-full bg-red-500 transition-all"
|
|
||||||
style={{ width: `${defectPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
</TableCell>
|
||||||
{pct(goodPct)}
|
)}
|
||||||
</span>
|
{tc.judgment && (
|
||||||
</div>
|
<TableCell className="text-center">
|
||||||
</TableCell>
|
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
||||||
{/* 판정 배지 */}
|
{row.result}
|
||||||
<TableCell className="text-center">
|
</Badge>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant="outline"
|
)}
|
||||||
className={cn(
|
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
||||||
"text-xs",
|
{tc.inspectedAt && (
|
||||||
badgeVariant("result", row.result),
|
<TableCell className={cn("text-sm", theme.mutedText)}>
|
||||||
)}
|
{row.inspectedAt !== "-"
|
||||||
>
|
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
||||||
{row.result}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center text-sm">
|
|
||||||
{row.inspectorName}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-gray-500">
|
|
||||||
{row.inspectedAt !== "-"
|
|
||||||
? new Date(row.inspectedAt).toLocaleString(
|
|
||||||
"ko-KR",
|
|
||||||
{
|
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
},
|
})
|
||||||
)
|
: "-"}
|
||||||
: "-"}
|
</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm text-gray-400">
|
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
||||||
{row.remark || "-"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -12,16 +12,10 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||||
RefreshCw,
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||||
Clock,
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
Loader2,
|
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
|
||||||
Inbox,
|
|
||||||
Wrench,
|
|
||||||
Zap,
|
|
||||||
Pause,
|
|
||||||
Power,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
/* ───── 상태 정의 ───── */
|
/* ───── 상태 정의 ───── */
|
||||||
|
|
||||||
@@ -134,11 +128,16 @@ interface WorkInstruction {
|
|||||||
/* ───── 컴포넌트 ───── */
|
/* ───── 컴포넌트 ───── */
|
||||||
|
|
||||||
export default function EquipmentMonitoringPage() {
|
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 [equipments, setEquipments] = useState<Equipment[]>([]);
|
||||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
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 [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||||
const autoRefreshRef = useRef(autoRefresh);
|
const autoRefreshRef = useRef(autoRefresh);
|
||||||
|
|
||||||
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
/* ── 자동 갱신 (30초) ── */
|
/* ── 자동 갱신 ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (autoRefreshRef.current) fetchData();
|
if (autoRefreshRef.current) fetchData();
|
||||||
}, 30000);
|
}, settings.refreshInterval * 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchData]);
|
}, [fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
/* ── 요약 통계 ── */
|
/* ── 요약 통계 ── */
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
|
|||||||
|
|
||||||
/* ── 필터 pill ── */
|
/* ── 필터 pill ── */
|
||||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
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: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
|
||||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-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-300 hover:bg-amber-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-300 hover:bg-red-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-300 hover:bg-gray-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 (
|
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">
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-8 w-1.5 rounded-full bg-amber-400" />
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">{formatDate(currentTime)}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 자동갱신 토글 */}
|
{/* 자동갱신 토글 */}
|
||||||
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-gray-700 text-xs gap-1.5",
|
"gap-1.5 text-xs",
|
||||||
autoRefresh
|
autoRefresh
|
||||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
|
||||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={() => setAutoRefresh((v) => !v)}
|
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"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
className="gap-1.5"
|
||||||
onClick={fetchData}
|
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
<Settings2 className="h-4 w-4" />
|
||||||
새로고침
|
설정
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── 요약 카드 5개 ── */}
|
{/* ── 요약 카드 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) => (
|
{summaryCards.map((card) => (
|
||||||
<button
|
<button
|
||||||
key={card.label}
|
key={card.label}
|
||||||
onClick={() =>
|
onClick={() => setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))}
|
||||||
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
|
|
||||||
}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
||||||
card.bg,
|
card.bg,
|
||||||
card.border,
|
card.border,
|
||||||
"hover:shadow-lg"
|
"hover:shadow-lg",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
||||||
{card.icon}
|
{card.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<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>
|
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||||
filterStatus === pill.value
|
filterStatus === pill.value
|
||||||
? cn(pill.color, "ring-1 ring-white/20")
|
? cn(pill.color, "ring-1 ring-foreground/10")
|
||||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pill.label}
|
{pill.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<span className="ml-auto text-sm text-gray-600 self-center">
|
<span className="text-muted-foreground ml-auto self-center text-sm">{filteredEquipments.length}대 표시</span>
|
||||||
{filteredEquipments.length}대 표시
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 로딩 ── */}
|
{/* ── 로딩 ── */}
|
||||||
{loading && equipments.length === 0 && (
|
{loading && equipments.length === 0 && (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-gray-600" />
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
<span className="text-muted-foreground ml-3">설비 데이터를 불러오는 중...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 데이터 없음 ── */}
|
{/* ── 데이터 없음 ── */}
|
||||||
{!loading && equipments.length === 0 && (
|
{!loading && equipments.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-gray-600">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Inbox className="h-12 w-12 mb-3" />
|
<Inbox className="mb-3 h-12 w-12" />
|
||||||
<p className="text-lg">등록된 설비가 없습니다.</p>
|
<p className="text-lg">등록된 설비가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 설비 카드 그리드 ── */}
|
{/* ── 설비 카드 그리드 ── */}
|
||||||
{filteredEquipments.length > 0 && (
|
{filteredEquipments.length > 0 && (
|
||||||
<div
|
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}>
|
||||||
className="grid gap-4"
|
|
||||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
|
|
||||||
>
|
|
||||||
{filteredEquipments.map((eq) => {
|
{filteredEquipments.map((eq) => {
|
||||||
const status = resolveStatus(eq.operation_status);
|
const status = resolveStatus(eq.operation_status);
|
||||||
const cfg = STATUS_MAP[status];
|
const cfg = STATUS_MAP[status];
|
||||||
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
|
|||||||
<div
|
<div
|
||||||
key={eq.id}
|
key={eq.id}
|
||||||
className={cn(
|
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.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="flex items-start justify-between px-4 pt-3 pb-2 pl-5">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-base font-semibold text-white truncate">
|
{df.equipmentName && (
|
||||||
{eq.equipment_name || "이름 없음"}
|
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
|
||||||
</h3>
|
{eq.equipment_name || "이름 없음"}
|
||||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
</h3>
|
||||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
)}
|
||||||
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
{df.operationStatus && (
|
||||||
className={cn(
|
<Badge
|
||||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
className={cn("ml-2 shrink-0 gap-1 border-0 text-xs font-medium", cfg.badgeBg, cfg.badgeText)}
|
||||||
cfg.badgeBg,
|
>
|
||||||
cfg.badgeText
|
{cfg.icon}
|
||||||
)}
|
{cfg.label}
|
||||||
>
|
</Badge>
|
||||||
{cfg.icon}
|
|
||||||
{cfg.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 정보 그리드 */}
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
|
||||||
<p className="text-white font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">생산수량</span>
|
|
||||||
<p className="text-white font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">작업자</span>
|
|
||||||
<p className="text-white font-medium">
|
|
||||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
|
||||||
? eqWIs[0].worker_name
|
|
||||||
: "-"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">설비코드</span>
|
|
||||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 가동률 프로그레스 */}
|
|
||||||
<div className="px-4 pl-5 py-2.5">
|
|
||||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
|
||||||
<span className="text-gray-500">가동률</span>
|
|
||||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
|
||||||
{utilization !== null ? `${utilization}%` : "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
|
||||||
{utilization !== null && (
|
|
||||||
<div
|
|
||||||
className={cn("h-full rounded-full transition-all duration-700", cfg.bar)}
|
|
||||||
style={{ width: `${utilization}%` }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 현재 작업지시 */}
|
|
||||||
<div className="px-4 pl-5 py-2.5">
|
|
||||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</p>
|
|
||||||
{eqWIs.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{eqWIs.slice(0, 2).map((wi) => (
|
|
||||||
<div key={wi.id} className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-blue-400 font-mono text-xs shrink-0">
|
|
||||||
{wi.instruction_number || "-"}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-300 truncate">
|
|
||||||
{wi.item_name || "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{eqWIs.length > 2 && (
|
|
||||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 구분선 */}
|
{/* 구분선 */}
|
||||||
<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="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
{df.dailyOperationTime && (
|
||||||
<span className="text-gray-600">온도</span>
|
<div>
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
<span className={cn("text-xs", theme.mutedText)}>금일 가동시간</span>
|
||||||
</div>
|
<p className={cn("font-medium", theme.text)}>-</p>
|
||||||
<div className="flex items-center gap-1.5">
|
</div>
|
||||||
<span className="text-gray-600">압력</span>
|
)}
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
{df.dailyProductionQty && (
|
||||||
</div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5">
|
<span className={cn("text-xs", theme.mutedText)}>생산수량</span>
|
||||||
<span className="text-gray-600">RPM</span>
|
<p className={cn("font-medium", theme.text)}>-</p>
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
|
|||||||
|
|
||||||
{/* 필터 결과 없음 */}
|
{/* 필터 결과 없음 */}
|
||||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
|
||||||
<Inbox className="h-10 w-10 mb-2" />
|
<Inbox className="mb-2 h-10 w-10" />
|
||||||
<p>해당 상태의 설비가 없습니다.</p>
|
<p>해당 상태의 설비가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -16,6 +20,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||||
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 작업지시별 공정현황으로 진행상태 계산
|
// 작업지시별 공정현황으로 진행상태 계산
|
||||||
function computeProgress(
|
function computeProgress(wiId: string, processMap: Map<string, ProcessStep[]>): "대기" | "진행중" | "완료" {
|
||||||
wiId: string,
|
|
||||||
processMap: Map<string, ProcessStep[]>
|
|
||||||
): "대기" | "진행중" | "완료" {
|
|
||||||
const steps = processMap.get(wiId);
|
const steps = processMap.get(wiId);
|
||||||
if (!steps || steps.length === 0) return "대기";
|
if (!steps || steps.length === 0) return "대기";
|
||||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||||
@@ -85,11 +87,15 @@ function computeProgress(
|
|||||||
|
|
||||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||||
export default function ProductionMonitoringPage() {
|
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 [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||||
|
|
||||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||||
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
// 작업지시 목록 조회
|
// 작업지시 목록 조회
|
||||||
const wiRes = await apiClient.get("/work-instruction/list");
|
const wiRes = await apiClient.get("/work-instruction/list");
|
||||||
const wiRaw: WorkInstruction[] =
|
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
|
||||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const wiData = wiRaw.filter((wi) => {
|
const wiData = wiRaw.filter((wi) => {
|
||||||
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
|
|||||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||||
try {
|
try {
|
||||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
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 || [];
|
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||||
|
|
||||||
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
// ─── 자동갱신 ────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh) return;
|
if (!autoRefresh) return;
|
||||||
const timer = setInterval(fetchData, 30000);
|
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [autoRefresh, fetchData]);
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
// ─── 통계 계산 ───────────────────────────────────────────
|
// ─── 통계 계산 ───────────────────────────────────────────
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
// ─── 렌더링 ──────────────────────────────────────────────
|
// ─── 렌더링 ──────────────────────────────────────────────
|
||||||
return (
|
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">
|
<div className="flex flex-shrink-0 items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
|
||||||
variant="outline"
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||||
size="sm"
|
|
||||||
onClick={fetchData}
|
|
||||||
disabled={loading}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
|
|||||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
className="gap-1.5"
|
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"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 요약 카드 */}
|
{/* 요약 카드 */}
|
||||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Timer className="w-5 h-5" />}
|
icon={<Timer className="h-5 w-5" />}
|
||||||
label="대기중"
|
label="대기중"
|
||||||
value={stats.waiting}
|
value={stats.waiting}
|
||||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Loader2 className="w-5 h-5" />}
|
icon={<Loader2 className="h-5 w-5" />}
|
||||||
label="진행중"
|
label="진행중"
|
||||||
value={stats.inProgress}
|
value={stats.inProgress}
|
||||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
label="완료"
|
label="완료"
|
||||||
value={stats.completed}
|
value={stats.completed}
|
||||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<TrendingUp className="w-5 h-5" />}
|
icon={<TrendingUp className="h-5 w-5" />}
|
||||||
label="달성율"
|
label="달성율"
|
||||||
value={`${stats.achievementRate}%`}
|
value={`${stats.achievementRate}%`}
|
||||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||||
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
</div>
|
</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) => (
|
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||||
<Button
|
<Button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
|
|||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[64px]",
|
"min-w-[64px]",
|
||||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
|
||||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
|
||||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
{/* 로딩 상태 */}
|
{/* 로딩 상태 */}
|
||||||
{loading && workInstructions.length === 0 && (
|
{loading && workInstructions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
|
||||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 빈 상태 */}
|
{/* 빈 상태 */}
|
||||||
{!loading && filteredInstructions.length === 0 && (
|
{!loading && filteredInstructions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Inbox className="w-12 h-12 mb-3" />
|
<Inbox className="mb-3 h-12 w-12" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{activeTab === "전체"
|
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||||
? "등록된 작업지시가 없습니다."
|
|
||||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 작업 카드 그리드 */}
|
{/* 작업 카드 */}
|
||||||
{filteredInstructions.length > 0 && (
|
{filteredInstructions.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="grid gap-4 flex-1"
|
className={cn(
|
||||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
"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) => (
|
{filteredInstructions.map((wi, idx) => (
|
||||||
<WorkCard
|
<WorkCard
|
||||||
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
instruction={wi}
|
instruction={wi}
|
||||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||||
|
displayFields={settings.displayFields}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -338,11 +354,11 @@ function SummaryCard({
|
|||||||
colorClass: string;
|
colorClass: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
|
||||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
<span className="text-muted-foreground text-xs">{label}</span>
|
||||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
<span className="text-foreground text-2xl font-bold">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -353,10 +369,12 @@ function WorkCard({
|
|||||||
instruction: wi,
|
instruction: wi,
|
||||||
steps,
|
steps,
|
||||||
progress,
|
progress,
|
||||||
|
displayFields: df,
|
||||||
}: {
|
}: {
|
||||||
instruction: WorkInstruction;
|
instruction: WorkInstruction;
|
||||||
steps: ProcessStep[];
|
steps: ProcessStep[];
|
||||||
progress: "대기" | "진행중" | "완료";
|
progress: "대기" | "진행중" | "완료";
|
||||||
|
displayFields: ProductionDisplayFields;
|
||||||
}) {
|
}) {
|
||||||
// API 응답은 flat 구조 (details 배열 아님)
|
// API 응답은 flat 구조 (details 배열 아님)
|
||||||
const itemName = (wi as any).item_name || "-";
|
const itemName = (wi as any).item_name || "-";
|
||||||
@@ -373,36 +391,28 @@ function WorkCard({
|
|||||||
const currentStep = steps.find((s) => s.status !== "completed");
|
const currentStep = steps.find((s) => s.status !== "completed");
|
||||||
|
|
||||||
// 프로그레스바 색상
|
// 프로그레스바 색상
|
||||||
const barColor =
|
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
|
||||||
progressPercent >= 100
|
|
||||||
? "bg-emerald-500"
|
|
||||||
: progressPercent >= 50
|
|
||||||
? "bg-blue-500"
|
|
||||||
: "bg-amber-500";
|
|
||||||
|
|
||||||
// 상태 배지 스타일
|
// 상태 배지 스타일
|
||||||
const statusBadge: Record<string, string> = {
|
const statusBadge: Record<string, string> = {
|
||||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-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-blue-500/10 text-blue-500 border-blue-500/30",
|
||||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
완료: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
const isUrgent = wi.status === "긴급";
|
const isUrgent = wi.status === "긴급";
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-sm text-foreground">
|
{df.workInstructionNo && (
|
||||||
{wi.work_instruction_no}
|
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
|
||||||
</span>
|
)}
|
||||||
{isUrgent && (
|
{df.priority && isUrgent && (
|
||||||
<Badge
|
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
|
||||||
variant="outline"
|
<AlertTriangle className="h-3 w-3" />
|
||||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="w-3 h-3" />
|
|
||||||
긴급
|
긴급
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -413,82 +423,88 @@ function WorkCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카드 본문 - 정보 */}
|
{/* 카드 본문 - 정보 */}
|
||||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
|
||||||
<InfoRow label="품목명" value={itemName} />
|
{df.itemName && <InfoRow label="품목명" value={itemName} />}
|
||||||
<InfoRow label="규격" value={spec} />
|
{df.spec && <InfoRow label="규격" value={spec} />}
|
||||||
<InfoRow label="거래처" value={customerName} />
|
{df.customerName && <InfoRow label="거래처" value={customerName} />}
|
||||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
|
||||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
|
||||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
{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>
|
||||||
|
|
||||||
{/* 공정현황 */}
|
{/* 공정현황 */}
|
||||||
<div className="px-4 py-3 border-b">
|
{df.processProgress && (
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="border-b px-4 py-3">
|
||||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
{steps.length > 0 && (
|
<span className="text-muted-foreground text-xs font-medium">공정현황</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
{steps.length > 0 && (
|
||||||
완료 {completedSteps}/{steps.length}
|
<span className="text-muted-foreground text-xs">
|
||||||
{currentStep && (
|
완료 {completedSteps}/{steps.length}
|
||||||
<span>
|
{currentStep && (
|
||||||
{" "}
|
<span>
|
||||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
{" "}
|
||||||
</span>
|
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||||
)}
|
</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>
|
</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">
|
{df.progressBar && (
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="px-4 py-3">
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
{completedQty} / {totalQty}
|
<span className="text-muted-foreground text-xs">
|
||||||
</span>
|
{completedQty} / {totalQty}
|
||||||
<span
|
</span>
|
||||||
className={cn(
|
<span
|
||||||
"text-xs font-bold",
|
className={cn(
|
||||||
progressPercent >= 100
|
"text-xs font-bold",
|
||||||
? "text-emerald-500"
|
progressPercent >= 100
|
||||||
: progressPercent >= 50
|
? "text-emerald-500"
|
||||||
? "text-blue-500"
|
: progressPercent >= 50
|
||||||
: "text-amber-500"
|
? "text-blue-500"
|
||||||
)}
|
: "text-amber-500",
|
||||||
>
|
)}
|
||||||
{progressPercent}%
|
>
|
||||||
</span>
|
{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>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -496,7 +512,7 @@ function WorkCard({
|
|||||||
// ─── 정보 행 ───────────────────────────────────────────────
|
// ─── 정보 행 ───────────────────────────────────────────────
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
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-muted-foreground shrink-0">{label}:</span>
|
||||||
<span className="text-foreground truncate">{value}</span>
|
<span className="text-foreground truncate">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,23 +4,12 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"
|
|||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||||
RefreshCw,
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||||
Clock,
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
Loader2,
|
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
||||||
Inbox,
|
|
||||||
Search,
|
|
||||||
ClipboardCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
/* ───── 타입 ───── */
|
/* ───── 타입 ───── */
|
||||||
interface ProcessRow {
|
interface ProcessRow {
|
||||||
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
|
|||||||
|
|
||||||
/* ───── 유틸 ───── */
|
/* ───── 유틸 ───── */
|
||||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||||
const pct = (n: number) =>
|
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||||
`${n.toFixed(1)}%`;
|
|
||||||
|
|
||||||
const badgeVariant = (
|
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
|
||||||
type: "result" | "type" | "defectRate",
|
|
||||||
value: string | number,
|
|
||||||
) => {
|
|
||||||
if (type === "result") {
|
if (type === "result") {
|
||||||
if (value === "합격")
|
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
|
||||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||||
}
|
}
|
||||||
if (type === "type") {
|
if (type === "type") {
|
||||||
if (value === "공정검사")
|
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
|
||||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
|
||||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
}
|
}
|
||||||
@@ -93,10 +76,15 @@ const badgeVariant = (
|
|||||||
|
|
||||||
/* ───── 컴포넌트 ───── */
|
/* ───── 컴포넌트 ───── */
|
||||||
export default function QualityMonitoringPage() {
|
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 [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
|
|||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.post(
|
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||||
"/table-management/tables/work_order_process/data",
|
|
||||||
{ autoFilter: true },
|
|
||||||
);
|
|
||||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||||
setProcessData(rows);
|
setProcessData(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
|
|||||||
/* ───── 자동 갱신 ───── */
|
/* ───── 자동 갱신 ───── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRefresh) {
|
if (autoRefresh) {
|
||||||
intervalRef.current = setInterval(fetchData, 30_000);
|
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
};
|
};
|
||||||
}, [autoRefresh, fetchData]);
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
/* ───── 검사 행 변환 ───── */
|
/* ───── 검사 행 변환 ───── */
|
||||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||||
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
|
|||||||
const goodQty = r.good_qty ?? 0;
|
const goodQty = r.good_qty ?? 0;
|
||||||
const defectQty = r.defect_qty ?? 0;
|
const defectQty = r.defect_qty ?? 0;
|
||||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||||
const result: InspectionRow["result"] =
|
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
|
||||||
r.status !== "completed"
|
|
||||||
? "대기"
|
|
||||||
: defectQty > 0
|
|
||||||
? "불합격"
|
|
||||||
: "합격";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
no: idx + 1,
|
no: idx + 1,
|
||||||
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
|
|||||||
|
|
||||||
/* ───── 렌더링 ───── */
|
/* ───── 렌더링 ───── */
|
||||||
return (
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
||||||
품질점검현황{" "}
|
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
||||||
<span className="text-emerald-600">모니터링</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{currentTime.toLocaleString("ko-KR", {
|
{currentTime.toLocaleString("ko-KR", {
|
||||||
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||||
variant="outline"
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
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>
|
<span className="ml-1">새로고침</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={autoRefresh ? "default" : "outline"}
|
variant={autoRefresh ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setAutoRefresh((p) => !p)}
|
onClick={() => setAutoRefresh((p) => !p)}
|
||||||
className={cn(
|
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
|
||||||
autoRefresh &&
|
|
||||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Clock className="h-4 w-4 mr-1" />
|
<Clock className="mr-1 h-4 w-4" />
|
||||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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>
|
</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">
|
<div className="grid grid-cols-5 gap-4">
|
||||||
{summaryCards.map((card) => (
|
{summaryCards.map((card) => (
|
||||||
<div
|
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||||
key={card.label}
|
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||||
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)}>
|
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||||
{card.value}
|
{card.value}
|
||||||
{card.sub && (
|
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
|
||||||
<span className="ml-1 text-base font-normal text-white/70">
|
|
||||||
{card.sub}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
|
|||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={cn(
|
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
|
activeTab === tab.key
|
||||||
? "bg-emerald-600 text-white shadow"
|
? "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}
|
{tab.label}
|
||||||
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
|
|||||||
</div>
|
</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">
|
<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-lg font-medium">준비중</p>
|
||||||
<p className="text-sm mt-1">
|
<p className="mt-1 text-sm">
|
||||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
||||||
아직 지원되지 않습니다.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : loading && filteredRows.length === 0 ? (
|
) : loading && filteredRows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
<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>
|
<p>데이터를 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredRows.length === 0 ? (
|
) : filteredRows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
<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>
|
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<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="w-[50px] text-center">No</TableHead>
|
||||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
||||||
<TableHead className="min-w-[90px] text-center">
|
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
||||||
검사유형
|
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
||||||
</TableHead>
|
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
||||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
||||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
||||||
검사수량
|
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
||||||
</TableHead>
|
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
||||||
합격수량
|
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
||||||
</TableHead>
|
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
||||||
불합격수량
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[70px] text-right">
|
|
||||||
불량율
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[160px] text-center">
|
|
||||||
검사결과
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[70px] text-center">
|
|
||||||
판정
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[80px] text-center">
|
|
||||||
검사자
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
|
||||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredRows.map((row) => {
|
{filteredRows.map((row) => {
|
||||||
const goodPct =
|
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
||||||
row.inspectionQty > 0
|
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
||||||
? (row.goodQty / row.inspectionQty) * 100
|
|
||||||
: 0;
|
|
||||||
const defectPct =
|
|
||||||
row.inspectionQty > 0
|
|
||||||
? (row.defectQty / row.inspectionQty) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow key={row.no} className={theme.tableRowHover}>
|
||||||
key={row.no}
|
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
||||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
||||||
>
|
{tc.inspectionType && (
|
||||||
<TableCell className="text-center text-sm text-gray-500">
|
<TableCell className="text-center">
|
||||||
{row.no}
|
<Badge
|
||||||
</TableCell>
|
variant="outline"
|
||||||
<TableCell className="font-mono text-sm">
|
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
||||||
{row.inspectionNo}
|
>
|
||||||
</TableCell>
|
{row.inspectionType}
|
||||||
<TableCell className="text-center">
|
</Badge>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant="outline"
|
)}
|
||||||
className={cn(
|
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
||||||
"text-xs",
|
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
||||||
badgeVariant("type", row.inspectionType),
|
{tc.inspectionQty && (
|
||||||
)}
|
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
||||||
>
|
)}
|
||||||
{row.inspectionType}
|
{tc.passFailQty && (
|
||||||
</Badge>
|
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm font-medium">
|
{tc.passFailQty && (
|
||||||
{row.itemName}
|
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm text-gray-500">
|
{tc.defectRate && (
|
||||||
{row.spec}
|
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
||||||
</TableCell>
|
{pct(row.defectRate)}
|
||||||
<TableCell className="text-right text-sm">
|
</TableCell>
|
||||||
{fmt(row.inspectionQty)}
|
)}
|
||||||
</TableCell>
|
{tc.resultBar && (
|
||||||
<TableCell className="text-right text-sm text-emerald-600">
|
<TableCell>
|
||||||
{fmt(row.goodQty)}
|
<div className="flex items-center gap-2">
|
||||||
</TableCell>
|
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
||||||
<TableCell className="text-right text-sm text-red-600">
|
<div
|
||||||
{fmt(row.defectQty)}
|
className="h-full bg-emerald-500 transition-all"
|
||||||
</TableCell>
|
style={{ width: `${goodPct}%` }}
|
||||||
<TableCell
|
/>
|
||||||
className={cn(
|
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
||||||
"text-right text-sm",
|
</div>
|
||||||
badgeVariant("defectRate", row.defectRate),
|
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
||||||
)}
|
{pct(goodPct)}
|
||||||
>
|
</span>
|
||||||
{pct(row.defectRate)}
|
|
||||||
</TableCell>
|
|
||||||
{/* 검사결과 프로그레스바 */}
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
|
||||||
<div
|
|
||||||
className="h-full bg-emerald-500 transition-all"
|
|
||||||
style={{ width: `${goodPct}%` }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="h-full bg-red-500 transition-all"
|
|
||||||
style={{ width: `${defectPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
</TableCell>
|
||||||
{pct(goodPct)}
|
)}
|
||||||
</span>
|
{tc.judgment && (
|
||||||
</div>
|
<TableCell className="text-center">
|
||||||
</TableCell>
|
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
||||||
{/* 판정 배지 */}
|
{row.result}
|
||||||
<TableCell className="text-center">
|
</Badge>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant="outline"
|
)}
|
||||||
className={cn(
|
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
||||||
"text-xs",
|
{tc.inspectedAt && (
|
||||||
badgeVariant("result", row.result),
|
<TableCell className={cn("text-sm", theme.mutedText)}>
|
||||||
)}
|
{row.inspectedAt !== "-"
|
||||||
>
|
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
||||||
{row.result}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center text-sm">
|
|
||||||
{row.inspectorName}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-gray-500">
|
|
||||||
{row.inspectedAt !== "-"
|
|
||||||
? new Date(row.inspectedAt).toLocaleString(
|
|
||||||
"ko-KR",
|
|
||||||
{
|
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
},
|
})
|
||||||
)
|
: "-"}
|
||||||
: "-"}
|
</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm text-gray-400">
|
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
||||||
{row.remark || "-"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -12,16 +12,10 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||||
RefreshCw,
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||||
Clock,
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
Loader2,
|
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
|
||||||
Inbox,
|
|
||||||
Wrench,
|
|
||||||
Zap,
|
|
||||||
Pause,
|
|
||||||
Power,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
/* ───── 상태 정의 ───── */
|
/* ───── 상태 정의 ───── */
|
||||||
|
|
||||||
@@ -134,11 +128,16 @@ interface WorkInstruction {
|
|||||||
/* ───── 컴포넌트 ───── */
|
/* ───── 컴포넌트 ───── */
|
||||||
|
|
||||||
export default function EquipmentMonitoringPage() {
|
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 [equipments, setEquipments] = useState<Equipment[]>([]);
|
||||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
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 [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||||
const autoRefreshRef = useRef(autoRefresh);
|
const autoRefreshRef = useRef(autoRefresh);
|
||||||
|
|
||||||
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
/* ── 자동 갱신 (30초) ── */
|
/* ── 자동 갱신 ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (autoRefreshRef.current) fetchData();
|
if (autoRefreshRef.current) fetchData();
|
||||||
}, 30000);
|
}, settings.refreshInterval * 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchData]);
|
}, [fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
/* ── 요약 통계 ── */
|
/* ── 요약 통계 ── */
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
|
|||||||
|
|
||||||
/* ── 필터 pill ── */
|
/* ── 필터 pill ── */
|
||||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
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: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
|
||||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-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-300 hover:bg-amber-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-300 hover:bg-red-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-300 hover:bg-gray-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 (
|
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">
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-8 w-1.5 rounded-full bg-amber-400" />
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">{formatDate(currentTime)}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 자동갱신 토글 */}
|
{/* 자동갱신 토글 */}
|
||||||
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-gray-700 text-xs gap-1.5",
|
"gap-1.5 text-xs",
|
||||||
autoRefresh
|
autoRefresh
|
||||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
|
||||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={() => setAutoRefresh((v) => !v)}
|
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"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
className="gap-1.5"
|
||||||
onClick={fetchData}
|
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
<Settings2 className="h-4 w-4" />
|
||||||
새로고침
|
설정
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── 요약 카드 5개 ── */}
|
{/* ── 요약 카드 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) => (
|
{summaryCards.map((card) => (
|
||||||
<button
|
<button
|
||||||
key={card.label}
|
key={card.label}
|
||||||
onClick={() =>
|
onClick={() => setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))}
|
||||||
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
|
|
||||||
}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
||||||
card.bg,
|
card.bg,
|
||||||
card.border,
|
card.border,
|
||||||
"hover:shadow-lg"
|
"hover:shadow-lg",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
||||||
{card.icon}
|
{card.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<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>
|
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||||
filterStatus === pill.value
|
filterStatus === pill.value
|
||||||
? cn(pill.color, "ring-1 ring-white/20")
|
? cn(pill.color, "ring-1 ring-foreground/10")
|
||||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pill.label}
|
{pill.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<span className="ml-auto text-sm text-gray-600 self-center">
|
<span className="text-muted-foreground ml-auto self-center text-sm">{filteredEquipments.length}대 표시</span>
|
||||||
{filteredEquipments.length}대 표시
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 로딩 ── */}
|
{/* ── 로딩 ── */}
|
||||||
{loading && equipments.length === 0 && (
|
{loading && equipments.length === 0 && (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-gray-600" />
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
<span className="text-muted-foreground ml-3">설비 데이터를 불러오는 중...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 데이터 없음 ── */}
|
{/* ── 데이터 없음 ── */}
|
||||||
{!loading && equipments.length === 0 && (
|
{!loading && equipments.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-gray-600">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Inbox className="h-12 w-12 mb-3" />
|
<Inbox className="mb-3 h-12 w-12" />
|
||||||
<p className="text-lg">등록된 설비가 없습니다.</p>
|
<p className="text-lg">등록된 설비가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 설비 카드 그리드 ── */}
|
{/* ── 설비 카드 그리드 ── */}
|
||||||
{filteredEquipments.length > 0 && (
|
{filteredEquipments.length > 0 && (
|
||||||
<div
|
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}>
|
||||||
className="grid gap-4"
|
|
||||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
|
|
||||||
>
|
|
||||||
{filteredEquipments.map((eq) => {
|
{filteredEquipments.map((eq) => {
|
||||||
const status = resolveStatus(eq.operation_status);
|
const status = resolveStatus(eq.operation_status);
|
||||||
const cfg = STATUS_MAP[status];
|
const cfg = STATUS_MAP[status];
|
||||||
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
|
|||||||
<div
|
<div
|
||||||
key={eq.id}
|
key={eq.id}
|
||||||
className={cn(
|
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.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="flex items-start justify-between px-4 pt-3 pb-2 pl-5">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-base font-semibold text-white truncate">
|
{df.equipmentName && (
|
||||||
{eq.equipment_name || "이름 없음"}
|
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
|
||||||
</h3>
|
{eq.equipment_name || "이름 없음"}
|
||||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
</h3>
|
||||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
)}
|
||||||
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
{df.operationStatus && (
|
||||||
className={cn(
|
<Badge
|
||||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
className={cn("ml-2 shrink-0 gap-1 border-0 text-xs font-medium", cfg.badgeBg, cfg.badgeText)}
|
||||||
cfg.badgeBg,
|
>
|
||||||
cfg.badgeText
|
{cfg.icon}
|
||||||
)}
|
{cfg.label}
|
||||||
>
|
</Badge>
|
||||||
{cfg.icon}
|
|
||||||
{cfg.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 정보 그리드 */}
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
|
||||||
<p className="text-white font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">생산수량</span>
|
|
||||||
<p className="text-white font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">작업자</span>
|
|
||||||
<p className="text-white font-medium">
|
|
||||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
|
||||||
? eqWIs[0].worker_name
|
|
||||||
: "-"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">설비코드</span>
|
|
||||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 가동률 프로그레스 */}
|
|
||||||
<div className="px-4 pl-5 py-2.5">
|
|
||||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
|
||||||
<span className="text-gray-500">가동률</span>
|
|
||||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
|
||||||
{utilization !== null ? `${utilization}%` : "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
|
||||||
{utilization !== null && (
|
|
||||||
<div
|
|
||||||
className={cn("h-full rounded-full transition-all duration-700", cfg.bar)}
|
|
||||||
style={{ width: `${utilization}%` }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 현재 작업지시 */}
|
|
||||||
<div className="px-4 pl-5 py-2.5">
|
|
||||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</p>
|
|
||||||
{eqWIs.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{eqWIs.slice(0, 2).map((wi) => (
|
|
||||||
<div key={wi.id} className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-blue-400 font-mono text-xs shrink-0">
|
|
||||||
{wi.instruction_number || "-"}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-300 truncate">
|
|
||||||
{wi.item_name || "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{eqWIs.length > 2 && (
|
|
||||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 구분선 */}
|
{/* 구분선 */}
|
||||||
<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="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
{df.dailyOperationTime && (
|
||||||
<span className="text-gray-600">온도</span>
|
<div>
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
<span className={cn("text-xs", theme.mutedText)}>금일 가동시간</span>
|
||||||
</div>
|
<p className={cn("font-medium", theme.text)}>-</p>
|
||||||
<div className="flex items-center gap-1.5">
|
</div>
|
||||||
<span className="text-gray-600">압력</span>
|
)}
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
{df.dailyProductionQty && (
|
||||||
</div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5">
|
<span className={cn("text-xs", theme.mutedText)}>생산수량</span>
|
||||||
<span className="text-gray-600">RPM</span>
|
<p className={cn("font-medium", theme.text)}>-</p>
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
|
|||||||
|
|
||||||
{/* 필터 결과 없음 */}
|
{/* 필터 결과 없음 */}
|
||||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
|
||||||
<Inbox className="h-10 w-10 mb-2" />
|
<Inbox className="mb-2 h-10 w-10" />
|
||||||
<p>해당 상태의 설비가 없습니다.</p>
|
<p>해당 상태의 설비가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -16,6 +20,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||||
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 작업지시별 공정현황으로 진행상태 계산
|
// 작업지시별 공정현황으로 진행상태 계산
|
||||||
function computeProgress(
|
function computeProgress(wiId: string, processMap: Map<string, ProcessStep[]>): "대기" | "진행중" | "완료" {
|
||||||
wiId: string,
|
|
||||||
processMap: Map<string, ProcessStep[]>
|
|
||||||
): "대기" | "진행중" | "완료" {
|
|
||||||
const steps = processMap.get(wiId);
|
const steps = processMap.get(wiId);
|
||||||
if (!steps || steps.length === 0) return "대기";
|
if (!steps || steps.length === 0) return "대기";
|
||||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||||
@@ -85,11 +87,15 @@ function computeProgress(
|
|||||||
|
|
||||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||||
export default function ProductionMonitoringPage() {
|
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 [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||||
|
|
||||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||||
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
// 작업지시 목록 조회
|
// 작업지시 목록 조회
|
||||||
const wiRes = await apiClient.get("/work-instruction/list");
|
const wiRes = await apiClient.get("/work-instruction/list");
|
||||||
const wiRaw: WorkInstruction[] =
|
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
|
||||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const wiData = wiRaw.filter((wi) => {
|
const wiData = wiRaw.filter((wi) => {
|
||||||
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
|
|||||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||||
try {
|
try {
|
||||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
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 || [];
|
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||||
|
|
||||||
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
// ─── 자동갱신 ────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh) return;
|
if (!autoRefresh) return;
|
||||||
const timer = setInterval(fetchData, 30000);
|
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [autoRefresh, fetchData]);
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
// ─── 통계 계산 ───────────────────────────────────────────
|
// ─── 통계 계산 ───────────────────────────────────────────
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
// ─── 렌더링 ──────────────────────────────────────────────
|
// ─── 렌더링 ──────────────────────────────────────────────
|
||||||
return (
|
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">
|
<div className="flex flex-shrink-0 items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
|
||||||
variant="outline"
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||||
size="sm"
|
|
||||||
onClick={fetchData}
|
|
||||||
disabled={loading}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
|
|||||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
className="gap-1.5"
|
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"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 요약 카드 */}
|
{/* 요약 카드 */}
|
||||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Timer className="w-5 h-5" />}
|
icon={<Timer className="h-5 w-5" />}
|
||||||
label="대기중"
|
label="대기중"
|
||||||
value={stats.waiting}
|
value={stats.waiting}
|
||||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Loader2 className="w-5 h-5" />}
|
icon={<Loader2 className="h-5 w-5" />}
|
||||||
label="진행중"
|
label="진행중"
|
||||||
value={stats.inProgress}
|
value={stats.inProgress}
|
||||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
label="완료"
|
label="완료"
|
||||||
value={stats.completed}
|
value={stats.completed}
|
||||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<TrendingUp className="w-5 h-5" />}
|
icon={<TrendingUp className="h-5 w-5" />}
|
||||||
label="달성율"
|
label="달성율"
|
||||||
value={`${stats.achievementRate}%`}
|
value={`${stats.achievementRate}%`}
|
||||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||||
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
</div>
|
</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) => (
|
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||||
<Button
|
<Button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
|
|||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[64px]",
|
"min-w-[64px]",
|
||||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
|
||||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
|
||||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
{/* 로딩 상태 */}
|
{/* 로딩 상태 */}
|
||||||
{loading && workInstructions.length === 0 && (
|
{loading && workInstructions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
|
||||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 빈 상태 */}
|
{/* 빈 상태 */}
|
||||||
{!loading && filteredInstructions.length === 0 && (
|
{!loading && filteredInstructions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Inbox className="w-12 h-12 mb-3" />
|
<Inbox className="mb-3 h-12 w-12" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{activeTab === "전체"
|
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||||
? "등록된 작업지시가 없습니다."
|
|
||||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 작업 카드 그리드 */}
|
{/* 작업 카드 */}
|
||||||
{filteredInstructions.length > 0 && (
|
{filteredInstructions.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="grid gap-4 flex-1"
|
className={cn(
|
||||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
"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) => (
|
{filteredInstructions.map((wi, idx) => (
|
||||||
<WorkCard
|
<WorkCard
|
||||||
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
instruction={wi}
|
instruction={wi}
|
||||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||||
|
displayFields={settings.displayFields}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -338,11 +354,11 @@ function SummaryCard({
|
|||||||
colorClass: string;
|
colorClass: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
|
||||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
<span className="text-muted-foreground text-xs">{label}</span>
|
||||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
<span className="text-foreground text-2xl font-bold">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -353,10 +369,12 @@ function WorkCard({
|
|||||||
instruction: wi,
|
instruction: wi,
|
||||||
steps,
|
steps,
|
||||||
progress,
|
progress,
|
||||||
|
displayFields: df,
|
||||||
}: {
|
}: {
|
||||||
instruction: WorkInstruction;
|
instruction: WorkInstruction;
|
||||||
steps: ProcessStep[];
|
steps: ProcessStep[];
|
||||||
progress: "대기" | "진행중" | "완료";
|
progress: "대기" | "진행중" | "완료";
|
||||||
|
displayFields: ProductionDisplayFields;
|
||||||
}) {
|
}) {
|
||||||
// API 응답은 flat 구조 (details 배열 아님)
|
// API 응답은 flat 구조 (details 배열 아님)
|
||||||
const itemName = (wi as any).item_name || "-";
|
const itemName = (wi as any).item_name || "-";
|
||||||
@@ -373,36 +391,28 @@ function WorkCard({
|
|||||||
const currentStep = steps.find((s) => s.status !== "completed");
|
const currentStep = steps.find((s) => s.status !== "completed");
|
||||||
|
|
||||||
// 프로그레스바 색상
|
// 프로그레스바 색상
|
||||||
const barColor =
|
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
|
||||||
progressPercent >= 100
|
|
||||||
? "bg-emerald-500"
|
|
||||||
: progressPercent >= 50
|
|
||||||
? "bg-blue-500"
|
|
||||||
: "bg-amber-500";
|
|
||||||
|
|
||||||
// 상태 배지 스타일
|
// 상태 배지 스타일
|
||||||
const statusBadge: Record<string, string> = {
|
const statusBadge: Record<string, string> = {
|
||||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-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-blue-500/10 text-blue-500 border-blue-500/30",
|
||||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
완료: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
const isUrgent = wi.status === "긴급";
|
const isUrgent = wi.status === "긴급";
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-sm text-foreground">
|
{df.workInstructionNo && (
|
||||||
{wi.work_instruction_no}
|
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
|
||||||
</span>
|
)}
|
||||||
{isUrgent && (
|
{df.priority && isUrgent && (
|
||||||
<Badge
|
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
|
||||||
variant="outline"
|
<AlertTriangle className="h-3 w-3" />
|
||||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="w-3 h-3" />
|
|
||||||
긴급
|
긴급
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -413,82 +423,88 @@ function WorkCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카드 본문 - 정보 */}
|
{/* 카드 본문 - 정보 */}
|
||||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
|
||||||
<InfoRow label="품목명" value={itemName} />
|
{df.itemName && <InfoRow label="품목명" value={itemName} />}
|
||||||
<InfoRow label="규격" value={spec} />
|
{df.spec && <InfoRow label="규격" value={spec} />}
|
||||||
<InfoRow label="거래처" value={customerName} />
|
{df.customerName && <InfoRow label="거래처" value={customerName} />}
|
||||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
|
||||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
|
||||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
{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>
|
||||||
|
|
||||||
{/* 공정현황 */}
|
{/* 공정현황 */}
|
||||||
<div className="px-4 py-3 border-b">
|
{df.processProgress && (
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="border-b px-4 py-3">
|
||||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
{steps.length > 0 && (
|
<span className="text-muted-foreground text-xs font-medium">공정현황</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
{steps.length > 0 && (
|
||||||
완료 {completedSteps}/{steps.length}
|
<span className="text-muted-foreground text-xs">
|
||||||
{currentStep && (
|
완료 {completedSteps}/{steps.length}
|
||||||
<span>
|
{currentStep && (
|
||||||
{" "}
|
<span>
|
||||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
{" "}
|
||||||
</span>
|
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||||
)}
|
</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>
|
</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">
|
{df.progressBar && (
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="px-4 py-3">
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
{completedQty} / {totalQty}
|
<span className="text-muted-foreground text-xs">
|
||||||
</span>
|
{completedQty} / {totalQty}
|
||||||
<span
|
</span>
|
||||||
className={cn(
|
<span
|
||||||
"text-xs font-bold",
|
className={cn(
|
||||||
progressPercent >= 100
|
"text-xs font-bold",
|
||||||
? "text-emerald-500"
|
progressPercent >= 100
|
||||||
: progressPercent >= 50
|
? "text-emerald-500"
|
||||||
? "text-blue-500"
|
: progressPercent >= 50
|
||||||
: "text-amber-500"
|
? "text-blue-500"
|
||||||
)}
|
: "text-amber-500",
|
||||||
>
|
)}
|
||||||
{progressPercent}%
|
>
|
||||||
</span>
|
{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>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -496,7 +512,7 @@ function WorkCard({
|
|||||||
// ─── 정보 행 ───────────────────────────────────────────────
|
// ─── 정보 행 ───────────────────────────────────────────────
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
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-muted-foreground shrink-0">{label}:</span>
|
||||||
<span className="text-foreground truncate">{value}</span>
|
<span className="text-foreground truncate">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,23 +4,12 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"
|
|||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||||
RefreshCw,
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||||
Clock,
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
Loader2,
|
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
||||||
Inbox,
|
|
||||||
Search,
|
|
||||||
ClipboardCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
/* ───── 타입 ───── */
|
/* ───── 타입 ───── */
|
||||||
interface ProcessRow {
|
interface ProcessRow {
|
||||||
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
|
|||||||
|
|
||||||
/* ───── 유틸 ───── */
|
/* ───── 유틸 ───── */
|
||||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||||
const pct = (n: number) =>
|
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||||
`${n.toFixed(1)}%`;
|
|
||||||
|
|
||||||
const badgeVariant = (
|
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
|
||||||
type: "result" | "type" | "defectRate",
|
|
||||||
value: string | number,
|
|
||||||
) => {
|
|
||||||
if (type === "result") {
|
if (type === "result") {
|
||||||
if (value === "합격")
|
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
|
||||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||||
}
|
}
|
||||||
if (type === "type") {
|
if (type === "type") {
|
||||||
if (value === "공정검사")
|
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
|
||||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
|
||||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
}
|
}
|
||||||
@@ -93,10 +76,15 @@ const badgeVariant = (
|
|||||||
|
|
||||||
/* ───── 컴포넌트 ───── */
|
/* ───── 컴포넌트 ───── */
|
||||||
export default function QualityMonitoringPage() {
|
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 [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
|
|||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.post(
|
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||||
"/table-management/tables/work_order_process/data",
|
|
||||||
{ autoFilter: true },
|
|
||||||
);
|
|
||||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||||
setProcessData(rows);
|
setProcessData(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
|
|||||||
/* ───── 자동 갱신 ───── */
|
/* ───── 자동 갱신 ───── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRefresh) {
|
if (autoRefresh) {
|
||||||
intervalRef.current = setInterval(fetchData, 30_000);
|
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
};
|
};
|
||||||
}, [autoRefresh, fetchData]);
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
/* ───── 검사 행 변환 ───── */
|
/* ───── 검사 행 변환 ───── */
|
||||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||||
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
|
|||||||
const goodQty = r.good_qty ?? 0;
|
const goodQty = r.good_qty ?? 0;
|
||||||
const defectQty = r.defect_qty ?? 0;
|
const defectQty = r.defect_qty ?? 0;
|
||||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||||
const result: InspectionRow["result"] =
|
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
|
||||||
r.status !== "completed"
|
|
||||||
? "대기"
|
|
||||||
: defectQty > 0
|
|
||||||
? "불합격"
|
|
||||||
: "합격";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
no: idx + 1,
|
no: idx + 1,
|
||||||
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
|
|||||||
|
|
||||||
/* ───── 렌더링 ───── */
|
/* ───── 렌더링 ───── */
|
||||||
return (
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
||||||
품질점검현황{" "}
|
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
||||||
<span className="text-emerald-600">모니터링</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{currentTime.toLocaleString("ko-KR", {
|
{currentTime.toLocaleString("ko-KR", {
|
||||||
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||||
variant="outline"
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
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>
|
<span className="ml-1">새로고침</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={autoRefresh ? "default" : "outline"}
|
variant={autoRefresh ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setAutoRefresh((p) => !p)}
|
onClick={() => setAutoRefresh((p) => !p)}
|
||||||
className={cn(
|
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
|
||||||
autoRefresh &&
|
|
||||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Clock className="h-4 w-4 mr-1" />
|
<Clock className="mr-1 h-4 w-4" />
|
||||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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>
|
</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">
|
<div className="grid grid-cols-5 gap-4">
|
||||||
{summaryCards.map((card) => (
|
{summaryCards.map((card) => (
|
||||||
<div
|
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||||
key={card.label}
|
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||||
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)}>
|
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||||
{card.value}
|
{card.value}
|
||||||
{card.sub && (
|
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
|
||||||
<span className="ml-1 text-base font-normal text-white/70">
|
|
||||||
{card.sub}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
|
|||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={cn(
|
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
|
activeTab === tab.key
|
||||||
? "bg-emerald-600 text-white shadow"
|
? "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}
|
{tab.label}
|
||||||
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
|
|||||||
</div>
|
</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">
|
<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-lg font-medium">준비중</p>
|
||||||
<p className="text-sm mt-1">
|
<p className="mt-1 text-sm">
|
||||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
||||||
아직 지원되지 않습니다.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : loading && filteredRows.length === 0 ? (
|
) : loading && filteredRows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
<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>
|
<p>데이터를 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredRows.length === 0 ? (
|
) : filteredRows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
<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>
|
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<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="w-[50px] text-center">No</TableHead>
|
||||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
||||||
<TableHead className="min-w-[90px] text-center">
|
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
||||||
검사유형
|
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
||||||
</TableHead>
|
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
||||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
||||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
||||||
검사수량
|
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
||||||
</TableHead>
|
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
||||||
합격수량
|
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
||||||
</TableHead>
|
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
||||||
불합격수량
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[70px] text-right">
|
|
||||||
불량율
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[160px] text-center">
|
|
||||||
검사결과
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[70px] text-center">
|
|
||||||
판정
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[80px] text-center">
|
|
||||||
검사자
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
|
||||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredRows.map((row) => {
|
{filteredRows.map((row) => {
|
||||||
const goodPct =
|
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
||||||
row.inspectionQty > 0
|
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
||||||
? (row.goodQty / row.inspectionQty) * 100
|
|
||||||
: 0;
|
|
||||||
const defectPct =
|
|
||||||
row.inspectionQty > 0
|
|
||||||
? (row.defectQty / row.inspectionQty) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow key={row.no} className={theme.tableRowHover}>
|
||||||
key={row.no}
|
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
||||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
||||||
>
|
{tc.inspectionType && (
|
||||||
<TableCell className="text-center text-sm text-gray-500">
|
<TableCell className="text-center">
|
||||||
{row.no}
|
<Badge
|
||||||
</TableCell>
|
variant="outline"
|
||||||
<TableCell className="font-mono text-sm">
|
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
||||||
{row.inspectionNo}
|
>
|
||||||
</TableCell>
|
{row.inspectionType}
|
||||||
<TableCell className="text-center">
|
</Badge>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant="outline"
|
)}
|
||||||
className={cn(
|
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
||||||
"text-xs",
|
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
||||||
badgeVariant("type", row.inspectionType),
|
{tc.inspectionQty && (
|
||||||
)}
|
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
||||||
>
|
)}
|
||||||
{row.inspectionType}
|
{tc.passFailQty && (
|
||||||
</Badge>
|
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm font-medium">
|
{tc.passFailQty && (
|
||||||
{row.itemName}
|
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm text-gray-500">
|
{tc.defectRate && (
|
||||||
{row.spec}
|
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
||||||
</TableCell>
|
{pct(row.defectRate)}
|
||||||
<TableCell className="text-right text-sm">
|
</TableCell>
|
||||||
{fmt(row.inspectionQty)}
|
)}
|
||||||
</TableCell>
|
{tc.resultBar && (
|
||||||
<TableCell className="text-right text-sm text-emerald-600">
|
<TableCell>
|
||||||
{fmt(row.goodQty)}
|
<div className="flex items-center gap-2">
|
||||||
</TableCell>
|
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
||||||
<TableCell className="text-right text-sm text-red-600">
|
<div
|
||||||
{fmt(row.defectQty)}
|
className="h-full bg-emerald-500 transition-all"
|
||||||
</TableCell>
|
style={{ width: `${goodPct}%` }}
|
||||||
<TableCell
|
/>
|
||||||
className={cn(
|
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
||||||
"text-right text-sm",
|
</div>
|
||||||
badgeVariant("defectRate", row.defectRate),
|
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
||||||
)}
|
{pct(goodPct)}
|
||||||
>
|
</span>
|
||||||
{pct(row.defectRate)}
|
|
||||||
</TableCell>
|
|
||||||
{/* 검사결과 프로그레스바 */}
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
|
||||||
<div
|
|
||||||
className="h-full bg-emerald-500 transition-all"
|
|
||||||
style={{ width: `${goodPct}%` }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="h-full bg-red-500 transition-all"
|
|
||||||
style={{ width: `${defectPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
</TableCell>
|
||||||
{pct(goodPct)}
|
)}
|
||||||
</span>
|
{tc.judgment && (
|
||||||
</div>
|
<TableCell className="text-center">
|
||||||
</TableCell>
|
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
||||||
{/* 판정 배지 */}
|
{row.result}
|
||||||
<TableCell className="text-center">
|
</Badge>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant="outline"
|
)}
|
||||||
className={cn(
|
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
||||||
"text-xs",
|
{tc.inspectedAt && (
|
||||||
badgeVariant("result", row.result),
|
<TableCell className={cn("text-sm", theme.mutedText)}>
|
||||||
)}
|
{row.inspectedAt !== "-"
|
||||||
>
|
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
||||||
{row.result}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center text-sm">
|
|
||||||
{row.inspectorName}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-gray-500">
|
|
||||||
{row.inspectedAt !== "-"
|
|
||||||
? new Date(row.inspectedAt).toLocaleString(
|
|
||||||
"ko-KR",
|
|
||||||
{
|
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
},
|
})
|
||||||
)
|
: "-"}
|
||||||
: "-"}
|
</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm text-gray-400">
|
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
||||||
{row.remark || "-"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -12,16 +12,10 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||||
RefreshCw,
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||||
Clock,
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
Loader2,
|
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
|
||||||
Inbox,
|
|
||||||
Wrench,
|
|
||||||
Zap,
|
|
||||||
Pause,
|
|
||||||
Power,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
/* ───── 상태 정의 ───── */
|
/* ───── 상태 정의 ───── */
|
||||||
|
|
||||||
@@ -134,11 +128,16 @@ interface WorkInstruction {
|
|||||||
/* ───── 컴포넌트 ───── */
|
/* ───── 컴포넌트 ───── */
|
||||||
|
|
||||||
export default function EquipmentMonitoringPage() {
|
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 [equipments, setEquipments] = useState<Equipment[]>([]);
|
||||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
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 [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||||
const autoRefreshRef = useRef(autoRefresh);
|
const autoRefreshRef = useRef(autoRefresh);
|
||||||
|
|
||||||
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
/* ── 자동 갱신 (30초) ── */
|
/* ── 자동 갱신 ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (autoRefreshRef.current) fetchData();
|
if (autoRefreshRef.current) fetchData();
|
||||||
}, 30000);
|
}, settings.refreshInterval * 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchData]);
|
}, [fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
/* ── 요약 통계 ── */
|
/* ── 요약 통계 ── */
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
|
|||||||
|
|
||||||
/* ── 필터 pill ── */
|
/* ── 필터 pill ── */
|
||||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
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: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
|
||||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-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-300 hover:bg-amber-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-300 hover:bg-red-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-300 hover:bg-gray-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 (
|
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">
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-8 w-1.5 rounded-full bg-amber-400" />
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">{formatDate(currentTime)}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 자동갱신 토글 */}
|
{/* 자동갱신 토글 */}
|
||||||
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-gray-700 text-xs gap-1.5",
|
"gap-1.5 text-xs",
|
||||||
autoRefresh
|
autoRefresh
|
||||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
|
||||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={() => setAutoRefresh((v) => !v)}
|
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"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
className="gap-1.5"
|
||||||
onClick={fetchData}
|
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
<Settings2 className="h-4 w-4" />
|
||||||
새로고침
|
설정
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── 요약 카드 5개 ── */}
|
{/* ── 요약 카드 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) => (
|
{summaryCards.map((card) => (
|
||||||
<button
|
<button
|
||||||
key={card.label}
|
key={card.label}
|
||||||
onClick={() =>
|
onClick={() => setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))}
|
||||||
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
|
|
||||||
}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
||||||
card.bg,
|
card.bg,
|
||||||
card.border,
|
card.border,
|
||||||
"hover:shadow-lg"
|
"hover:shadow-lg",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
||||||
{card.icon}
|
{card.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<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>
|
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||||
filterStatus === pill.value
|
filterStatus === pill.value
|
||||||
? cn(pill.color, "ring-1 ring-white/20")
|
? cn(pill.color, "ring-1 ring-foreground/10")
|
||||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pill.label}
|
{pill.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<span className="ml-auto text-sm text-gray-600 self-center">
|
<span className="text-muted-foreground ml-auto self-center text-sm">{filteredEquipments.length}대 표시</span>
|
||||||
{filteredEquipments.length}대 표시
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 로딩 ── */}
|
{/* ── 로딩 ── */}
|
||||||
{loading && equipments.length === 0 && (
|
{loading && equipments.length === 0 && (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-gray-600" />
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
<span className="text-muted-foreground ml-3">설비 데이터를 불러오는 중...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 데이터 없음 ── */}
|
{/* ── 데이터 없음 ── */}
|
||||||
{!loading && equipments.length === 0 && (
|
{!loading && equipments.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-gray-600">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Inbox className="h-12 w-12 mb-3" />
|
<Inbox className="mb-3 h-12 w-12" />
|
||||||
<p className="text-lg">등록된 설비가 없습니다.</p>
|
<p className="text-lg">등록된 설비가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 설비 카드 그리드 ── */}
|
{/* ── 설비 카드 그리드 ── */}
|
||||||
{filteredEquipments.length > 0 && (
|
{filteredEquipments.length > 0 && (
|
||||||
<div
|
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}>
|
||||||
className="grid gap-4"
|
|
||||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
|
|
||||||
>
|
|
||||||
{filteredEquipments.map((eq) => {
|
{filteredEquipments.map((eq) => {
|
||||||
const status = resolveStatus(eq.operation_status);
|
const status = resolveStatus(eq.operation_status);
|
||||||
const cfg = STATUS_MAP[status];
|
const cfg = STATUS_MAP[status];
|
||||||
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
|
|||||||
<div
|
<div
|
||||||
key={eq.id}
|
key={eq.id}
|
||||||
className={cn(
|
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.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="flex items-start justify-between px-4 pt-3 pb-2 pl-5">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-base font-semibold text-white truncate">
|
{df.equipmentName && (
|
||||||
{eq.equipment_name || "이름 없음"}
|
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
|
||||||
</h3>
|
{eq.equipment_name || "이름 없음"}
|
||||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
</h3>
|
||||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
)}
|
||||||
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
{df.operationStatus && (
|
||||||
className={cn(
|
<Badge
|
||||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
className={cn("ml-2 shrink-0 gap-1 border-0 text-xs font-medium", cfg.badgeBg, cfg.badgeText)}
|
||||||
cfg.badgeBg,
|
>
|
||||||
cfg.badgeText
|
{cfg.icon}
|
||||||
)}
|
{cfg.label}
|
||||||
>
|
</Badge>
|
||||||
{cfg.icon}
|
|
||||||
{cfg.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 정보 그리드 */}
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
|
||||||
<p className="text-white font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">생산수량</span>
|
|
||||||
<p className="text-white font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">작업자</span>
|
|
||||||
<p className="text-white font-medium">
|
|
||||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
|
||||||
? eqWIs[0].worker_name
|
|
||||||
: "-"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">설비코드</span>
|
|
||||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 가동률 프로그레스 */}
|
|
||||||
<div className="px-4 pl-5 py-2.5">
|
|
||||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
|
||||||
<span className="text-gray-500">가동률</span>
|
|
||||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
|
||||||
{utilization !== null ? `${utilization}%` : "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
|
||||||
{utilization !== null && (
|
|
||||||
<div
|
|
||||||
className={cn("h-full rounded-full transition-all duration-700", cfg.bar)}
|
|
||||||
style={{ width: `${utilization}%` }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 현재 작업지시 */}
|
|
||||||
<div className="px-4 pl-5 py-2.5">
|
|
||||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</p>
|
|
||||||
{eqWIs.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{eqWIs.slice(0, 2).map((wi) => (
|
|
||||||
<div key={wi.id} className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-blue-400 font-mono text-xs shrink-0">
|
|
||||||
{wi.instruction_number || "-"}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-300 truncate">
|
|
||||||
{wi.item_name || "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{eqWIs.length > 2 && (
|
|
||||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 구분선 */}
|
{/* 구분선 */}
|
||||||
<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="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
{df.dailyOperationTime && (
|
||||||
<span className="text-gray-600">온도</span>
|
<div>
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
<span className={cn("text-xs", theme.mutedText)}>금일 가동시간</span>
|
||||||
</div>
|
<p className={cn("font-medium", theme.text)}>-</p>
|
||||||
<div className="flex items-center gap-1.5">
|
</div>
|
||||||
<span className="text-gray-600">압력</span>
|
)}
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
{df.dailyProductionQty && (
|
||||||
</div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5">
|
<span className={cn("text-xs", theme.mutedText)}>생산수량</span>
|
||||||
<span className="text-gray-600">RPM</span>
|
<p className={cn("font-medium", theme.text)}>-</p>
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
|
|||||||
|
|
||||||
{/* 필터 결과 없음 */}
|
{/* 필터 결과 없음 */}
|
||||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
|
||||||
<Inbox className="h-10 w-10 mb-2" />
|
<Inbox className="mb-2 h-10 w-10" />
|
||||||
<p>해당 상태의 설비가 없습니다.</p>
|
<p>해당 상태의 설비가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -16,6 +20,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||||
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 작업지시별 공정현황으로 진행상태 계산
|
// 작업지시별 공정현황으로 진행상태 계산
|
||||||
function computeProgress(
|
function computeProgress(wiId: string, processMap: Map<string, ProcessStep[]>): "대기" | "진행중" | "완료" {
|
||||||
wiId: string,
|
|
||||||
processMap: Map<string, ProcessStep[]>
|
|
||||||
): "대기" | "진행중" | "완료" {
|
|
||||||
const steps = processMap.get(wiId);
|
const steps = processMap.get(wiId);
|
||||||
if (!steps || steps.length === 0) return "대기";
|
if (!steps || steps.length === 0) return "대기";
|
||||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||||
@@ -85,11 +87,15 @@ function computeProgress(
|
|||||||
|
|
||||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||||
export default function ProductionMonitoringPage() {
|
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 [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||||
|
|
||||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||||
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
// 작업지시 목록 조회
|
// 작업지시 목록 조회
|
||||||
const wiRes = await apiClient.get("/work-instruction/list");
|
const wiRes = await apiClient.get("/work-instruction/list");
|
||||||
const wiRaw: WorkInstruction[] =
|
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
|
||||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const wiData = wiRaw.filter((wi) => {
|
const wiData = wiRaw.filter((wi) => {
|
||||||
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
|
|||||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||||
try {
|
try {
|
||||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
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 || [];
|
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||||
|
|
||||||
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
// ─── 자동갱신 ────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh) return;
|
if (!autoRefresh) return;
|
||||||
const timer = setInterval(fetchData, 30000);
|
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [autoRefresh, fetchData]);
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
// ─── 통계 계산 ───────────────────────────────────────────
|
// ─── 통계 계산 ───────────────────────────────────────────
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
// ─── 렌더링 ──────────────────────────────────────────────
|
// ─── 렌더링 ──────────────────────────────────────────────
|
||||||
return (
|
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">
|
<div className="flex flex-shrink-0 items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
|
||||||
variant="outline"
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||||
size="sm"
|
|
||||||
onClick={fetchData}
|
|
||||||
disabled={loading}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
|
|||||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
className="gap-1.5"
|
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"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 요약 카드 */}
|
{/* 요약 카드 */}
|
||||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Timer className="w-5 h-5" />}
|
icon={<Timer className="h-5 w-5" />}
|
||||||
label="대기중"
|
label="대기중"
|
||||||
value={stats.waiting}
|
value={stats.waiting}
|
||||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Loader2 className="w-5 h-5" />}
|
icon={<Loader2 className="h-5 w-5" />}
|
||||||
label="진행중"
|
label="진행중"
|
||||||
value={stats.inProgress}
|
value={stats.inProgress}
|
||||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
label="완료"
|
label="완료"
|
||||||
value={stats.completed}
|
value={stats.completed}
|
||||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<TrendingUp className="w-5 h-5" />}
|
icon={<TrendingUp className="h-5 w-5" />}
|
||||||
label="달성율"
|
label="달성율"
|
||||||
value={`${stats.achievementRate}%`}
|
value={`${stats.achievementRate}%`}
|
||||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||||
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
</div>
|
</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) => (
|
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||||
<Button
|
<Button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
|
|||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[64px]",
|
"min-w-[64px]",
|
||||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
|
||||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
|
||||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
{/* 로딩 상태 */}
|
{/* 로딩 상태 */}
|
||||||
{loading && workInstructions.length === 0 && (
|
{loading && workInstructions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
|
||||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 빈 상태 */}
|
{/* 빈 상태 */}
|
||||||
{!loading && filteredInstructions.length === 0 && (
|
{!loading && filteredInstructions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Inbox className="w-12 h-12 mb-3" />
|
<Inbox className="mb-3 h-12 w-12" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{activeTab === "전체"
|
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||||
? "등록된 작업지시가 없습니다."
|
|
||||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 작업 카드 그리드 */}
|
{/* 작업 카드 */}
|
||||||
{filteredInstructions.length > 0 && (
|
{filteredInstructions.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="grid gap-4 flex-1"
|
className={cn(
|
||||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
"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) => (
|
{filteredInstructions.map((wi, idx) => (
|
||||||
<WorkCard
|
<WorkCard
|
||||||
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
instruction={wi}
|
instruction={wi}
|
||||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||||
|
displayFields={settings.displayFields}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -338,11 +354,11 @@ function SummaryCard({
|
|||||||
colorClass: string;
|
colorClass: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
|
||||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
<span className="text-muted-foreground text-xs">{label}</span>
|
||||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
<span className="text-foreground text-2xl font-bold">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -353,10 +369,12 @@ function WorkCard({
|
|||||||
instruction: wi,
|
instruction: wi,
|
||||||
steps,
|
steps,
|
||||||
progress,
|
progress,
|
||||||
|
displayFields: df,
|
||||||
}: {
|
}: {
|
||||||
instruction: WorkInstruction;
|
instruction: WorkInstruction;
|
||||||
steps: ProcessStep[];
|
steps: ProcessStep[];
|
||||||
progress: "대기" | "진행중" | "완료";
|
progress: "대기" | "진행중" | "완료";
|
||||||
|
displayFields: ProductionDisplayFields;
|
||||||
}) {
|
}) {
|
||||||
// API 응답은 flat 구조 (details 배열 아님)
|
// API 응답은 flat 구조 (details 배열 아님)
|
||||||
const itemName = (wi as any).item_name || "-";
|
const itemName = (wi as any).item_name || "-";
|
||||||
@@ -373,36 +391,28 @@ function WorkCard({
|
|||||||
const currentStep = steps.find((s) => s.status !== "completed");
|
const currentStep = steps.find((s) => s.status !== "completed");
|
||||||
|
|
||||||
// 프로그레스바 색상
|
// 프로그레스바 색상
|
||||||
const barColor =
|
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
|
||||||
progressPercent >= 100
|
|
||||||
? "bg-emerald-500"
|
|
||||||
: progressPercent >= 50
|
|
||||||
? "bg-blue-500"
|
|
||||||
: "bg-amber-500";
|
|
||||||
|
|
||||||
// 상태 배지 스타일
|
// 상태 배지 스타일
|
||||||
const statusBadge: Record<string, string> = {
|
const statusBadge: Record<string, string> = {
|
||||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-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-blue-500/10 text-blue-500 border-blue-500/30",
|
||||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
완료: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
const isUrgent = wi.status === "긴급";
|
const isUrgent = wi.status === "긴급";
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-sm text-foreground">
|
{df.workInstructionNo && (
|
||||||
{wi.work_instruction_no}
|
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
|
||||||
</span>
|
)}
|
||||||
{isUrgent && (
|
{df.priority && isUrgent && (
|
||||||
<Badge
|
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
|
||||||
variant="outline"
|
<AlertTriangle className="h-3 w-3" />
|
||||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="w-3 h-3" />
|
|
||||||
긴급
|
긴급
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -413,82 +423,88 @@ function WorkCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카드 본문 - 정보 */}
|
{/* 카드 본문 - 정보 */}
|
||||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
|
||||||
<InfoRow label="품목명" value={itemName} />
|
{df.itemName && <InfoRow label="품목명" value={itemName} />}
|
||||||
<InfoRow label="규격" value={spec} />
|
{df.spec && <InfoRow label="규격" value={spec} />}
|
||||||
<InfoRow label="거래처" value={customerName} />
|
{df.customerName && <InfoRow label="거래처" value={customerName} />}
|
||||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
|
||||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
|
||||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
{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>
|
||||||
|
|
||||||
{/* 공정현황 */}
|
{/* 공정현황 */}
|
||||||
<div className="px-4 py-3 border-b">
|
{df.processProgress && (
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="border-b px-4 py-3">
|
||||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
{steps.length > 0 && (
|
<span className="text-muted-foreground text-xs font-medium">공정현황</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
{steps.length > 0 && (
|
||||||
완료 {completedSteps}/{steps.length}
|
<span className="text-muted-foreground text-xs">
|
||||||
{currentStep && (
|
완료 {completedSteps}/{steps.length}
|
||||||
<span>
|
{currentStep && (
|
||||||
{" "}
|
<span>
|
||||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
{" "}
|
||||||
</span>
|
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||||
)}
|
</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>
|
</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">
|
{df.progressBar && (
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="px-4 py-3">
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
{completedQty} / {totalQty}
|
<span className="text-muted-foreground text-xs">
|
||||||
</span>
|
{completedQty} / {totalQty}
|
||||||
<span
|
</span>
|
||||||
className={cn(
|
<span
|
||||||
"text-xs font-bold",
|
className={cn(
|
||||||
progressPercent >= 100
|
"text-xs font-bold",
|
||||||
? "text-emerald-500"
|
progressPercent >= 100
|
||||||
: progressPercent >= 50
|
? "text-emerald-500"
|
||||||
? "text-blue-500"
|
: progressPercent >= 50
|
||||||
: "text-amber-500"
|
? "text-blue-500"
|
||||||
)}
|
: "text-amber-500",
|
||||||
>
|
)}
|
||||||
{progressPercent}%
|
>
|
||||||
</span>
|
{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>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -496,7 +512,7 @@ function WorkCard({
|
|||||||
// ─── 정보 행 ───────────────────────────────────────────────
|
// ─── 정보 행 ───────────────────────────────────────────────
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
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-muted-foreground shrink-0">{label}:</span>
|
||||||
<span className="text-foreground truncate">{value}</span>
|
<span className="text-foreground truncate">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,23 +4,12 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"
|
|||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||||
RefreshCw,
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||||
Clock,
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
Loader2,
|
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
||||||
Inbox,
|
|
||||||
Search,
|
|
||||||
ClipboardCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
/* ───── 타입 ───── */
|
/* ───── 타입 ───── */
|
||||||
interface ProcessRow {
|
interface ProcessRow {
|
||||||
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
|
|||||||
|
|
||||||
/* ───── 유틸 ───── */
|
/* ───── 유틸 ───── */
|
||||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||||
const pct = (n: number) =>
|
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||||
`${n.toFixed(1)}%`;
|
|
||||||
|
|
||||||
const badgeVariant = (
|
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
|
||||||
type: "result" | "type" | "defectRate",
|
|
||||||
value: string | number,
|
|
||||||
) => {
|
|
||||||
if (type === "result") {
|
if (type === "result") {
|
||||||
if (value === "합격")
|
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
|
||||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||||
}
|
}
|
||||||
if (type === "type") {
|
if (type === "type") {
|
||||||
if (value === "공정검사")
|
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
|
||||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
|
||||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
}
|
}
|
||||||
@@ -93,10 +76,15 @@ const badgeVariant = (
|
|||||||
|
|
||||||
/* ───── 컴포넌트 ───── */
|
/* ───── 컴포넌트 ───── */
|
||||||
export default function QualityMonitoringPage() {
|
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 [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
|
|||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.post(
|
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||||
"/table-management/tables/work_order_process/data",
|
|
||||||
{ autoFilter: true },
|
|
||||||
);
|
|
||||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||||
setProcessData(rows);
|
setProcessData(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
|
|||||||
/* ───── 자동 갱신 ───── */
|
/* ───── 자동 갱신 ───── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRefresh) {
|
if (autoRefresh) {
|
||||||
intervalRef.current = setInterval(fetchData, 30_000);
|
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
};
|
};
|
||||||
}, [autoRefresh, fetchData]);
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
/* ───── 검사 행 변환 ───── */
|
/* ───── 검사 행 변환 ───── */
|
||||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||||
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
|
|||||||
const goodQty = r.good_qty ?? 0;
|
const goodQty = r.good_qty ?? 0;
|
||||||
const defectQty = r.defect_qty ?? 0;
|
const defectQty = r.defect_qty ?? 0;
|
||||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||||
const result: InspectionRow["result"] =
|
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
|
||||||
r.status !== "completed"
|
|
||||||
? "대기"
|
|
||||||
: defectQty > 0
|
|
||||||
? "불합격"
|
|
||||||
: "합격";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
no: idx + 1,
|
no: idx + 1,
|
||||||
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
|
|||||||
|
|
||||||
/* ───── 렌더링 ───── */
|
/* ───── 렌더링 ───── */
|
||||||
return (
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
||||||
품질점검현황{" "}
|
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
||||||
<span className="text-emerald-600">모니터링</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{currentTime.toLocaleString("ko-KR", {
|
{currentTime.toLocaleString("ko-KR", {
|
||||||
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||||
variant="outline"
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
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>
|
<span className="ml-1">새로고침</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={autoRefresh ? "default" : "outline"}
|
variant={autoRefresh ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setAutoRefresh((p) => !p)}
|
onClick={() => setAutoRefresh((p) => !p)}
|
||||||
className={cn(
|
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
|
||||||
autoRefresh &&
|
|
||||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Clock className="h-4 w-4 mr-1" />
|
<Clock className="mr-1 h-4 w-4" />
|
||||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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>
|
</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">
|
<div className="grid grid-cols-5 gap-4">
|
||||||
{summaryCards.map((card) => (
|
{summaryCards.map((card) => (
|
||||||
<div
|
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||||
key={card.label}
|
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||||
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)}>
|
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||||
{card.value}
|
{card.value}
|
||||||
{card.sub && (
|
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
|
||||||
<span className="ml-1 text-base font-normal text-white/70">
|
|
||||||
{card.sub}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
|
|||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={cn(
|
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
|
activeTab === tab.key
|
||||||
? "bg-emerald-600 text-white shadow"
|
? "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}
|
{tab.label}
|
||||||
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
|
|||||||
</div>
|
</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">
|
<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-lg font-medium">준비중</p>
|
||||||
<p className="text-sm mt-1">
|
<p className="mt-1 text-sm">
|
||||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
||||||
아직 지원되지 않습니다.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : loading && filteredRows.length === 0 ? (
|
) : loading && filteredRows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
<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>
|
<p>데이터를 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredRows.length === 0 ? (
|
) : filteredRows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
<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>
|
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<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="w-[50px] text-center">No</TableHead>
|
||||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
||||||
<TableHead className="min-w-[90px] text-center">
|
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
||||||
검사유형
|
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
||||||
</TableHead>
|
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
||||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
||||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
||||||
검사수량
|
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
||||||
</TableHead>
|
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
||||||
합격수량
|
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
||||||
</TableHead>
|
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
||||||
불합격수량
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[70px] text-right">
|
|
||||||
불량율
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[160px] text-center">
|
|
||||||
검사결과
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[70px] text-center">
|
|
||||||
판정
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[80px] text-center">
|
|
||||||
검사자
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
|
||||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredRows.map((row) => {
|
{filteredRows.map((row) => {
|
||||||
const goodPct =
|
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
||||||
row.inspectionQty > 0
|
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
||||||
? (row.goodQty / row.inspectionQty) * 100
|
|
||||||
: 0;
|
|
||||||
const defectPct =
|
|
||||||
row.inspectionQty > 0
|
|
||||||
? (row.defectQty / row.inspectionQty) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow key={row.no} className={theme.tableRowHover}>
|
||||||
key={row.no}
|
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
||||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
||||||
>
|
{tc.inspectionType && (
|
||||||
<TableCell className="text-center text-sm text-gray-500">
|
<TableCell className="text-center">
|
||||||
{row.no}
|
<Badge
|
||||||
</TableCell>
|
variant="outline"
|
||||||
<TableCell className="font-mono text-sm">
|
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
||||||
{row.inspectionNo}
|
>
|
||||||
</TableCell>
|
{row.inspectionType}
|
||||||
<TableCell className="text-center">
|
</Badge>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant="outline"
|
)}
|
||||||
className={cn(
|
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
||||||
"text-xs",
|
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
||||||
badgeVariant("type", row.inspectionType),
|
{tc.inspectionQty && (
|
||||||
)}
|
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
||||||
>
|
)}
|
||||||
{row.inspectionType}
|
{tc.passFailQty && (
|
||||||
</Badge>
|
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm font-medium">
|
{tc.passFailQty && (
|
||||||
{row.itemName}
|
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm text-gray-500">
|
{tc.defectRate && (
|
||||||
{row.spec}
|
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
||||||
</TableCell>
|
{pct(row.defectRate)}
|
||||||
<TableCell className="text-right text-sm">
|
</TableCell>
|
||||||
{fmt(row.inspectionQty)}
|
)}
|
||||||
</TableCell>
|
{tc.resultBar && (
|
||||||
<TableCell className="text-right text-sm text-emerald-600">
|
<TableCell>
|
||||||
{fmt(row.goodQty)}
|
<div className="flex items-center gap-2">
|
||||||
</TableCell>
|
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
||||||
<TableCell className="text-right text-sm text-red-600">
|
<div
|
||||||
{fmt(row.defectQty)}
|
className="h-full bg-emerald-500 transition-all"
|
||||||
</TableCell>
|
style={{ width: `${goodPct}%` }}
|
||||||
<TableCell
|
/>
|
||||||
className={cn(
|
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
||||||
"text-right text-sm",
|
</div>
|
||||||
badgeVariant("defectRate", row.defectRate),
|
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
||||||
)}
|
{pct(goodPct)}
|
||||||
>
|
</span>
|
||||||
{pct(row.defectRate)}
|
|
||||||
</TableCell>
|
|
||||||
{/* 검사결과 프로그레스바 */}
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
|
||||||
<div
|
|
||||||
className="h-full bg-emerald-500 transition-all"
|
|
||||||
style={{ width: `${goodPct}%` }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="h-full bg-red-500 transition-all"
|
|
||||||
style={{ width: `${defectPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
</TableCell>
|
||||||
{pct(goodPct)}
|
)}
|
||||||
</span>
|
{tc.judgment && (
|
||||||
</div>
|
<TableCell className="text-center">
|
||||||
</TableCell>
|
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
||||||
{/* 판정 배지 */}
|
{row.result}
|
||||||
<TableCell className="text-center">
|
</Badge>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant="outline"
|
)}
|
||||||
className={cn(
|
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
||||||
"text-xs",
|
{tc.inspectedAt && (
|
||||||
badgeVariant("result", row.result),
|
<TableCell className={cn("text-sm", theme.mutedText)}>
|
||||||
)}
|
{row.inspectedAt !== "-"
|
||||||
>
|
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
||||||
{row.result}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center text-sm">
|
|
||||||
{row.inspectorName}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-gray-500">
|
|
||||||
{row.inspectedAt !== "-"
|
|
||||||
? new Date(row.inspectedAt).toLocaleString(
|
|
||||||
"ko-KR",
|
|
||||||
{
|
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
},
|
})
|
||||||
)
|
: "-"}
|
||||||
: "-"}
|
</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm text-gray-400">
|
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
||||||
{row.remark || "-"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -12,16 +12,10 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||||
RefreshCw,
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||||
Clock,
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
Loader2,
|
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
|
||||||
Inbox,
|
|
||||||
Wrench,
|
|
||||||
Zap,
|
|
||||||
Pause,
|
|
||||||
Power,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
/* ───── 상태 정의 ───── */
|
/* ───── 상태 정의 ───── */
|
||||||
|
|
||||||
@@ -134,11 +128,16 @@ interface WorkInstruction {
|
|||||||
/* ───── 컴포넌트 ───── */
|
/* ───── 컴포넌트 ───── */
|
||||||
|
|
||||||
export default function EquipmentMonitoringPage() {
|
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 [equipments, setEquipments] = useState<Equipment[]>([]);
|
||||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
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 [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||||
const autoRefreshRef = useRef(autoRefresh);
|
const autoRefreshRef = useRef(autoRefresh);
|
||||||
|
|
||||||
@@ -182,13 +181,13 @@ export default function EquipmentMonitoringPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
/* ── 자동 갱신 (30초) ── */
|
/* ── 자동 갱신 ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (autoRefreshRef.current) fetchData();
|
if (autoRefreshRef.current) fetchData();
|
||||||
}, 30000);
|
}, settings.refreshInterval * 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchData]);
|
}, [fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
/* ── 요약 통계 ── */
|
/* ── 요약 통계 ── */
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -293,11 +292,11 @@ export default function EquipmentMonitoringPage() {
|
|||||||
|
|
||||||
/* ── 필터 pill ── */
|
/* ── 필터 pill ── */
|
||||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
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: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
|
||||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-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-300 hover:bg-amber-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-300 hover:bg-red-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-300 hover:bg-gray-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 (
|
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">
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-8 w-1.5 rounded-full bg-amber-400" />
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">{formatDate(currentTime)}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 자동갱신 토글 */}
|
{/* 자동갱신 토글 */}
|
||||||
@@ -330,51 +335,56 @@ export default function EquipmentMonitoringPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-gray-700 text-xs gap-1.5",
|
"gap-1.5 text-xs",
|
||||||
autoRefresh
|
autoRefresh
|
||||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
|
||||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={() => setAutoRefresh((v) => !v)}
|
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"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
className="gap-1.5"
|
||||||
onClick={fetchData}
|
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
<Settings2 className="h-4 w-4" />
|
||||||
새로고침
|
설정
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── 요약 카드 5개 ── */}
|
{/* ── 요약 카드 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) => (
|
{summaryCards.map((card) => (
|
||||||
<button
|
<button
|
||||||
key={card.label}
|
key={card.label}
|
||||||
onClick={() =>
|
onClick={() => setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))}
|
||||||
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
|
|
||||||
}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
||||||
card.bg,
|
card.bg,
|
||||||
card.border,
|
card.border,
|
||||||
"hover:shadow-lg"
|
"hover:shadow-lg",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
||||||
{card.icon}
|
{card.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<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>
|
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -390,40 +400,35 @@ export default function EquipmentMonitoringPage() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||||
filterStatus === pill.value
|
filterStatus === pill.value
|
||||||
? cn(pill.color, "ring-1 ring-white/20")
|
? cn(pill.color, "ring-1 ring-foreground/10")
|
||||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pill.label}
|
{pill.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<span className="ml-auto text-sm text-gray-600 self-center">
|
<span className="text-muted-foreground ml-auto self-center text-sm">{filteredEquipments.length}대 표시</span>
|
||||||
{filteredEquipments.length}대 표시
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 로딩 ── */}
|
{/* ── 로딩 ── */}
|
||||||
{loading && equipments.length === 0 && (
|
{loading && equipments.length === 0 && (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-gray-600" />
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
<span className="text-muted-foreground ml-3">설비 데이터를 불러오는 중...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 데이터 없음 ── */}
|
{/* ── 데이터 없음 ── */}
|
||||||
{!loading && equipments.length === 0 && (
|
{!loading && equipments.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-gray-600">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Inbox className="h-12 w-12 mb-3" />
|
<Inbox className="mb-3 h-12 w-12" />
|
||||||
<p className="text-lg">등록된 설비가 없습니다.</p>
|
<p className="text-lg">등록된 설비가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 설비 카드 그리드 ── */}
|
{/* ── 설비 카드 그리드 ── */}
|
||||||
{filteredEquipments.length > 0 && (
|
{filteredEquipments.length > 0 && (
|
||||||
<div
|
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}>
|
||||||
className="grid gap-4"
|
|
||||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
|
|
||||||
>
|
|
||||||
{filteredEquipments.map((eq) => {
|
{filteredEquipments.map((eq) => {
|
||||||
const status = resolveStatus(eq.operation_status);
|
const status = resolveStatus(eq.operation_status);
|
||||||
const cfg = STATUS_MAP[status];
|
const cfg = STATUS_MAP[status];
|
||||||
@@ -434,129 +439,139 @@ export default function EquipmentMonitoringPage() {
|
|||||||
<div
|
<div
|
||||||
key={eq.id}
|
key={eq.id}
|
||||||
className={cn(
|
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.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="flex items-start justify-between px-4 pt-3 pb-2 pl-5">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-base font-semibold text-white truncate">
|
{df.equipmentName && (
|
||||||
{eq.equipment_name || "이름 없음"}
|
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
|
||||||
</h3>
|
{eq.equipment_name || "이름 없음"}
|
||||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
</h3>
|
||||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
)}
|
||||||
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
{df.operationStatus && (
|
||||||
className={cn(
|
<Badge
|
||||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
className={cn("ml-2 shrink-0 gap-1 border-0 text-xs font-medium", cfg.badgeBg, cfg.badgeText)}
|
||||||
cfg.badgeBg,
|
>
|
||||||
cfg.badgeText
|
{cfg.icon}
|
||||||
)}
|
{cfg.label}
|
||||||
>
|
</Badge>
|
||||||
{cfg.icon}
|
|
||||||
{cfg.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 정보 그리드 */}
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
|
||||||
<p className="text-white font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">생산수량</span>
|
|
||||||
<p className="text-white font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">작업자</span>
|
|
||||||
<p className="text-white font-medium">
|
|
||||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
|
||||||
? eqWIs[0].worker_name
|
|
||||||
: "-"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 text-xs">설비코드</span>
|
|
||||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 가동률 프로그레스 */}
|
|
||||||
<div className="px-4 pl-5 py-2.5">
|
|
||||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
|
||||||
<span className="text-gray-500">가동률</span>
|
|
||||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
|
||||||
{utilization !== null ? `${utilization}%` : "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
|
||||||
{utilization !== null && (
|
|
||||||
<div
|
|
||||||
className={cn("h-full rounded-full transition-all duration-700", cfg.bar)}
|
|
||||||
style={{ width: `${utilization}%` }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
|
||||||
|
|
||||||
{/* 현재 작업지시 */}
|
|
||||||
<div className="px-4 pl-5 py-2.5">
|
|
||||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</p>
|
|
||||||
{eqWIs.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{eqWIs.slice(0, 2).map((wi) => (
|
|
||||||
<div key={wi.id} className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-blue-400 font-mono text-xs shrink-0">
|
|
||||||
{wi.instruction_number || "-"}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-300 truncate">
|
|
||||||
{wi.item_name || "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{eqWIs.length > 2 && (
|
|
||||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 구분선 */}
|
{/* 구분선 */}
|
||||||
<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="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
{df.dailyOperationTime && (
|
||||||
<span className="text-gray-600">온도</span>
|
<div>
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
<span className={cn("text-xs", theme.mutedText)}>금일 가동시간</span>
|
||||||
</div>
|
<p className={cn("font-medium", theme.text)}>-</p>
|
||||||
<div className="flex items-center gap-1.5">
|
</div>
|
||||||
<span className="text-gray-600">압력</span>
|
)}
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
{df.dailyProductionQty && (
|
||||||
</div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5">
|
<span className={cn("text-xs", theme.mutedText)}>생산수량</span>
|
||||||
<span className="text-gray-600">RPM</span>
|
<p className={cn("font-medium", theme.text)}>-</p>
|
||||||
<span className="text-gray-500 font-mono">-</span>
|
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -565,8 +580,8 @@ export default function EquipmentMonitoringPage() {
|
|||||||
|
|
||||||
{/* 필터 결과 없음 */}
|
{/* 필터 결과 없음 */}
|
||||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
|
||||||
<Inbox className="h-10 w-10 mb-2" />
|
<Inbox className="mb-2 h-10 w-10" />
|
||||||
<p>해당 상태의 설비가 없습니다.</p>
|
<p>해당 상태의 설비가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -16,6 +20,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||||
@@ -71,10 +76,7 @@ function formatTime(date: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 작업지시별 공정현황으로 진행상태 계산
|
// 작업지시별 공정현황으로 진행상태 계산
|
||||||
function computeProgress(
|
function computeProgress(wiId: string, processMap: Map<string, ProcessStep[]>): "대기" | "진행중" | "완료" {
|
||||||
wiId: string,
|
|
||||||
processMap: Map<string, ProcessStep[]>
|
|
||||||
): "대기" | "진행중" | "완료" {
|
|
||||||
const steps = processMap.get(wiId);
|
const steps = processMap.get(wiId);
|
||||||
if (!steps || steps.length === 0) return "대기";
|
if (!steps || steps.length === 0) return "대기";
|
||||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||||
@@ -85,11 +87,15 @@ function computeProgress(
|
|||||||
|
|
||||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||||
export default function ProductionMonitoringPage() {
|
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 [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||||
|
|
||||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||||
@@ -105,8 +111,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
// 작업지시 목록 조회
|
// 작업지시 목록 조회
|
||||||
const wiRes = await apiClient.get("/work-instruction/list");
|
const wiRes = await apiClient.get("/work-instruction/list");
|
||||||
const wiRaw: WorkInstruction[] =
|
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
|
||||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const wiData = wiRaw.filter((wi) => {
|
const wiData = wiRaw.filter((wi) => {
|
||||||
@@ -120,7 +125,9 @@ export default function ProductionMonitoringPage() {
|
|||||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||||
try {
|
try {
|
||||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
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 || [];
|
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||||
|
|
||||||
@@ -162,12 +169,12 @@ export default function ProductionMonitoringPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
// ─── 자동갱신 ────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh) return;
|
if (!autoRefresh) return;
|
||||||
const timer = setInterval(fetchData, 30000);
|
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [autoRefresh, fetchData]);
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
// ─── 통계 계산 ───────────────────────────────────────────
|
// ─── 통계 계산 ───────────────────────────────────────────
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -202,23 +209,17 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
// ─── 렌더링 ──────────────────────────────────────────────
|
// ─── 렌더링 ──────────────────────────────────────────────
|
||||||
return (
|
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">
|
<div className="flex flex-shrink-0 items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
|
||||||
variant="outline"
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||||
size="sm"
|
|
||||||
onClick={fetchData}
|
|
||||||
disabled={loading}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -227,34 +228,43 @@ export default function ProductionMonitoringPage() {
|
|||||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
className="gap-1.5"
|
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"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 요약 카드 */}
|
{/* 요약 카드 */}
|
||||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Timer className="w-5 h-5" />}
|
icon={<Timer className="h-5 w-5" />}
|
||||||
label="대기중"
|
label="대기중"
|
||||||
value={stats.waiting}
|
value={stats.waiting}
|
||||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Loader2 className="w-5 h-5" />}
|
icon={<Loader2 className="h-5 w-5" />}
|
||||||
label="진행중"
|
label="진행중"
|
||||||
value={stats.inProgress}
|
value={stats.inProgress}
|
||||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
label="완료"
|
label="완료"
|
||||||
value={stats.completed}
|
value={stats.completed}
|
||||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<TrendingUp className="w-5 h-5" />}
|
icon={<TrendingUp className="h-5 w-5" />}
|
||||||
label="달성율"
|
label="달성율"
|
||||||
value={`${stats.achievementRate}%`}
|
value={`${stats.achievementRate}%`}
|
||||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||||
@@ -262,7 +272,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
</div>
|
</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) => (
|
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||||
<Button
|
<Button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -271,9 +281,9 @@ export default function ProductionMonitoringPage() {
|
|||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[64px]",
|
"min-w-[64px]",
|
||||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
|
||||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
|
||||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
@@ -287,29 +297,34 @@ export default function ProductionMonitoringPage() {
|
|||||||
|
|
||||||
{/* 로딩 상태 */}
|
{/* 로딩 상태 */}
|
||||||
{loading && workInstructions.length === 0 && (
|
{loading && workInstructions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
|
||||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 빈 상태 */}
|
{/* 빈 상태 */}
|
||||||
{!loading && filteredInstructions.length === 0 && (
|
{!loading && filteredInstructions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||||
<Inbox className="w-12 h-12 mb-3" />
|
<Inbox className="mb-3 h-12 w-12" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{activeTab === "전체"
|
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||||
? "등록된 작업지시가 없습니다."
|
|
||||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 작업 카드 그리드 */}
|
{/* 작업 카드 */}
|
||||||
{filteredInstructions.length > 0 && (
|
{filteredInstructions.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="grid gap-4 flex-1"
|
className={cn(
|
||||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
"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) => (
|
{filteredInstructions.map((wi, idx) => (
|
||||||
<WorkCard
|
<WorkCard
|
||||||
@@ -317,6 +332,7 @@ export default function ProductionMonitoringPage() {
|
|||||||
instruction={wi}
|
instruction={wi}
|
||||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||||
|
displayFields={settings.displayFields}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -338,11 +354,11 @@ function SummaryCard({
|
|||||||
colorClass: string;
|
colorClass: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
|
||||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
<span className="text-muted-foreground text-xs">{label}</span>
|
||||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
<span className="text-foreground text-2xl font-bold">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -353,10 +369,12 @@ function WorkCard({
|
|||||||
instruction: wi,
|
instruction: wi,
|
||||||
steps,
|
steps,
|
||||||
progress,
|
progress,
|
||||||
|
displayFields: df,
|
||||||
}: {
|
}: {
|
||||||
instruction: WorkInstruction;
|
instruction: WorkInstruction;
|
||||||
steps: ProcessStep[];
|
steps: ProcessStep[];
|
||||||
progress: "대기" | "진행중" | "완료";
|
progress: "대기" | "진행중" | "완료";
|
||||||
|
displayFields: ProductionDisplayFields;
|
||||||
}) {
|
}) {
|
||||||
// API 응답은 flat 구조 (details 배열 아님)
|
// API 응답은 flat 구조 (details 배열 아님)
|
||||||
const itemName = (wi as any).item_name || "-";
|
const itemName = (wi as any).item_name || "-";
|
||||||
@@ -373,36 +391,28 @@ function WorkCard({
|
|||||||
const currentStep = steps.find((s) => s.status !== "completed");
|
const currentStep = steps.find((s) => s.status !== "completed");
|
||||||
|
|
||||||
// 프로그레스바 색상
|
// 프로그레스바 색상
|
||||||
const barColor =
|
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
|
||||||
progressPercent >= 100
|
|
||||||
? "bg-emerald-500"
|
|
||||||
: progressPercent >= 50
|
|
||||||
? "bg-blue-500"
|
|
||||||
: "bg-amber-500";
|
|
||||||
|
|
||||||
// 상태 배지 스타일
|
// 상태 배지 스타일
|
||||||
const statusBadge: Record<string, string> = {
|
const statusBadge: Record<string, string> = {
|
||||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-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-blue-500/10 text-blue-500 border-blue-500/30",
|
||||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
완료: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
const isUrgent = wi.status === "긴급";
|
const isUrgent = wi.status === "긴급";
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-sm text-foreground">
|
{df.workInstructionNo && (
|
||||||
{wi.work_instruction_no}
|
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
|
||||||
</span>
|
)}
|
||||||
{isUrgent && (
|
{df.priority && isUrgent && (
|
||||||
<Badge
|
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
|
||||||
variant="outline"
|
<AlertTriangle className="h-3 w-3" />
|
||||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="w-3 h-3" />
|
|
||||||
긴급
|
긴급
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -413,82 +423,88 @@ function WorkCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카드 본문 - 정보 */}
|
{/* 카드 본문 - 정보 */}
|
||||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
|
||||||
<InfoRow label="품목명" value={itemName} />
|
{df.itemName && <InfoRow label="품목명" value={itemName} />}
|
||||||
<InfoRow label="규격" value={spec} />
|
{df.spec && <InfoRow label="규격" value={spec} />}
|
||||||
<InfoRow label="거래처" value={customerName} />
|
{df.customerName && <InfoRow label="거래처" value={customerName} />}
|
||||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
|
||||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
|
||||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
{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>
|
||||||
|
|
||||||
{/* 공정현황 */}
|
{/* 공정현황 */}
|
||||||
<div className="px-4 py-3 border-b">
|
{df.processProgress && (
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="border-b px-4 py-3">
|
||||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
{steps.length > 0 && (
|
<span className="text-muted-foreground text-xs font-medium">공정현황</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
{steps.length > 0 && (
|
||||||
완료 {completedSteps}/{steps.length}
|
<span className="text-muted-foreground text-xs">
|
||||||
{currentStep && (
|
완료 {completedSteps}/{steps.length}
|
||||||
<span>
|
{currentStep && (
|
||||||
{" "}
|
<span>
|
||||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
{" "}
|
||||||
</span>
|
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||||
)}
|
</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>
|
</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">
|
{df.progressBar && (
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="px-4 py-3">
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
{completedQty} / {totalQty}
|
<span className="text-muted-foreground text-xs">
|
||||||
</span>
|
{completedQty} / {totalQty}
|
||||||
<span
|
</span>
|
||||||
className={cn(
|
<span
|
||||||
"text-xs font-bold",
|
className={cn(
|
||||||
progressPercent >= 100
|
"text-xs font-bold",
|
||||||
? "text-emerald-500"
|
progressPercent >= 100
|
||||||
: progressPercent >= 50
|
? "text-emerald-500"
|
||||||
? "text-blue-500"
|
: progressPercent >= 50
|
||||||
: "text-amber-500"
|
? "text-blue-500"
|
||||||
)}
|
: "text-amber-500",
|
||||||
>
|
)}
|
||||||
{progressPercent}%
|
>
|
||||||
</span>
|
{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>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -496,7 +512,7 @@ function WorkCard({
|
|||||||
// ─── 정보 행 ───────────────────────────────────────────────
|
// ─── 정보 행 ───────────────────────────────────────────────
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
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-muted-foreground shrink-0">{label}:</span>
|
||||||
<span className="text-foreground truncate">{value}</span>
|
<span className="text-foreground truncate">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,23 +4,12 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"
|
|||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
|
||||||
RefreshCw,
|
import { getMonitoringTheme } from "@/lib/monitoringTheme";
|
||||||
Clock,
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
Loader2,
|
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
|
||||||
Inbox,
|
|
||||||
Search,
|
|
||||||
ClipboardCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
/* ───── 타입 ───── */
|
/* ───── 타입 ───── */
|
||||||
interface ProcessRow {
|
interface ProcessRow {
|
||||||
@@ -65,22 +54,16 @@ type TabKey = (typeof TABS)[number]["key"];
|
|||||||
|
|
||||||
/* ───── 유틸 ───── */
|
/* ───── 유틸 ───── */
|
||||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||||
const pct = (n: number) =>
|
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||||
`${n.toFixed(1)}%`;
|
|
||||||
|
|
||||||
const badgeVariant = (
|
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
|
||||||
type: "result" | "type" | "defectRate",
|
|
||||||
value: string | number,
|
|
||||||
) => {
|
|
||||||
if (type === "result") {
|
if (type === "result") {
|
||||||
if (value === "합격")
|
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
|
||||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||||
}
|
}
|
||||||
if (type === "type") {
|
if (type === "type") {
|
||||||
if (value === "공정검사")
|
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
|
||||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
|
||||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
}
|
}
|
||||||
@@ -93,10 +76,15 @@ const badgeVariant = (
|
|||||||
|
|
||||||
/* ───── 컴포넌트 ───── */
|
/* ───── 컴포넌트 ───── */
|
||||||
export default function QualityMonitoringPage() {
|
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 [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
@@ -110,10 +98,7 @@ export default function QualityMonitoringPage() {
|
|||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.post(
|
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||||
"/table-management/tables/work_order_process/data",
|
|
||||||
{ autoFilter: true },
|
|
||||||
);
|
|
||||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||||
setProcessData(rows);
|
setProcessData(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -130,12 +115,12 @@ export default function QualityMonitoringPage() {
|
|||||||
/* ───── 자동 갱신 ───── */
|
/* ───── 자동 갱신 ───── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRefresh) {
|
if (autoRefresh) {
|
||||||
intervalRef.current = setInterval(fetchData, 30_000);
|
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
};
|
};
|
||||||
}, [autoRefresh, fetchData]);
|
}, [autoRefresh, fetchData, settings.refreshInterval]);
|
||||||
|
|
||||||
/* ───── 검사 행 변환 ───── */
|
/* ───── 검사 행 변환 ───── */
|
||||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||||
@@ -152,12 +137,7 @@ export default function QualityMonitoringPage() {
|
|||||||
const goodQty = r.good_qty ?? 0;
|
const goodQty = r.good_qty ?? 0;
|
||||||
const defectQty = r.defect_qty ?? 0;
|
const defectQty = r.defect_qty ?? 0;
|
||||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||||
const result: InspectionRow["result"] =
|
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
|
||||||
r.status !== "completed"
|
|
||||||
? "대기"
|
|
||||||
: defectQty > 0
|
|
||||||
? "불합격"
|
|
||||||
: "합격";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
no: idx + 1,
|
no: idx + 1,
|
||||||
@@ -235,18 +215,17 @@ export default function QualityMonitoringPage() {
|
|||||||
|
|
||||||
/* ───── 렌더링 ───── */
|
/* ───── 렌더링 ───── */
|
||||||
return (
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
<h1 className={cn("text-xl font-bold", theme.headerText)}>
|
||||||
품질점검현황{" "}
|
품질점검현황 <span className="text-emerald-600">모니터링</span>
|
||||||
<span className="text-emerald-600">모니터링</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{currentTime.toLocaleString("ko-KR", {
|
{currentTime.toLocaleString("ko-KR", {
|
||||||
@@ -260,56 +239,41 @@ export default function QualityMonitoringPage() {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||||
variant="outline"
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
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>
|
<span className="ml-1">새로고침</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={autoRefresh ? "default" : "outline"}
|
variant={autoRefresh ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setAutoRefresh((p) => !p)}
|
onClick={() => setAutoRefresh((p) => !p)}
|
||||||
className={cn(
|
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
|
||||||
autoRefresh &&
|
|
||||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Clock className="h-4 w-4 mr-1" />
|
<Clock className="mr-1 h-4 w-4" />
|
||||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||||
</Button>
|
</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>
|
</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">
|
<div className="grid grid-cols-5 gap-4">
|
||||||
{summaryCards.map((card) => (
|
{summaryCards.map((card) => (
|
||||||
<div
|
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||||
key={card.label}
|
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||||
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)}>
|
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||||
{card.value}
|
{card.value}
|
||||||
{card.sub && (
|
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
|
||||||
<span className="ml-1 text-base font-normal text-white/70">
|
|
||||||
{card.sub}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -322,10 +286,10 @@ export default function QualityMonitoringPage() {
|
|||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={cn(
|
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
|
activeTab === tab.key
|
||||||
? "bg-emerald-600 text-white shadow"
|
? "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}
|
{tab.label}
|
||||||
@@ -334,170 +298,120 @@ export default function QualityMonitoringPage() {
|
|||||||
</div>
|
</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">
|
<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-lg font-medium">준비중</p>
|
||||||
<p className="text-sm mt-1">
|
<p className="mt-1 text-sm">
|
||||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 아직 지원되지 않습니다.
|
||||||
아직 지원되지 않습니다.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : loading && filteredRows.length === 0 ? (
|
) : loading && filteredRows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
<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>
|
<p>데이터를 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredRows.length === 0 ? (
|
) : filteredRows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
<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>
|
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<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="w-[50px] text-center">No</TableHead>
|
||||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
{tc.inspectionNo && <TableHead className="min-w-[120px]">검사번호</TableHead>}
|
||||||
<TableHead className="min-w-[90px] text-center">
|
{tc.inspectionType && <TableHead className="min-w-[90px] text-center">검사유형</TableHead>}
|
||||||
검사유형
|
{tc.itemName && <TableHead className="min-w-[140px]">품목명</TableHead>}
|
||||||
</TableHead>
|
{tc.spec && <TableHead className="min-w-[100px]">규격</TableHead>}
|
||||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right">검사수량</TableHead>}
|
||||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">합격수량</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.passFailQty && <TableHead className="min-w-[80px] text-right">불합격수량</TableHead>}
|
||||||
검사수량
|
{tc.defectRate && <TableHead className="min-w-[70px] text-right">불량율</TableHead>}
|
||||||
</TableHead>
|
{tc.resultBar && <TableHead className="min-w-[160px] text-center">검사결과</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.judgment && <TableHead className="min-w-[70px] text-center">판정</TableHead>}
|
||||||
합격수량
|
{tc.inspector && <TableHead className="min-w-[80px] text-center">검사자</TableHead>}
|
||||||
</TableHead>
|
{tc.inspectedAt && <TableHead className="min-w-[150px]">검사일시</TableHead>}
|
||||||
<TableHead className="min-w-[80px] text-right">
|
{tc.inspectionCriteria && <TableHead className="min-w-[100px]">검사기준</TableHead>}
|
||||||
불합격수량
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[70px] text-right">
|
|
||||||
불량율
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[160px] text-center">
|
|
||||||
검사결과
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[70px] text-center">
|
|
||||||
판정
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[80px] text-center">
|
|
||||||
검사자
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
|
||||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredRows.map((row) => {
|
{filteredRows.map((row) => {
|
||||||
const goodPct =
|
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
|
||||||
row.inspectionQty > 0
|
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
|
||||||
? (row.goodQty / row.inspectionQty) * 100
|
|
||||||
: 0;
|
|
||||||
const defectPct =
|
|
||||||
row.inspectionQty > 0
|
|
||||||
? (row.defectQty / row.inspectionQty) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow key={row.no} className={theme.tableRowHover}>
|
||||||
key={row.no}
|
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
|
||||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
|
||||||
>
|
{tc.inspectionType && (
|
||||||
<TableCell className="text-center text-sm text-gray-500">
|
<TableCell className="text-center">
|
||||||
{row.no}
|
<Badge
|
||||||
</TableCell>
|
variant="outline"
|
||||||
<TableCell className="font-mono text-sm">
|
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
|
||||||
{row.inspectionNo}
|
>
|
||||||
</TableCell>
|
{row.inspectionType}
|
||||||
<TableCell className="text-center">
|
</Badge>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant="outline"
|
)}
|
||||||
className={cn(
|
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
|
||||||
"text-xs",
|
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
|
||||||
badgeVariant("type", row.inspectionType),
|
{tc.inspectionQty && (
|
||||||
)}
|
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
|
||||||
>
|
)}
|
||||||
{row.inspectionType}
|
{tc.passFailQty && (
|
||||||
</Badge>
|
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm font-medium">
|
{tc.passFailQty && (
|
||||||
{row.itemName}
|
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm text-gray-500">
|
{tc.defectRate && (
|
||||||
{row.spec}
|
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
|
||||||
</TableCell>
|
{pct(row.defectRate)}
|
||||||
<TableCell className="text-right text-sm">
|
</TableCell>
|
||||||
{fmt(row.inspectionQty)}
|
)}
|
||||||
</TableCell>
|
{tc.resultBar && (
|
||||||
<TableCell className="text-right text-sm text-emerald-600">
|
<TableCell>
|
||||||
{fmt(row.goodQty)}
|
<div className="flex items-center gap-2">
|
||||||
</TableCell>
|
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
|
||||||
<TableCell className="text-right text-sm text-red-600">
|
<div
|
||||||
{fmt(row.defectQty)}
|
className="h-full bg-emerald-500 transition-all"
|
||||||
</TableCell>
|
style={{ width: `${goodPct}%` }}
|
||||||
<TableCell
|
/>
|
||||||
className={cn(
|
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
|
||||||
"text-right text-sm",
|
</div>
|
||||||
badgeVariant("defectRate", row.defectRate),
|
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
|
||||||
)}
|
{pct(goodPct)}
|
||||||
>
|
</span>
|
||||||
{pct(row.defectRate)}
|
|
||||||
</TableCell>
|
|
||||||
{/* 검사결과 프로그레스바 */}
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
|
||||||
<div
|
|
||||||
className="h-full bg-emerald-500 transition-all"
|
|
||||||
style={{ width: `${goodPct}%` }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="h-full bg-red-500 transition-all"
|
|
||||||
style={{ width: `${defectPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
</TableCell>
|
||||||
{pct(goodPct)}
|
)}
|
||||||
</span>
|
{tc.judgment && (
|
||||||
</div>
|
<TableCell className="text-center">
|
||||||
</TableCell>
|
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
|
||||||
{/* 판정 배지 */}
|
{row.result}
|
||||||
<TableCell className="text-center">
|
</Badge>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant="outline"
|
)}
|
||||||
className={cn(
|
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
|
||||||
"text-xs",
|
{tc.inspectedAt && (
|
||||||
badgeVariant("result", row.result),
|
<TableCell className={cn("text-sm", theme.mutedText)}>
|
||||||
)}
|
{row.inspectedAt !== "-"
|
||||||
>
|
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
|
||||||
{row.result}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center text-sm">
|
|
||||||
{row.inspectorName}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-gray-500">
|
|
||||||
{row.inspectedAt !== "-"
|
|
||||||
? new Date(row.inspectedAt).toLocaleString(
|
|
||||||
"ko-KR",
|
|
||||||
{
|
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
},
|
})
|
||||||
)
|
: "-"}
|
||||||
: "-"}
|
</TableCell>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm text-gray-400">
|
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
|
||||||
{row.remark || "-"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</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/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/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/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/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/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/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/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/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 }),
|
"/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/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/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/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/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/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 }),
|
"/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/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/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/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/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/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 }),
|
"/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/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/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/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/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/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 }),
|
"/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/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/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/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/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/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 }),
|
"/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/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/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/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/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/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 }),
|
"/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user