244 lines
8.9 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|