Files
vexplor/frontend/components/pop/hardcoded/production/ProcessTimer.tsx
SeongHyun Kim 9b7b88ff7c feat: POP 하드코딩 화면 추가 (PC 코드 무변경 재병합)
- POP 전용 39개 파일 추가 (홈/입고/출고/생산)
- 백엔드 INSERT에 id gen_random_uuid 추가 (5개 파일)
- POP 전용 API 7개 추가 (창고/위치/입고/동기화)
- PC 코드 구조/순서/로직 변경 없음 (AppLayout, UserDropdown 미수정)
2026-04-02 17:39:42 +09:00

244 lines
8.9 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type TimerStatus = "idle" | "running" | "paused" | "completed";
interface ProcessTimerProps {
status: TimerStatus;
/** ISO string or epoch — when the timer was first started */
startedAt: string | null;
/** ISO string or epoch — when paused (null if not paused) */
pausedAt: string | null;
/** Total paused seconds accumulated before current pause */
totalPausedSeconds: number;
/** Completed at timestamp */
completedAt: string | null;
/** Actual work time in seconds (from server, used when completed) */
actualWorkTime: number | null;
onStart: () => void;
onPause: () => void;
onResume: () => void;
onComplete: () => void;
disabled?: boolean;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function formatTime(totalSeconds: number): string {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function ProcessTimer({
status,
startedAt,
pausedAt,
totalPausedSeconds,
completedAt,
actualWorkTime,
onStart,
onPause,
onResume,
onComplete,
disabled = false,
}: ProcessTimerProps) {
const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const calcElapsed = useCallback(() => {
if (!startedAt) return 0;
const start = new Date(startedAt).getTime();
const now = Date.now();
let pausedMs = totalPausedSeconds * 1000;
// If currently paused, add time since pause started
if (pausedAt) {
const pauseStart = new Date(pausedAt).getTime();
pausedMs += now - pauseStart;
}
return Math.max(0, Math.floor((now - start - pausedMs) / 1000));
}, [startedAt, pausedAt, totalPausedSeconds]);
useEffect(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (status === "completed" && actualWorkTime !== null) {
setElapsed(actualWorkTime);
return;
}
if (status === "running") {
setElapsed(calcElapsed());
intervalRef.current = setInterval(() => {
setElapsed(calcElapsed());
}, 1000);
} else if (status === "paused") {
setElapsed(calcElapsed());
} else if (status === "idle") {
setElapsed(0);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [status, startedAt, pausedAt, totalPausedSeconds, actualWorkTime, calcElapsed]);
/* Color by status */
const colorMap: Record<TimerStatus, { bg: string; text: string; border: string; ring: string }> = {
idle: { bg: "bg-gray-50", text: "text-gray-400", border: "border-gray-200", ring: "ring-gray-200" },
running: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200", ring: "ring-blue-300" },
paused: { bg: "bg-amber-50", text: "text-amber-600", border: "border-amber-200", ring: "ring-amber-300" },
completed: { bg: "bg-green-50", text: "text-green-600", border: "border-green-200", ring: "ring-green-300" },
};
const colors = colorMap[status];
const statusLabels: Record<TimerStatus, string> = {
idle: "대기",
running: "진행중",
paused: "일시정지",
completed: "완료",
};
return (
<div className={`rounded-2xl border-2 ${colors.border} ${colors.bg} p-5 sm:p-6`}>
{/* Status badge */}
<div className="flex items-center justify-between mb-4">
<span className={`text-xs font-bold px-3 py-1 rounded-full ${colors.bg} ${colors.text} border ${colors.border}`}>
{statusLabels[status]}
</span>
{status === "running" && (
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
<span className="text-xs text-blue-500 font-medium"></span>
</span>
)}
</div>
{/* Timer display */}
<div className="text-center mb-5">
<p
className={`text-5xl sm:text-6xl font-black tracking-wider ${colors.text}`}
style={{ fontVariantNumeric: "tabular-nums", fontFamily: "monospace" }}
>
{formatTime(elapsed)}
</p>
{startedAt && (
<p className="text-xs text-gray-400 mt-2">
: {new Date(startedAt).toLocaleTimeString("ko-KR")}
{completedAt && ` | 종료: ${new Date(completedAt).toLocaleTimeString("ko-KR")}`}
</p>
)}
</div>
{/* Buttons */}
<div className="flex gap-3">
{status === "idle" && (
<button
onClick={onStart}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</span>
</button>
)}
{status === "running" && (
<>
<button
onClick={onPause}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
</span>
</button>
<button
onClick={onComplete}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #10b981 0%, #059669 100%)" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z" />
</svg>
</span>
</button>
</>
)}
{status === "paused" && (
<>
<button
onClick={onResume}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</span>
</button>
<button
onClick={onComplete}
disabled={disabled}
className="flex-1 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{ background: "linear-gradient(135deg, #10b981 0%, #059669 100%)" }}
>
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z" />
</svg>
</span>
</button>
</>
)}
{status === "completed" && (
<div className="flex-1 h-14 rounded-xl bg-green-100 border-2 border-green-300 flex items-center justify-center gap-2 text-green-700 font-bold text-lg">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
)}
</div>
</div>
);
}