"use client"; /* eslint-disable react-hooks/set-state-in-effect */ import Image from "next/image"; import { useState, useEffect, useRef } from "react"; import LayoutContainer from "@/components/layout/LayoutContainer"; import { verifyTimeTrackingPin, clockInWorker, clockOutWorker, getOpenClockIn, logoutTimeTracking, getTimeTrackingTasksField, getWorkerPayPeriodHours, getTimeTrackingSession, type TimeTrackingSession, type TimeTaskField, type PayPeriodHours, } from "@/actions/time-tracking/field"; const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; const BRAND_NAME = "Tuxedo Corn"; // ── Translations ─────────────────────────────────────────────────────────────── type Translations = { title: string; enter_pin: string; pin_placeholder: string; clock_in: string; clock_out: string; select_task: string; general_labor: string; logout: string; clocked_in_at: string; lunch_break: string; notes_placeholder: string; submit_clock_out: string; invalid_pin: string; back_to_login: string; cancel: string; hours_this_period: string; today: string; this_week: string; overtime_warning: string; daily_overtime_hit: string; weekly_overtime_hit: string; pay_period: string; hours_remaining: string; overtime: string; }; const TRANS_EN: Translations = { title: "Worker Clock", enter_pin: "Enter your 4-digit PIN", pin_placeholder: "PIN", clock_in: "Clock In", clock_out: "Clock Out", select_task: "Select a task", general_labor: "General Labor", logout: "Logout", clocked_in_at: "Clocked in at", lunch_break: "Lunch break (minutes)", notes_placeholder: "Notes (optional)", submit_clock_out: "Submit Clock Out", invalid_pin: "Invalid PIN. Please try again.", back_to_login: "Back to PIN entry", cancel: "Cancel", hours_this_period: "Hours this pay period", today: "Today", this_week: "This week", overtime_warning: "Overtime threshold reached", daily_overtime_hit: "Daily overtime", weekly_overtime_hit: "Weekly overtime", pay_period: "Pay period", hours_remaining: "hours remaining", overtime: "Overtime", }; const TRANS_ES: Translations = { title: "Reloj de Trabajador", enter_pin: "Ingrese su PIN de 4 dígitos", pin_placeholder: "PIN", clock_in: "Entrada", clock_out: "Salida", select_task: "Seleccione una tarea", general_labor: "Labor General", logout: "Salir", clocked_in_at: "Entrada a las", lunch_break: "Descanso de almuerzo (minutos)", notes_placeholder: "Notas (opcional)", submit_clock_out: "Enviar Salida", invalid_pin: "PIN inválido. Por favor intente de nuevo.", back_to_login: "Volver al PIN", cancel: "Cancelar", hours_this_period: "Horas este período", today: "Hoy", this_week: "Esta semana", overtime_warning: "Umbral de horas extra alcanzado", daily_overtime_hit: "Horas extra diarias", weekly_overtime_hit: "Horas extra semanales", pay_period: "Período de pago", hours_remaining: "horas restantes", overtime: "Horas extra", }; // ── Cookie helpers ───────────────────────────────────────────────────────────── function getLangCookie(): string { if (typeof document === "undefined") return "en"; const match = document.cookie.match(/time_tracking_lang=(\w+)/); return match ? match[1] : "en"; } function setLangCookie(lang: string) { document.cookie = `time_tracking_lang=${lang}; path=/; max-age=86400`; } // ── Helpers ─────────────────────────────────────────────────────────────────── function formatTime(iso: string): string { return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true }); } function formatHours(minutes: number): string { const h = Math.floor(minutes / 60); const m = minutes % 60; if (h === 0) return `${m}m`; if (m === 0) return `${h}h`; return `${h}h ${m}m`; } function formatHoursDecimal(h: number): string { if (h < 1) return `${(h * 60).toFixed(0)}m`; return `${h.toFixed(1)}h`; } function formatPeriodRange(start: string, end: string, lang: "en" | "es"): string { const s = new Date(start); const e = new Date(end); if (lang === "es") { return `${s.toLocaleDateString("es-ES", { month: "short", day: "numeric" })} – ${e.toLocaleDateString("es-ES", { month: "short", day: "numeric" })}`; } return `${s.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${e.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`; } function taskLabel(task: TimeTaskField, lang: "en" | "es"): string { return lang === "en" ? task.name : (task.name_es ?? task.name); } // ── Screen types ─────────────────────────────────────────────────────────────── type Screen = "loading" | "pin" | "task_select" | "working" | "clock_out_form" | "clocked_out"; interface OpenEntry { log_id: string; task_name: string; clock_in: string; elapsed_minutes: number; } // ── Main Component ───────────────────────────────────────────────────────────── export default function TimeTrackingFieldClient({ brandId, brandName, brandAccent, logoUrl, }: { brandId: string; brandName: string; brandAccent: string; logoUrl?: string | null; }) { const [lang, setLang] = useState<"en" | "es">(() => (getLangCookie() as "en" | "es") ?? "en"); const [screen, setScreen] = useState("pin"); const [session, setSession] = useState(null); const [tasks, setTasks] = useState([]); const [openEntry, setOpenEntry] = useState(null); const [payPeriod, setPayPeriod] = useState(null); const [clockOutResult, setClockOutResult] = useState<{ clock_out: string; total_minutes: number } | null>(null); const [selectedTaskId, setSelectedTaskId] = useState(""); const [lunchMinutes, setLunchMinutes] = useState(0); const [notes, setNotes] = useState(""); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const t = lang === "en" ? TRANS_EN : TRANS_ES; const pollRef = useRef | null>(null); const initRef = useRef(false); // Keep latest loadPayPeriod reachable from polling + init effects without // having to add it to their dep arrays (the function identity changes on // every render since it's a plain const). const loadPayPeriodRef = useRef<() => Promise>(() => Promise.resolve()); // Fetch pay period hours const loadPayPeriod = async () => { const result = await getWorkerPayPeriodHours(brandId); if (result.success) setPayPeriod(result); }; // Keep ref pointing at the latest loadPayPeriod so effects below can call // it without re-running on every render. Updated in an effect (not during // render) per the React refs-during-render rule. useEffect(() => { loadPayPeriodRef.current = loadPayPeriod; }); // Init useEffect(() => { if (initRef.current) return; initRef.current = true; async function init() { // Only check for open clock-in if session exists const stored = await getTimeTrackingSession(); if (!stored) { setScreen("pin"); await loadPayPeriodRef.current(); return; } setSession(stored); const open = await getOpenClockIn(brandId); await loadPayPeriodRef.current(); if (open.success && open.open && open.log_id) { setOpenEntry({ log_id: open.log_id!, task_name: open.task_name ?? t.general_labor, clock_in: open.clock_in!, elapsed_minutes: open.elapsed_minutes ?? 0, }); setScreen("working"); } else { setScreen("task_select"); } } init(); // intentionally omits screen — re-running init on screen change causes race with session cookie being set // t is referenced inside init() for `t.general_labor`; init runs once via initRef so the dep churn doesn't matter // loadPayPeriodRef is stable; init() reads through it to avoid dep churn }, [brandId, t.general_labor]); // Poll elapsed + pay period every 30s when on working screen useEffect(() => { if (screen !== "working") { if (pollRef.current) clearInterval(pollRef.current); return; } pollRef.current = setInterval(async () => { const open = await getOpenClockIn(brandId); if (open.success && open.open && open.log_id) { setOpenEntry(prev => prev ? { ...prev, elapsed_minutes: open.elapsed_minutes ?? prev.elapsed_minutes } : null ); } await loadPayPeriodRef.current(); }, 30000); return () => { if (pollRef.current) clearInterval(pollRef.current); }; }, [screen, brandId]); // loadPayPeriodRef is stable; interval reads through it // ── Handlers ─────────────────────────────────────────────────────────────── const toggleLang = () => { const next = lang === "en" ? "es" : "en"; setLangCookie(next); setLang(next); }; const handlePinSubmit = async (pin: string) => { setError(null); const result = await verifyTimeTrackingPin(brandId, pin); if (!result.success) { setError(result.error ?? t.invalid_pin); return; } setSession(result.session!); const [open, taskList] = await Promise.all([ getOpenClockIn(brandId), getTimeTrackingTasksField(brandId), ]); setTasks(taskList); await loadPayPeriod(); if (open.success && open.open && open.log_id) { setOpenEntry({ log_id: open.log_id!, task_name: open.task_name ?? t.general_labor, clock_in: open.clock_in!, elapsed_minutes: open.elapsed_minutes ?? 0, }); setScreen("working"); } else { setScreen("task_select"); } }; // Dispatch overtime notification (fire-and-forget) const dispatchNotification = async (workerName: string, dailyHours: number, weeklyHours: number) => { try { await fetch("/api/time-tracking/notify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ brandId, workerId: session?.worker_id, workerName, dailyHours, weeklyHours }), }); } catch { // non-critical } }; const handleSelectTask = async (taskName: string) => { setError(null); setSubmitting(true); const result = await clockInWorker(brandId, undefined, taskName); setSubmitting(false); if (!result.success) { if (result.error === "Already clocked in") { const open = await getOpenClockIn(brandId); if (open.success && open.open) { setOpenEntry({ log_id: open.log_id!, task_name: open.task_name ?? taskName, clock_in: open.clock_in!, elapsed_minutes: open.elapsed_minutes ?? 0, }); setScreen("working"); return; } } setError(result.error ?? "Clock in failed"); return; } setOpenEntry({ log_id: result.log_id!, task_name: taskName, clock_in: result.clock_in!, elapsed_minutes: 0, }); await loadPayPeriod(); if (payPeriod) { dispatchNotification(session?.name ?? taskName, payPeriod.daily_hours, payPeriod.weekly_hours); } setScreen("working"); }; const handleClockOut = async () => { setError(null); setSubmitting(true); const result = await clockOutWorker(lunchMinutes, notes || undefined); setSubmitting(false); if (!result.success) { setError(result.error ?? "Clock out failed"); return; } setClockOutResult({ clock_out: result.clock_out!, total_minutes: result.total_minutes ?? 0 }); await loadPayPeriod(); if (payPeriod) { dispatchNotification(session?.name ?? openEntry?.task_name ?? "Worker", payPeriod.daily_hours, payPeriod.weekly_hours); } setScreen("clocked_out"); }; const handleLogout = async () => { await logoutTimeTracking(); setSession(null); setOpenEntry(null); setPayPeriod(null); setClockOutResult(null); setSelectedTaskId(""); setLunchMinutes(0); setNotes(""); setScreen("pin"); }; const handleBackToPin = () => { setClockOutResult(null); setOpenEntry(null); setPayPeriod(null); setSelectedTaskId(""); setLunchMinutes(0); setNotes(""); setScreen("pin"); }; const showOvertimeWarning = payPeriod && (payPeriod.daily_overtime || payPeriod.weekly_overtime); return (
{/* Header */}
{logoUrl ? Olathe Sweet : null} {brandName}
{screen === "loading" && (
Loading...
)} {screen === "pin" && ( )} {(screen === "task_select") && ( )} {screen === "working" && openEntry && ( setScreen("clock_out_form")} onLogout={handleLogout} logoUrl={logoUrl} /> )} {screen === "clock_out_form" && openEntry && ( setScreen("working")} error={error} submitting={submitting} logoUrl={logoUrl} /> )} {screen === "clocked_out" && clockOutResult && ( )}
); } // ── Pay Period Card ───────────────────────────────────────────────────────────── function PayPeriodCard({ t, lang, payPeriod, showWarning, }: { t: Translations; lang: "en" | "es"; payPeriod: PayPeriodHours | null; showWarning: boolean; logoUrl?: string | null; }) { if (!payPeriod) return null; const rangeStr = formatPeriodRange(payPeriod.period_start, payPeriod.period_end, lang); const dailyRemaining = Math.max(0, payPeriod.daily_threshold - payPeriod.daily_hours); const weeklyRemaining = Math.max(0, payPeriod.weekly_threshold - payPeriod.weekly_hours); const dailyOT = payPeriod.daily_hours > payPeriod.daily_threshold; const weeklyOT = payPeriod.weekly_hours > payPeriod.weekly_threshold; return (

{t.pay_period}

{showWarning && ( {t.overtime_warning} )}

{rangeStr}

{/* Large remaining hours display */} {!dailyOT && !weeklyOT ? (

{formatHoursDecimal(weeklyRemaining)}

{t.hours_remaining} {t.this_week.toLowerCase()}

) : (

{t.overtime}

{dailyOT ? `${formatHoursDecimal(payPeriod.daily_hours)} ${t.today.toLowerCase()}` : ""} {dailyOT && weeklyOT ? " · " : ""} {weeklyOT ? `${formatHoursDecimal(payPeriod.weekly_hours)} ${t.this_week.toLowerCase()}` : ""}

)}
{/* Main total */}

{formatHoursDecimal(payPeriod.total_hours)}

{t.hours_this_period}

{/* Today + week breakdown */}
{t.today} {formatHoursDecimal(payPeriod.daily_hours)}
{t.this_week} {formatHoursDecimal(payPeriod.weekly_hours)}
{/* Overtime flags */} {(payPeriod.daily_overtime || payPeriod.weekly_overtime) && (
{payPeriod.daily_overtime && ( {t.daily_overtime_hit} )} {payPeriod.weekly_overtime && ( {t.weekly_overtime_hit} )}
)}
); } // ── Working Screen ──────────────────────────────────────────────────────────── function WorkingScreen({ t, lang, openEntry, payPeriod, showOvertimeWarning, onClockOut, onLogout, logoUrl, }: { t: Translations; lang: "en" | "es"; openEntry: OpenEntry; payPeriod: PayPeriodHours | null; showOvertimeWarning: boolean; onClockOut: () => void; onLogout: () => void; logoUrl?: string | null; }) { return (

{t.clocked_in_at}

{formatTime(openEntry.clock_in)}

{openEntry.task_name}

{formatHours(openEntry.elapsed_minutes)}

); } // ── Task Select Screen ───────────────────────────────────────────────────────── function TaskSelectScreen({ t, tasks, lang, onSelectTask, error, submitting, logoUrl, }: { t: Translations; tasks: TimeTaskField[]; lang: "en" | "es"; onSelectTask: (name: string) => void; error: string | null; submitting: boolean; logoUrl?: string | null; }) { return (
{logoUrl ? Olathe Sweet : null}

{t.title}

{t.select_task}

{error && (
{error}
)}
{tasks.map(task => ( ))}
); } // ── Clock Out Form ──────────────────────────────────────────────────────────── function ClockOutForm({ t, tasks, lang, openEntryTaskName, selectedTaskId, setSelectedTaskId, lunchMinutes, setLunchMinutes, notes, setNotes, onSubmit, onBack, error, submitting, }: { t: Translations; tasks: TimeTaskField[]; lang: "en" | "es"; openEntryTaskName: string; selectedTaskId: string; setSelectedTaskId: (id: string) => void; lunchMinutes: number; setLunchMinutes: (m: number) => void; notes: string; setNotes: (n: string) => void; onSubmit: () => void; onBack: () => void; error: string | null; submitting: boolean; logoUrl?: string | null; }) { return (

{t.clock_out}

{openEntryTaskName}

{/* Task */}
{tasks.map(task => ( ))}
{/* Lunch */}
setLunchMinutes(Math.max(0, parseInt(e.target.value) || 0))} className="w-full px-4 py-3 rounded-xl border border-zinc-700 bg-zinc-900 text-white text-center font-mono text-lg focus:outline-none focus:border-emerald-500 transition-colors" />
{/* Notes */}