feat: 생산관리 메인 메뉴 (입고/출고와 동일 패턴)
- /pop/production → ProductionMain (아이콘 메뉴: 공정실행/작업지시/생산현황/불량관리/실적조회) - /pop/production/process → WorkOrderList (기존 공정 목록 이동) - KPI 실데이터 연동 (작업지시 목록 API) - amber gradient 테마, 최근 생산활동 - cmux 검증 완료
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { WorkOrderList } from "@/components/pop/hardcoded/production";
|
||||
import { ProductionMain } from "@/components/pop/hardcoded/production";
|
||||
|
||||
export default function ProductionPage() {
|
||||
return (
|
||||
<PopShell showBanner={false} title="생산관리" showBack>
|
||||
<WorkOrderList />
|
||||
<PopShell showBanner={false} title="생산관리">
|
||||
<ProductionMain />
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
|
||||
12
frontend/app/(pop)/pop/production/process/page.tsx
Normal file
12
frontend/app/(pop)/pop/production/process/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { WorkOrderList } from "@/components/pop/hardcoded/production";
|
||||
|
||||
export default function ProductionProcessPage() {
|
||||
return (
|
||||
<PopShell showBanner={false} title="공정실행" showBack>
|
||||
<WorkOrderList />
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
389
frontend/components/pop/hardcoded/production/ProductionMain.tsx
Normal file
389
frontend/components/pop/hardcoded/production/ProductionMain.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface ProductionMenuItem {
|
||||
id: string;
|
||||
title: string;
|
||||
gradient: string;
|
||||
shadowColor: string;
|
||||
icon: React.ReactNode;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface RecentActivityItem {
|
||||
id: string;
|
||||
time: string;
|
||||
processName: string;
|
||||
itemName: string;
|
||||
qty: string;
|
||||
statusColor: string;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
interface KpiData {
|
||||
todayCompleted: number;
|
||||
inProgressCount: number;
|
||||
completedCount: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const MENU_ITEMS: ProductionMenuItem[] = [
|
||||
{
|
||||
id: "process",
|
||||
title: "공정실행",
|
||||
gradient: "linear-gradient(135deg,#f59e0b,#d97706)",
|
||||
shadowColor: "rgba(245,158,11,.3)",
|
||||
icon: (
|
||||
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
href: "/pop/production/process",
|
||||
},
|
||||
{
|
||||
id: "work-order",
|
||||
title: "작업지시",
|
||||
gradient: "linear-gradient(135deg,#f59e0b,#d97706)",
|
||||
shadowColor: "rgba(245,158,11,.3)",
|
||||
icon: (
|
||||
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V19.5a2.25 2.25 0 002.25 2.25h.75" />
|
||||
</svg>
|
||||
),
|
||||
href: "#",
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
title: "생산현황",
|
||||
gradient: "linear-gradient(135deg,#f59e0b,#d97706)",
|
||||
shadowColor: "rgba(245,158,11,.3)",
|
||||
icon: (
|
||||
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
),
|
||||
href: "#",
|
||||
},
|
||||
{
|
||||
id: "defect",
|
||||
title: "불량관리",
|
||||
gradient: "linear-gradient(135deg,#f59e0b,#d97706)",
|
||||
shadowColor: "rgba(245,158,11,.3)",
|
||||
icon: (
|
||||
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
),
|
||||
href: "#",
|
||||
},
|
||||
{
|
||||
id: "result",
|
||||
title: "실적조회",
|
||||
gradient: "linear-gradient(135deg,#f59e0b,#d97706)",
|
||||
shadowColor: "rgba(245,158,11,.3)",
|
||||
icon: (
|
||||
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941" />
|
||||
</svg>
|
||||
),
|
||||
href: "#",
|
||||
},
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function ProductionMain() {
|
||||
const router = useRouter();
|
||||
|
||||
/* KPI carousel */
|
||||
const [kpiIdx, setKpiIdx] = useState(0);
|
||||
const kpiTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
/* Data state */
|
||||
const [kpi, setKpi] = useState<KpiData>({
|
||||
todayCompleted: 0,
|
||||
inProgressCount: 0,
|
||||
completedCount: 0,
|
||||
});
|
||||
const [recentItems, setRecentItems] = useState<RecentActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const startKpiAuto = useCallback(() => {
|
||||
if (kpiTimerRef.current) clearInterval(kpiTimerRef.current);
|
||||
kpiTimerRef.current = setInterval(() => setKpiIdx((p) => (p + 1) % 3), 4000);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
startKpiAuto();
|
||||
return () => {
|
||||
if (kpiTimerRef.current) clearInterval(kpiTimerRef.current);
|
||||
};
|
||||
}, [startKpiAuto]);
|
||||
|
||||
/* Fetch real data */
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const wiRes = await apiClient.get("/work-instruction/list");
|
||||
let wiData: any[] = [];
|
||||
if (wiRes.data?.data) {
|
||||
wiData = Array.isArray(wiRes.data.data)
|
||||
? wiRes.data.data
|
||||
: wiRes.data.data.rows || [];
|
||||
} else if (Array.isArray(wiRes.data)) {
|
||||
wiData = wiRes.data;
|
||||
}
|
||||
|
||||
// KPI 계산
|
||||
let todayCompleted = 0;
|
||||
let inProgressCount = 0;
|
||||
let completedCount = 0;
|
||||
|
||||
for (const wi of wiData) {
|
||||
const status = wi.progress_status || wi.status || "";
|
||||
if (status === "completed" || status === "완료") {
|
||||
completedCount++;
|
||||
todayCompleted += Number(wi.completed_qty) || 0;
|
||||
} else if (status === "in_progress" || status === "진행중") {
|
||||
inProgressCount++;
|
||||
}
|
||||
}
|
||||
|
||||
setKpi({ todayCompleted, inProgressCount, completedCount });
|
||||
|
||||
// 최근 활동 5건 (최신순)
|
||||
const sorted = [...wiData].sort((a, b) => {
|
||||
const da = new Date(a.start_date || a.created_date || "").getTime() || 0;
|
||||
const db = new Date(b.start_date || b.created_date || "").getTime() || 0;
|
||||
return db - da;
|
||||
});
|
||||
|
||||
const recent: RecentActivityItem[] = sorted.slice(0, 5).map((wi, idx) => {
|
||||
const dateObj = wi.start_date ? new Date(wi.start_date) : null;
|
||||
const time = dateObj
|
||||
? `${String(dateObj.getMonth() + 1).padStart(2, "0")}/${String(dateObj.getDate()).padStart(2, "0")}`
|
||||
: "--/--";
|
||||
|
||||
const status = wi.progress_status || wi.status || "";
|
||||
let statusColor = "text-gray-600 bg-gray-50";
|
||||
let statusLabel = "대기";
|
||||
if (status === "completed" || status === "완료") {
|
||||
statusColor = "text-green-600 bg-green-50";
|
||||
statusLabel = "완료";
|
||||
} else if (status === "in_progress" || status === "진행중") {
|
||||
statusColor = "text-blue-600 bg-blue-50";
|
||||
statusLabel = "진행중";
|
||||
} else if (status === "acceptable" || status === "접수가능") {
|
||||
statusColor = "text-amber-600 bg-amber-50";
|
||||
statusLabel = "접수가능";
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(wi.id || idx),
|
||||
time,
|
||||
processName: wi.routing ? "공정 있음" : "공정 미설정",
|
||||
itemName: wi.item_name || wi.item_code || wi.work_instruction_no || "-",
|
||||
qty: `${(Number(wi.qty) || 0).toLocaleString()} EA`,
|
||||
statusColor,
|
||||
statusLabel,
|
||||
};
|
||||
});
|
||||
|
||||
setRecentItems(recent);
|
||||
} catch {
|
||||
// 실패 시 0/빈 배열 유지
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleMenuClick = (item: ProductionMenuItem) => {
|
||||
if (item.href === "#") {
|
||||
alert(`${item.title} 화면은 준비 중입니다.`);
|
||||
} else {
|
||||
router.push(item.href);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* ===== Back + Title ===== */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/pop/home")}
|
||||
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">생산관리</h1>
|
||||
<p className="text-xs text-gray-400 mt-0.5">메뉴를 선택하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== KPI Carousel ===== */}
|
||||
<div className="relative overflow-hidden">
|
||||
<div
|
||||
className="flex select-none transition-transform duration-400"
|
||||
style={{ transform: `translateX(-${kpiIdx * 100}%)`, transition: "transform 0.4s cubic-bezier(.25,.46,.45,.94)" }}
|
||||
>
|
||||
{/* Slide 1 */}
|
||||
<div className="min-w-full shrink-0">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
|
||||
<div className="grid grid-cols-3 gap-0">
|
||||
<KpiCell value={loading ? "-" : kpi.todayCompleted.toLocaleString()} label="금일 생산실적" color="text-amber-600" unit="EA" />
|
||||
<KpiCell value={loading ? "-" : kpi.inProgressCount.toLocaleString()} label="진행중 공정" color="text-blue-600" />
|
||||
<KpiCell value={loading ? "-" : kpi.completedCount.toLocaleString()} label="완료 공정" color="text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Single dot (only 1 slide) */}
|
||||
<div className="flex justify-center gap-2 mt-3">
|
||||
{[0].map((idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => { setKpiIdx(idx); startKpiAuto(); }}
|
||||
className="border-none p-0 transition-all duration-300 cursor-pointer"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: "#f59e0b",
|
||||
}}
|
||||
aria-label={`KPI 슬라이드 ${idx + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Menu Icons ===== */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-1 h-5 rounded-full bg-amber-500" />
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-900">생산 메뉴</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-start gap-x-5 gap-y-4 sm:gap-x-6 sm:gap-y-5">
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<MenuIcon key={item.id} item={item} onClick={handleMenuClick} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== Recent Activity ===== */}
|
||||
<section>
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-100">
|
||||
<h3 className="text-base sm:text-lg font-bold text-gray-900">최근 생산활동</h3>
|
||||
<span className="text-xs text-gray-400">최근 5건</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3">
|
||||
<div className="w-[44px] h-4 bg-gray-100 rounded animate-pulse" />
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<div className="h-4 bg-gray-100 rounded w-3/4 animate-pulse" />
|
||||
<div className="h-3 bg-gray-50 rounded w-1/2 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : recentItems.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-gray-400">
|
||||
최근 생산활동 내역이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
recentItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span
|
||||
className="text-xs font-semibold text-gray-400 min-w-[44px] text-right"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{item.time}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900 truncate">{item.itemName}</span>
|
||||
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full shrink-0 ${item.statusColor}`}>
|
||||
{item.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5 truncate">
|
||||
{item.processName} | {item.qty}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-components */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function KpiCell({ value, label, color, unit }: { value: string; label: string; color: string; unit?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center py-2">
|
||||
<div className="flex items-end gap-0.5">
|
||||
<span
|
||||
className={`text-2xl sm:text-3xl font-extrabold leading-none ${color}`}
|
||||
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{unit && <span className="text-xs text-gray-400 mb-0.5">{unit}</span>}
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-gray-400 mt-1">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuIcon({ item, onClick }: { item: ProductionMenuItem; onClick: (item: ProductionMenuItem) => void }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-2 w-16 sm:w-[72px] cursor-pointer group"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
onClick={() => onClick(item)}
|
||||
>
|
||||
<div
|
||||
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center transition-transform duration-150 group-hover:scale-105 group-active:scale-[0.93]"
|
||||
style={{ background: item.gradient, boxShadow: `0 4px 12px ${item.shadowColor}` }}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-[11px] sm:text-xs font-semibold text-gray-700 text-center leading-tight">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { ProductionMain } from "./ProductionMain";
|
||||
export { WorkOrderList } from "./WorkOrderList";
|
||||
export { ProcessWork } from "./ProcessWork";
|
||||
export { ProcessTimer } from "./ProcessTimer";
|
||||
|
||||
Reference in New Issue
Block a user