Files
vexplor_dev/frontend/components/pop/hardcoded/KpiCarousel.tsx
SeongHyun Kim a04ddd15ec feat: POP 기능 병합 (pop-screen → main, PC 무변경 46건)
- POP 생산: 재고 관리, 재작업 이력, BOM 자재투입 기능 추가
- POP 설정: 설정 시스템 + 관리 페이지 (/pop/admin)
- POP 화면: 버그 수정 + 설정 연동 + 다음공정 활성화 수정
- PC 코드 무변경 (보류 6건: app.ts, 출고/입고/작업지시 컨트롤러, 레이아웃)
2026-04-05 17:45:33 +09:00

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