- POP 생산: 재고 관리, 재작업 이력, BOM 자재투입 기능 추가 - POP 설정: 설정 시스템 + 관리 페이지 (/pop/admin) - POP 화면: 버그 수정 + 설정 연동 + 다음공정 활성화 수정 - PC 코드 무변경 (보류 6건: app.ts, 출고/입고/작업지시 컨트롤러, 레이아웃)
186 lines
6.6 KiB
TypeScript
186 lines
6.6 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: "완료",
|
|
};
|
|
|
|
const btnClass = "h-12 rounded-xl text-base font-bold text-white active:scale-95 transition-all disabled:opacity-40 px-5";
|
|
|
|
return (
|
|
<div className={`rounded-xl border ${colors.border} ${colors.bg} px-4 py-3`}>
|
|
<div className="flex items-center justify-between gap-4">
|
|
{/* Left: status + time + start info */}
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<span className={`text-xs font-bold px-2.5 py-1 rounded-md ${colors.text} border ${colors.border} shrink-0`}>
|
|
{statusLabels[status]}
|
|
</span>
|
|
<span
|
|
className={`text-3xl font-black tracking-wider ${colors.text} shrink-0`}
|
|
style={{ fontVariantNumeric: "tabular-nums", fontFamily: "monospace" }}
|
|
>
|
|
{formatTime(elapsed)}
|
|
</span>
|
|
{startedAt && (
|
|
<span className="text-xs text-gray-400 shrink-0 hidden sm:inline">
|
|
시작 {new Date(startedAt).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: buttons */}
|
|
<div className="flex gap-2 shrink-0">
|
|
{status === "idle" && (
|
|
<button onClick={onStart} disabled={disabled} className={btnClass}
|
|
style={{ background: "linear-gradient(135deg, #3b82f6, #1d4ed8)" }}>
|
|
▶ 시작
|
|
</button>
|
|
)}
|
|
{status === "running" && (
|
|
<>
|
|
<button onClick={onPause} disabled={disabled} className={btnClass}
|
|
style={{ background: "linear-gradient(135deg, #f59e0b, #d97706)" }}>
|
|
⏸ 일시정지
|
|
</button>
|
|
<button onClick={onComplete} disabled={disabled} className={btnClass}
|
|
style={{ background: "linear-gradient(135deg, #10b981, #059669)" }}>
|
|
⏹ 종료
|
|
</button>
|
|
</>
|
|
)}
|
|
{status === "paused" && (
|
|
<>
|
|
<button onClick={onResume} disabled={disabled} className={btnClass}
|
|
style={{ background: "linear-gradient(135deg, #3b82f6, #1d4ed8)" }}>
|
|
▶ 재개
|
|
</button>
|
|
<button onClick={onComplete} disabled={disabled} className={btnClass}
|
|
style={{ background: "linear-gradient(135deg, #10b981, #059669)" }}>
|
|
⏹ 종료
|
|
</button>
|
|
</>
|
|
)}
|
|
{status === "completed" && (
|
|
<div className="h-12 px-5 rounded-xl bg-green-100 border-2 border-green-300 flex items-center gap-2 text-green-700 font-bold text-base">
|
|
✓ 완료
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|