Files
route-commerce/src/components/time-tracking/TimeTrackingFieldClient.tsx
T
Nora 29d9d23a26 fix: react-doctor → 64/100 (Bugs 122, Perf 286, A11y 613, Maint 436)
- CartContext: lazy initializers replace mount-only useEffect
  hydration; remove 8 no-initialize-state warnings
- Toast/AdminSearchInput: React 19 useContext/use + drop
  forwardRef (3 no-react19-deprecated-apis)
- ProductFormModal: lazy initializers + useSyncExternalStore
  for mount; parent adds key=editingProduct.id
- InstallPrompt: useReducer for prompt state (no-cascading-set-state)
- QRScanModal: ref-based latest-callback pattern replaces
  useEffectEvent deps mistake
- OnboardingFlow: functional setState (rerender-functional-setstate)
- UsersPage/StopsCalendar/FeaturesAndStats: lazy initializers
  (rerender-lazy-state-init)
- FAQClientPage: server-side brand settings fetch via getBrandSettingsPublic
  in layout; remove supabase import
- LandingPageWrapper: href='#' → href='#top' (anchor-is-valid)
- TuxedoVideoHero: replace animate-bounce with ease-out-expo
  (no-inline-bounce-easing)
- ProductTableClient: useCallback for handleDeleted
  (jsx-no-new-function-as-prop)
- excel-parser: pre-compile delimiter regexes (js-hoist-regexp)
- water-log/settings: Promise.all for parallel DB calls
  (async-parallel)
- ToastNotification: extract toast store to separate file
  (only-export-components)
- WholesaleClient: inline <WholesaleIcon/> instead of hoisting to
  const (rendering-hoist-jsx)
2026-06-26 02:41:56 -06:00

955 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<Screen>("pin");
const [session, setSession] = useState<TimeTrackingSession | null>(null);
const [tasks, setTasks] = useState<TimeTaskField[]>([]);
const [openEntry, setOpenEntry] = useState<OpenEntry | null>(null);
const [payPeriod, setPayPeriod] = useState<PayPeriodHours | null>(null);
const [clockOutResult, setClockOutResult] = useState<{ clock_out: string; total_minutes: number } | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string>("");
const [lunchMinutes, setLunchMinutes] = useState(0);
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const t = lang === "en" ? TRANS_EN : TRANS_ES;
const pollRef = useRef<ReturnType<typeof setInterval> | 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<void>>(() => 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 (
<div className="min-h-screen bg-stone-950 text-zinc-100">
{/* Header */}
<div className="bg-zinc-900 border-b border-zinc-800 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="relative inline-block h-8 w-auto">
{logoUrl ? <Image src={logoUrl} alt="Olathe Sweet" fill style={{ objectFit: "contain" }} className="opacity-80" /> : null}
</span>
<span className="text-sm font-semibold text-zinc-400">{brandName}</span>
</div>
<button type="button"
onClick={toggleLang}
className="text-xs font-mono text-zinc-500 hover:text-zinc-300 border border-zinc-700 rounded px-2 py-1 transition-colors"
>
{lang === "en" ? "ES" : "EN"}
</button>
</div>
<LayoutContainer>
<div className="max-w-md mx-auto py-8 px-4 space-y-5">
{screen === "loading" && (
<div className="text-center text-zinc-500 text-sm py-20">Loading...</div>
)}
{screen === "pin" && (
<PinEntry
t={t}
onSubmit={handlePinSubmit}
error={error}
submitting={submitting}
logoUrl={logoUrl}
/>
)}
{(screen === "task_select") && (
<TaskSelectScreen
t={t}
tasks={tasks}
lang={lang}
onSelectTask={handleSelectTask}
error={error}
submitting={submitting}
logoUrl={logoUrl}
/>
)}
{screen === "working" && openEntry && (
<WorkingScreen
t={t}
lang={lang}
openEntry={openEntry}
payPeriod={payPeriod}
showOvertimeWarning={showOvertimeWarning ?? false}
onClockOut={() => setScreen("clock_out_form")}
onLogout={handleLogout}
logoUrl={logoUrl}
/>
)}
{screen === "clock_out_form" && openEntry && (
<ClockOutForm
t={t}
tasks={tasks}
lang={lang}
openEntryTaskName={openEntry.task_name}
selectedTaskId={selectedTaskId}
setSelectedTaskId={setSelectedTaskId}
lunchMinutes={lunchMinutes}
setLunchMinutes={setLunchMinutes}
notes={notes}
setNotes={setNotes}
onSubmit={handleClockOut}
onBack={() => setScreen("working")}
error={error}
submitting={submitting}
logoUrl={logoUrl}
/>
)}
{screen === "clocked_out" && clockOutResult && (
<ClockedOutScreen
t={t}
lang={lang}
payPeriod={payPeriod}
clockOutResult={clockOutResult}
onBackToPin={handleBackToPin}
logoUrl={logoUrl}
/>
)}
</div>
</LayoutContainer>
</div>
);
}
// ── 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 (
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-widest text-zinc-500">
{t.pay_period}
</p>
{showWarning && (
<span className="flex items-center gap-1 text-xs text-amber-400 font-semibold">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{t.overtime_warning}
</span>
)}
</div>
<p className="text-xs text-zinc-600">{rangeStr}</p>
{/* Large remaining hours display */}
{!dailyOT && !weeklyOT ? (
<div className="text-center py-2">
<p className="text-4xl font-black text-emerald-400">{formatHoursDecimal(weeklyRemaining)}</p>
<p className="text-xs text-zinc-500 mt-1">{t.hours_remaining} {t.this_week.toLowerCase()}</p>
</div>
) : (
<div className="text-center py-2">
<p className="text-2xl font-black text-amber-400">{t.overtime}</p>
<p className="text-xs text-zinc-500 mt-1">
{dailyOT ? `${formatHoursDecimal(payPeriod.daily_hours)} ${t.today.toLowerCase()}` : ""}
{dailyOT && weeklyOT ? " · " : ""}
{weeklyOT ? `${formatHoursDecimal(payPeriod.weekly_hours)} ${t.this_week.toLowerCase()}` : ""}
</p>
</div>
)}
<div className="flex items-start gap-4">
{/* Main total */}
<div className="flex-1 text-center">
<p className="text-3xl font-black text-white">{formatHoursDecimal(payPeriod.total_hours)}</p>
<p className="text-[10px] uppercase tracking-widest text-zinc-500 mt-0.5">{t.hours_this_period}</p>
</div>
{/* Today + week breakdown */}
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${payPeriod.daily_overtime ? "bg-amber-400" : "bg-zinc-600"}`} />
<span className="text-xs text-zinc-400">{t.today}</span>
<span className="text-xs font-mono text-zinc-200 ml-auto">{formatHoursDecimal(payPeriod.daily_hours)}</span>
</div>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${payPeriod.weekly_overtime ? "bg-amber-400" : "bg-zinc-600"}`} />
<span className="text-xs text-zinc-400">{t.this_week}</span>
<span className="text-xs font-mono text-zinc-200 ml-auto">{formatHoursDecimal(payPeriod.weekly_hours)}</span>
</div>
</div>
</div>
{/* Overtime flags */}
{(payPeriod.daily_overtime || payPeriod.weekly_overtime) && (
<div className="flex gap-2 pt-1">
{payPeriod.daily_overtime && (
<span className="text-[10px] font-bold uppercase tracking-wider bg-amber-900/40 text-amber-400 border border-amber-700/50 rounded-full px-2 py-0.5">
{t.daily_overtime_hit}
</span>
)}
{payPeriod.weekly_overtime && (
<span className="text-[10px] font-bold uppercase tracking-wider bg-amber-900/40 text-amber-400 border border-amber-700/50 rounded-full px-2 py-0.5">
{t.weekly_overtime_hit}
</span>
)}
</div>
)}
</div>
);
}
// ── 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 (
<div className="space-y-4">
<PayPeriodCard t={t} lang={lang} payPeriod={payPeriod} showWarning={showOvertimeWarning} logoUrl={logoUrl} />
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-5 text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-emerald-900/40 border border-emerald-700/50 mb-3">
<svg className="w-7 h-7 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="text-xs text-zinc-400 uppercase tracking-widest mb-1">{t.clocked_in_at}</p>
<p className="text-xl font-bold text-white">{formatTime(openEntry.clock_in)}</p>
<p className="text-sm text-zinc-400 mt-1">{openEntry.task_name}</p>
<p className="text-3xl font-black text-emerald-400 mt-3">{formatHours(openEntry.elapsed_minutes)}</p>
</div>
<div className="space-y-2">
<button type="button"
onClick={onClockOut}
className="w-full py-4 rounded-2xl font-bold text-lg bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 text-white transition-all"
>
{t.clock_out}
</button>
<button type="button"
onClick={onLogout}
className="w-full py-3 rounded-2xl font-semibold text-sm text-zinc-400 hover:text-zinc-200 border border-zinc-700 hover:border-zinc-500 transition-all"
>
{t.logout}
</button>
</div>
</div>
);
}
// ── 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 (
<div className="space-y-5">
<div className="text-center">
<span className="relative inline-block h-14 w-auto">
{logoUrl ? <Image src={logoUrl} alt="Olathe Sweet" fill style={{ objectFit: "contain" }} className="mx-auto opacity-80 mb-3" /> : null}
</span>
<h1 className="text-2xl font-black text-white">{t.title}</h1>
<p className="text-sm text-zinc-400 mt-1">{t.select_task}</p>
</div>
{error && (
<div className="bg-red-900/20 border border-red-800/50 rounded-xl py-3 px-4 text-red-400 text-sm text-center">
{error}
</div>
)}
<div className="space-y-2">
<button type="button"
onClick={() => onSelectTask(t.general_labor)}
disabled={submitting}
className="w-full text-left px-4 py-3.5 rounded-xl border border-emerald-600 bg-emerald-900/20 text-emerald-400 font-medium hover:bg-emerald-900/30 transition-all disabled:opacity-40"
>
{t.general_labor}
</button>
{tasks.map(task => (
<button type="button"
key={task.id}
onClick={() => onSelectTask(taskLabel(task, lang))}
disabled={submitting}
className="w-full text-left px-4 py-3.5 rounded-xl border border-zinc-700 bg-zinc-900 text-zinc-300 hover:border-zinc-600 hover:bg-zinc-800 transition-all disabled:opacity-40"
>
<span className="font-medium">{taskLabel(task, lang)}</span>
<span className="text-zinc-500 text-xs ml-2">({task.unit})</span>
</button>
))}
</div>
</div>
);
}
// ── 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 (
<div className="space-y-5">
<div className="text-center">
<p className="text-sm text-zinc-400 uppercase tracking-widest mb-1">{t.clock_out}</p>
<p className="text-lg font-bold text-white">{openEntryTaskName}</p>
</div>
<div className="space-y-4">
{/* Task */}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-2">
{t.select_task}
</label>
<div className="space-y-2">
<button
type="button"
onClick={() => setSelectedTaskId("")}
className={`w-full text-left px-4 py-3 rounded-xl border transition-colors ${
selectedTaskId === ""
? "border-emerald-500 bg-emerald-900/20 text-emerald-400"
: "border-zinc-700 bg-zinc-900 text-zinc-300 hover:border-zinc-600"
}`}
>
{t.general_labor}
</button>
{tasks.map(task => (
<button
key={task.id}
type="button"
onClick={() => setSelectedTaskId(task.id)}
className={`w-full text-left px-4 py-3 rounded-xl border transition-colors ${
selectedTaskId === task.id
? "border-emerald-500 bg-emerald-900/20 text-emerald-400"
: "border-zinc-700 bg-zinc-900 text-zinc-300 hover:border-zinc-600"
}`}
>
<span className="font-medium">{taskLabel(task, lang)}</span>
<span className="text-zinc-500 text-xs ml-2">({task.unit})</span>
</button>
))}
</div>
</div>
{/* Lunch */}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-2">
{t.lunch_break}
</label>
<input aria-label="Number"
type="number"
min={0}
max={120}
value={lunchMinutes}
onChange={e => 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"
/>
</div>
{/* Notes */}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-2">
{t.notes_placeholder}
</label>
<textarea aria-label="Textarea"
value={notes}
onChange={e => setNotes(e.target.value)}
rows={2}
className="w-full px-4 py-3 rounded-xl border border-zinc-700 bg-zinc-900 text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500 transition-colors resize-none"
/>
</div>
{error && (
<p className="text-red-400 text-sm text-center bg-red-900/20 border border-red-800/50 rounded-xl py-3 px-4">
{error}
</p>
)}
<button type="button"
onClick={onSubmit}
disabled={submitting}
className="w-full py-4 rounded-2xl font-bold text-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all"
>
{submitting ? "..." : t.submit_clock_out}
</button>
<button type="button"
onClick={onBack}
className="w-full py-3 rounded-2xl font-semibold text-sm text-zinc-400 hover:text-zinc-200 border border-zinc-700 hover:border-zinc-500 transition-all"
>
{t.cancel}
</button>
</div>
</div>
);
}
// ── Clocked Out Screen ─────────────────────────────────────────────────────────
function ClockedOutScreen({
t,
lang,
payPeriod,
clockOutResult,
onBackToPin,
}: {
t: Translations;
lang: "en" | "es";
payPeriod: PayPeriodHours | null;
clockOutResult: { clock_out: string; total_minutes: number };
onBackToPin: () => void;
logoUrl?: string | null;
}) {
return (
<div className="space-y-5">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-emerald-900/40 border border-emerald-700/50 mb-4">
<svg className="w-8 h-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm text-zinc-400 uppercase tracking-widest mb-1">{t.clock_out}</p>
<p className="text-2xl font-bold text-white">{formatTime(clockOutResult.clock_out)}</p>
<p className="text-zinc-400 text-sm mt-2">
{lang === "en" ? "Total time:" : "Tiempo total:"}{" "}
<span className="text-emerald-400 font-bold">{formatHours(clockOutResult.total_minutes)}</span>
</p>
</div>
{payPeriod && (
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 text-center">
<p className="text-xs text-zinc-500 uppercase tracking-widest mb-2">{t.hours_this_period}</p>
<p className="text-2xl font-black text-white">{formatHoursDecimal(payPeriod.total_hours)}</p>
{(payPeriod.daily_overtime || payPeriod.weekly_overtime) && (
<div className="flex justify-center gap-2 mt-2">
{payPeriod.daily_overtime && (
<span className="text-[10px] font-bold uppercase bg-amber-900/40 text-amber-400 border border-amber-700/50 rounded-full px-2 py-0.5">
{t.daily_overtime_hit}
</span>
)}
{payPeriod.weekly_overtime && (
<span className="text-[10px] font-bold uppercase bg-amber-900/40 text-amber-400 border border-amber-700/50 rounded-full px-2 py-0.5">
{t.weekly_overtime_hit}
</span>
)}
</div>
)}
</div>
)}
<button type="button"
onClick={onBackToPin}
className="w-full py-3 rounded-2xl font-semibold text-sm text-zinc-400 hover:text-zinc-200 border border-zinc-700 hover:border-zinc-500 transition-all"
>
{t.back_to_login}
</button>
</div>
);
}
// ── PIN Entry ─────────────────────────────────────────────────────────────────
function PinEntry({
t,
onSubmit,
error,
submitting,
logoUrl,
}: {
t: Translations;
onSubmit: (pin: string) => void;
error: string | null;
submitting: boolean;
logoUrl?: string | null;
}) {
const [pin, setPin] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (pin.length !== 4) return;
onSubmit(pin);
};
return (
<div className="space-y-8">
<div className="text-center">
<span className="relative inline-block h-14 w-auto">
{logoUrl ? <Image src={logoUrl} alt="Olathe Sweet" fill style={{ objectFit: "contain" }} className="mx-auto opacity-80 mb-3" /> : null}
</span>
<h1 className="text-2xl font-black text-white">{t.title}</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-2 text-center">
{t.enter_pin}
</label>
<input aria-label="Password"
type="password"
inputMode="numeric"
maxLength={4}
value={pin}
onChange={e => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder={t.pin_placeholder}
className="w-full text-center text-3xl font-mono tracking-widest py-5 rounded-2xl border border-zinc-700 bg-zinc-900 text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500 transition-colors"
autoComplete="off"
/>
</div>
{error && (
<p className="text-red-400 text-sm text-center bg-red-900/20 border border-red-800/50 rounded-xl py-3 px-4">
{error}
</p>
)}
<button
type="submit"
disabled={pin.length !== 4 || submitting}
className="w-full py-4 rounded-2xl font-bold text-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 disabled:cursor-not-allowed text-white transition-all"
>
{submitting ? "..." : t.clock_in}
</button>
</form>
</div>
);
}