- POP 생산: 재고 관리, 재작업 이력, BOM 자재투입 기능 추가 - POP 설정: 설정 시스템 + 관리 페이지 (/pop/admin) - POP 화면: 버그 수정 + 설정 연동 + 다음공정 활성화 수정 - PC 코드 무변경 (보류 6건: app.ts, 출고/입고/작업지시 컨트롤러, 레이아웃)
466 lines
15 KiB
TypeScript
466 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useRef, useCallback, useEffect } from "react";
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* KPI Data Types */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
interface SummaryKpi {
|
|
totalWork: number;
|
|
inProgress: number;
|
|
completed: number;
|
|
issues: number;
|
|
}
|
|
|
|
interface InOutKpi {
|
|
inboundTotal: number;
|
|
inboundCompleted: number;
|
|
inboundInProgress: number;
|
|
outboundTotal: number;
|
|
outboundCompleted: number;
|
|
outboundInProgress: number;
|
|
}
|
|
|
|
interface ProdQualityKpi {
|
|
productionTotal: number;
|
|
productionCompleted: number;
|
|
productionInProgress: number;
|
|
qualityRate: number | null; // null = no data
|
|
}
|
|
|
|
|
|
export function KpiCarousel() {
|
|
const [current, setCurrent] = useState(0);
|
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
const autoTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const startXRef = useRef(0);
|
|
const moveXRef = useRef(0);
|
|
const draggingRef = useRef(false);
|
|
|
|
/* KPI state */
|
|
const [summary, setSummary] = useState<SummaryKpi>({
|
|
totalWork: 0, inProgress: 0, completed: 0, issues: 0,
|
|
});
|
|
const [inOut, setInOut] = useState<InOutKpi>({
|
|
inboundTotal: 0, inboundCompleted: 0, inboundInProgress: 0,
|
|
outboundTotal: 0, outboundCompleted: 0, outboundInProgress: 0,
|
|
});
|
|
const [prodQuality, setProdQuality] = useState<ProdQualityKpi>({
|
|
productionTotal: 0, productionCompleted: 0, productionInProgress: 0,
|
|
qualityRate: null,
|
|
});
|
|
|
|
const total = 3;
|
|
|
|
/* Fetch real data */
|
|
useEffect(() => {
|
|
const fetchKpi = async () => {
|
|
try {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const [inboundRes, outboundRes] = await Promise.all([
|
|
apiClient.get("/receiving/list", {
|
|
params: { date_from: today, date_to: today },
|
|
}),
|
|
apiClient.get("/outbound/list", {
|
|
params: { date_from: today, date_to: today },
|
|
}),
|
|
]);
|
|
|
|
const inboundData: Record<string, unknown>[] = inboundRes.data?.data ?? [];
|
|
const outboundData: Record<string, unknown>[] = outboundRes.data?.data ?? [];
|
|
|
|
// Inbound stats
|
|
const inboundTotal = inboundData.length;
|
|
const inboundCompleted = inboundData.filter(
|
|
(r) => r.inbound_status === "완료" || r.inbound_status === "입고완료"
|
|
).length;
|
|
const inboundInProgress = inboundTotal - inboundCompleted;
|
|
|
|
// Outbound stats
|
|
const outboundTotal = outboundData.length;
|
|
const outboundCompleted = outboundData.filter(
|
|
(r) => r.outbound_status === "완료" || r.outbound_status === "출고완료"
|
|
).length;
|
|
const outboundInProgress = outboundTotal - outboundCompleted;
|
|
|
|
// Summary KPI: total = inbound + outbound
|
|
const totalWork = inboundTotal + outboundTotal;
|
|
const completed = inboundCompleted + outboundCompleted;
|
|
const inProgress = inboundInProgress + outboundInProgress;
|
|
|
|
setSummary({
|
|
totalWork,
|
|
inProgress,
|
|
completed,
|
|
issues: 0,
|
|
});
|
|
|
|
setInOut({
|
|
inboundTotal,
|
|
inboundCompleted,
|
|
inboundInProgress,
|
|
outboundTotal,
|
|
outboundCompleted,
|
|
outboundInProgress,
|
|
});
|
|
|
|
// Production / Quality - no dedicated API yet, set to 0
|
|
setProdQuality({
|
|
productionTotal: 0,
|
|
productionCompleted: 0,
|
|
productionInProgress: 0,
|
|
qualityRate: null,
|
|
});
|
|
} catch {
|
|
// On failure, keep zeros (no dummy data)
|
|
}
|
|
};
|
|
|
|
fetchKpi();
|
|
}, []);
|
|
|
|
const goTo = useCallback(
|
|
(idx: number) => {
|
|
const next = ((idx % total) + total) % total;
|
|
setCurrent(next);
|
|
},
|
|
[total]
|
|
);
|
|
|
|
const startAuto = useCallback(() => {
|
|
if (autoTimerRef.current) clearInterval(autoTimerRef.current);
|
|
autoTimerRef.current = setInterval(() => {
|
|
setCurrent((prev) => ((prev + 1) % total + total) % total);
|
|
}, 5000);
|
|
}, [total]);
|
|
|
|
const stopAuto = useCallback(() => {
|
|
if (autoTimerRef.current) {
|
|
clearInterval(autoTimerRef.current);
|
|
autoTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
startAuto();
|
|
return () => stopAuto();
|
|
}, [startAuto, stopAuto]);
|
|
|
|
// Touch handlers
|
|
const handleTouchStart = (e: React.TouchEvent) => {
|
|
startXRef.current = e.touches[0].clientX;
|
|
draggingRef.current = true;
|
|
stopAuto();
|
|
};
|
|
|
|
const handleTouchMove = (e: React.TouchEvent) => {
|
|
if (!draggingRef.current) return;
|
|
moveXRef.current = e.touches[0].clientX - startXRef.current;
|
|
};
|
|
|
|
const handleTouchEnd = () => {
|
|
if (!draggingRef.current) return;
|
|
draggingRef.current = false;
|
|
if (Math.abs(moveXRef.current) > 50) {
|
|
if (moveXRef.current < 0) goTo(current + 1);
|
|
else goTo(current - 1);
|
|
}
|
|
moveXRef.current = 0;
|
|
startAuto();
|
|
};
|
|
|
|
// Mouse handlers (desktop drag)
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
startXRef.current = e.clientX;
|
|
draggingRef.current = true;
|
|
stopAuto();
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
if (!draggingRef.current) return;
|
|
moveXRef.current = e.clientX - startXRef.current;
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
if (!draggingRef.current) return;
|
|
draggingRef.current = false;
|
|
if (Math.abs(moveXRef.current) > 50) {
|
|
if (moveXRef.current < 0) goTo(current + 1);
|
|
else goTo(current - 1);
|
|
}
|
|
moveXRef.current = 0;
|
|
startAuto();
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
if (draggingRef.current) {
|
|
draggingRef.current = false;
|
|
moveXRef.current = 0;
|
|
startAuto();
|
|
}
|
|
};
|
|
|
|
const handleDotClick = (idx: number) => {
|
|
stopAuto();
|
|
goTo(idx);
|
|
startAuto();
|
|
};
|
|
|
|
/* Computed slide values */
|
|
const inboundPercent = inOut.inboundTotal > 0
|
|
? Math.round((inOut.inboundCompleted / inOut.inboundTotal) * 100)
|
|
: 0;
|
|
const outboundPercent = inOut.outboundTotal > 0
|
|
? Math.round((inOut.outboundCompleted / inOut.outboundTotal) * 100)
|
|
: 0;
|
|
const productionPercent = prodQuality.productionTotal > 0
|
|
? Math.round((prodQuality.productionCompleted / prodQuality.productionTotal) * 100)
|
|
: 0;
|
|
|
|
return (
|
|
<section>
|
|
<h2 className="text-xl sm:text-[22px] font-bold text-gray-900 tracking-tight mb-4">
|
|
오늘의 현황
|
|
</h2>
|
|
|
|
{/* Carousel container */}
|
|
<div className="relative overflow-hidden">
|
|
<div
|
|
ref={trackRef}
|
|
className="flex select-none"
|
|
style={{
|
|
transform: `translateX(-${current * 100}%)`,
|
|
transition: "transform 0.4s cubic-bezier(.25,.46,.45,.94)",
|
|
touchAction: "pan-y",
|
|
}}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
{/* Slide 1: Summary KPI */}
|
|
<div className="min-w-full shrink-0">
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-6">
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-0">
|
|
<KpiCell value={String(summary.totalWork)} label="총 작업" color="text-gray-900" labelColor="text-gray-400" border />
|
|
<KpiCell value={String(summary.inProgress)} label="진행중" color="text-blue-600" labelColor="text-blue-400" border />
|
|
<KpiCell value={String(summary.completed)} label="완료" color="text-green-600" labelColor="text-green-400" border />
|
|
<KpiCell value={String(summary.issues)} label="이슈" color="text-red-600" labelColor="text-red-400" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Slide 2: Receiving + Shipping */}
|
|
<div className="min-w-full shrink-0">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<KpiProgressCard
|
|
icon={
|
|
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
</svg>
|
|
}
|
|
iconGradient="linear-gradient(135deg,#3b82f6,#1d4ed8)"
|
|
title="입고"
|
|
badge={`${inOut.inboundInProgress}건 진행`}
|
|
badgeColor="text-blue-600 bg-blue-50"
|
|
value={inOut.inboundCompleted}
|
|
total={inOut.inboundTotal}
|
|
percent={inboundPercent}
|
|
barColor="bg-blue-500"
|
|
/>
|
|
<KpiProgressCard
|
|
icon={
|
|
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0H6.75m11.25 0h2.625c.621 0 1.125-.504 1.125-1.125v-4.875c0-.621-.504-1.125-1.125-1.125H17.25m-13.5-.375V6.375c0-.621.504-1.125 1.125-1.125h7.5c.621 0 1.125.504 1.125 1.125v7.125" />
|
|
</svg>
|
|
}
|
|
iconGradient="linear-gradient(135deg,#22c55e,#15803d)"
|
|
title="출고"
|
|
badge={`${inOut.outboundInProgress}건 진행`}
|
|
badgeColor="text-green-600 bg-green-50"
|
|
value={inOut.outboundCompleted}
|
|
total={inOut.outboundTotal}
|
|
percent={outboundPercent}
|
|
barColor="bg-green-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Slide 3: Production + Quality */}
|
|
<div className="min-w-full shrink-0">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<KpiProgressCard
|
|
icon={
|
|
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" strokeWidth={2} 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.281" />
|
|
</svg>
|
|
}
|
|
iconGradient="linear-gradient(135deg,#f59e0b,#d97706)"
|
|
title="생산"
|
|
badge={`${prodQuality.productionInProgress}건 진행`}
|
|
badgeColor="text-amber-600 bg-amber-50"
|
|
value={prodQuality.productionCompleted}
|
|
total={prodQuality.productionTotal > 0 ? prodQuality.productionTotal : null}
|
|
percent={productionPercent}
|
|
barColor="bg-amber-500"
|
|
/>
|
|
<KpiProgressCard
|
|
icon={
|
|
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
}
|
|
iconGradient="linear-gradient(135deg,#ef4444,#b91c1c)"
|
|
title="품질"
|
|
badge={prodQuality.qualityRate !== null ? "정상" : "데이터 없음"}
|
|
badgeColor={prodQuality.qualityRate !== null ? "text-green-600 bg-green-50" : "text-gray-500 bg-gray-50"}
|
|
value={prodQuality.qualityRate !== null ? prodQuality.qualityRate : 0}
|
|
total={null}
|
|
percent={prodQuality.qualityRate !== null ? prodQuality.qualityRate : 0}
|
|
barColor="bg-green-500"
|
|
valueColor={prodQuality.qualityRate !== null ? "text-green-600" : "text-gray-400"}
|
|
unit={prodQuality.qualityRate !== null ? "%" : undefined}
|
|
emptyText={prodQuality.qualityRate === null ? "-" : undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dots */}
|
|
<div className="flex justify-center gap-2 mt-3">
|
|
{[0, 1, 2].map((idx) => (
|
|
<button
|
|
key={idx}
|
|
onClick={() => handleDotClick(idx)}
|
|
className="border-none p-0 transition-all duration-300 cursor-pointer"
|
|
style={{
|
|
width: current === idx ? 24 : 8,
|
|
height: 8,
|
|
borderRadius: current === idx ? 4 : "50%",
|
|
background: current === idx ? "#3b82f6" : "#D1D5DB",
|
|
}}
|
|
aria-label={`슬라이드 ${idx + 1}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/* ---- Sub-components ---- */
|
|
|
|
function KpiCell({
|
|
value,
|
|
label,
|
|
color,
|
|
labelColor,
|
|
border = false,
|
|
}: {
|
|
value: string;
|
|
label: string;
|
|
color: string;
|
|
labelColor: string;
|
|
border?: boolean;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={`flex flex-col items-center py-3 lg:py-4 ${
|
|
border ? "lg:border-r lg:border-gray-100" : ""
|
|
}`}
|
|
>
|
|
<span
|
|
className={`text-3xl sm:text-4xl lg:text-[44px] font-extrabold leading-none ${color}`}
|
|
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.02em" }}
|
|
>
|
|
{value}
|
|
</span>
|
|
<span className={`text-xs sm:text-[13px] font-medium mt-1.5 sm:mt-2 ${labelColor}`}>
|
|
{label}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KpiProgressCard({
|
|
icon,
|
|
iconGradient,
|
|
title,
|
|
badge,
|
|
badgeColor,
|
|
value,
|
|
total,
|
|
percent,
|
|
barColor,
|
|
valueColor = "text-gray-900",
|
|
unit,
|
|
emptyText,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
iconGradient: string;
|
|
title: string;
|
|
badge: string;
|
|
badgeColor: string;
|
|
value: number;
|
|
total: number | null;
|
|
percent: number;
|
|
barColor: string;
|
|
valueColor?: string;
|
|
unit?: string;
|
|
emptyText?: string;
|
|
}) {
|
|
return (
|
|
<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-3">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
|
style={{ background: iconGradient }}
|
|
>
|
|
{icon}
|
|
</div>
|
|
<span className="text-sm font-semibold text-gray-900">{title}</span>
|
|
</div>
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${badgeColor}`}>
|
|
{badge}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-end gap-1">
|
|
<span
|
|
className={`text-3xl sm:text-[36px] font-extrabold leading-none ${valueColor}`}
|
|
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.02em" }}
|
|
>
|
|
{emptyText ?? value}
|
|
</span>
|
|
{total !== null && (
|
|
<span className="text-sm text-gray-400 mb-0.5 ml-1">/ {total}</span>
|
|
)}
|
|
{unit && <span className="text-sm text-gray-400 mb-0.5 ml-1">{unit}</span>}
|
|
</div>
|
|
<div className="mt-3 h-2 bg-gray-100 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full ${barColor} rounded-full`}
|
|
style={{
|
|
width: `${percent}%`,
|
|
animation: "popGrowWidth 0.8s ease-out both",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<style jsx>{`
|
|
@keyframes popGrowWidth {
|
|
from {
|
|
width: 0%;
|
|
}
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|