diff --git a/src/components/time-tracking/TimeTrackingFieldClient.tsx b/src/components/time-tracking/TimeTrackingFieldClient.tsx index 002b524..4cae2cc 100644 --- a/src/components/time-tracking/TimeTrackingFieldClient.tsx +++ b/src/components/time-tracking/TimeTrackingFieldClient.tsx @@ -188,6 +188,10 @@ type Translations = { history_today: string; history_yesterday: string; history_load_more: string; + + // Clocked-out CTA — keeping the session alive vs ending it + clocked_out_home_cta: string; + clocked_out_sign_out_cta: string; }; const TRANS_EN: Translations = { @@ -282,6 +286,9 @@ const TRANS_EN: Translations = { history_today: "Today", history_yesterday: "Yesterday", history_load_more: "Load more", + + clocked_out_home_cta: "Back to home", + clocked_out_sign_out_cta: "Sign out", }; const TRANS_ES: Translations = { @@ -376,6 +383,9 @@ const TRANS_ES: Translations = { history_today: "Hoy", history_yesterday: "Ayer", history_load_more: "Cargar más", + + clocked_out_home_cta: "Volver al inicio", + clocked_out_sign_out_cta: "Cerrar sesión", }; // ── Cookie helpers ───────────────────────────────────────────────────────────── @@ -815,9 +825,142 @@ export default function TimeTrackingFieldClient({ }; const handleBackToPin = () => { + // Session cookie is still valid — keep it. Just reset the + // in-memory per-shift state and bounce back to the PIN screen + // (the explicit "sign out" path). Same effect as before, just + // renamed for clarity. dispatch({ type: "RESET_FOR_LOGOUT" }); }; + // ── Hub navigation ──────────────────────────────────────────────────── + // Hub taps are a tiny state machine: "clock_in" sends the worker to + // task_select, "view_shift" goes straight to the live timer, + // "manual" opens the past-shift form, "history" opens the recent + // activity list. From each of those screens there's a "back to hub" + // path that lands here without re-auth. + const handleHubTap = ( + dest: "manual" | "history" | "clock_in" | "view_shift", + ) => { + dispatch({ type: "SET_ERROR", value: null }); + if (dest === "manual") { + dispatch({ type: "RESET_MANUAL_DRAFT" }); + dispatch({ type: "SET_SCREEN", value: "manual_entry" }); + return; + } + if (dest === "history") { + dispatch({ type: "SET_SCREEN", value: "history" }); + void loadRecentLogs(); + return; + } + if (dest === "view_shift") { + dispatch({ type: "SET_SCREEN", value: "working" }); + return; + } + // "clock_in" + if (tasks.length === 0) { + // Defensive: tasks should have been loaded at PIN submit, but + // re-fetch on demand so the picker isn't empty after a refresh. + void getTimeTrackingTasksField(brandId).then((ts) => { + dispatch({ type: "SET_TASKS", value: ts }); + dispatch({ type: "SET_SCREEN", value: "task_select" }); + }); + return; + } + dispatch({ type: "SET_SCREEN", value: "task_select" }); + }; + + // ── Recent activity loader ──────────────────────────────────────────── + const loadRecentLogs = async () => { + dispatch({ type: "SET_MANUAL_LOGS_LOADING", value: true }); + const result = await getRecentTimeLogs(brandId, 14); + if (result.success) { + dispatch({ type: "SET_MANUAL_LOGS", value: result.logs }); + } else { + dispatch({ + type: "SET_MANUAL_LOGS_ERROR", + value: result.error ?? "Couldn't load recent activity", + }); + } + }; + + // ── Manual entry submit ─────────────────────────────────────────────── + // Combines draft.date + draft.clockInTime into an ISO timestamp. + // Validates client-side (future / range / reason length) before + // hitting the server. Errors are surfaced via the SET_ERROR action + // and shown above the form by TimeTrackingScreens. + const handleSubmitManualLog = async () => { + dispatch({ type: "SET_ERROR", value: null }); + const d = state.manualEntryDraft; + + if (!d.date || !d.clockInTime || !d.clockOutTime) { + dispatch({ type: "SET_ERROR", value: t.manual_error_range }); + return; + } + const ci = new Date(`${d.date}T${d.clockInTime}:00`); + const co = new Date(`${d.date}T${d.clockOutTime}:00`); + if (ci > new Date()) { + dispatch({ type: "SET_ERROR", value: t.manual_error_future }); + return; + } + if (co <= ci) { + dispatch({ type: "SET_ERROR", value: t.manual_error_range }); + return; + } + const trimmedReason = d.reason.trim(); + if (trimmedReason.length < 3) { + dispatch({ type: "SET_ERROR", value: t.manual_error_reason }); + return; + } + const totalMin = Math.round((co.getTime() - ci.getTime()) / 60_000); + if (totalMin > 16 * 60) { + dispatch({ type: "SET_ERROR", value: t.manual_error_max }); + return; + } + const ageDays = Math.floor( + (Date.now() - ci.getTime()) / (24 * 60 * 60 * 1000), + ); + if (ageDays > 30) { + dispatch({ type: "SET_ERROR", value: t.manual_error_too_old }); + return; + } + + dispatch({ type: "SET_SUBMITTING", value: true }); + const result = await submitManualTimeLog({ + clockInIso: ci.toISOString(), + clockOutIso: co.toISOString(), + lunchBreakMinutes: d.lunchMinutes, + taskName: d.taskLabel || t.general_labor, + notes: d.notes || undefined, + reason: trimmedReason, + }); + dispatch({ type: "SET_SUBMITTING", value: false }); + if (!result.success) { + dispatch({ type: "SET_ERROR", value: result.error ?? t.manual_error_generic }); + return; + } + dispatch({ + type: "SET_MANUAL_ENTRY_RESULT", + value: { + log_id: result.log_id, + clock_in: result.clock_in, + clock_out: result.clock_out, + total_minutes: result.total_minutes, + }, + }); + dispatch({ type: "SET_SCREEN", value: "manual_entry_success" }); + void loadPayPeriodRef.current(); + }; + + const handleAnotherManualLog = () => { + dispatch({ type: "RESET_MANUAL_DRAFT" }); + dispatch({ type: "SET_SCREEN", value: "manual_entry" }); + }; + + const handleHome = () => { + dispatch({ type: "SET_SCREEN", value: "hub" }); + void loadPayPeriodRef.current(); + }; + const showOvertimeWarning = payPeriod && (payPeriod.daily_overtime || payPeriod.weekly_overtime); return ( @@ -834,6 +977,12 @@ export default function TimeTrackingFieldClient({ error={error} submitting={submitting} T={T} + workerName={sessionRef.current?.name ?? ""} + manualEntryDraft={state.manualEntryDraft} + manualEntryResult={state.manualEntryResult} + manualLogs={state.manualLogs} + manualLogsLoading={state.manualLogsLoading} + manualLogsError={state.manualLogsError} showOvertimeWarning={showOvertimeWarning ?? false} onToggleLang={toggleLang} onPinSubmit={handlePinSubmit} @@ -846,6 +995,12 @@ export default function TimeTrackingFieldClient({ onChangeSelectedTaskId={(v) => dispatch({ type: "SET_SELECTED_TASK_ID", value: v })} onChangeLunchMinutes={(v) => dispatch({ type: "SET_LUNCH_MINUTES", value: v })} onChangeNotes={(v) => dispatch({ type: "SET_NOTES", value: v })} + onChangeManualDraft={(v) => dispatch({ type: "SET_MANUAL_DRAFT", value: v })} + onHubTap={handleHubTap} + onSubmitManualLog={handleSubmitManualLog} + onAnotherManualLog={handleAnotherManualLog} + onHome={handleHome} + onReloadHistory={loadRecentLogs} /> ); } @@ -886,6 +1041,69 @@ type Presentation = { total_time: string; generic_error: string; forgot_hint: string; + + // Cycle 12 — Hub, Manual Entry, History (mirrors Translations keys) + hub_greeting: string; + hub_subtitle: string; + hub_logged_today: string; + hub_logged_today_none: string; + hub_manual_entry: string; + hub_manual_entry_sub: string; + hub_recent_activity: string; + hub_recent_activity_sub: string; + hub_quick_actions: string; + hub_sign_out: string; + hub_status_on: string; + hub_status_off: string; + hub_view_shift: string; + + manual_title: string; + manual_subtitle: string; + manual_section_when: string; + manual_section_who: string; + manual_section_why: string; + manual_date: string; + manual_clock_in_label: string; + manual_clock_out_label: string; + manual_task: string; + manual_task_placeholder: string; + manual_lunch: string; + manual_reason: string; + manual_reason_placeholder: string; + manual_notes: string; + manual_notes_placeholder: string; + manual_submit: string; + manual_submit_help: string; + manual_submitting: string; + manual_error_future: string; + manual_error_range: string; + manual_error_reason: string; + manual_error_max: string; + manual_error_too_old: string; + manual_error_generic: string; + + manual_success_title: string; + manual_success_subtitle: string; + manual_success_submitted: string; + manual_success_total: string; + manual_success_lunch: string; + manual_success_another: string; + manual_success_home: string; + + history_title: string; + history_subtitle: string; + history_loading: string; + history_empty_title: string; + history_empty_sub: string; + history_total_label: string; + history_manual_badge: string; + history_today: string; + history_yesterday: string; + history_load_more: string; + + // Clocked-out CTA — keeping the session alive vs ending it + clocked_out_home_cta: string; + clocked_out_sign_out_cta: string; }; function translationsFor(t: Translations): Presentation { @@ -1067,6 +1285,12 @@ function TimeTrackingScreens({ error, submitting, T, + workerName, + manualEntryDraft, + manualEntryResult, + manualLogs, + manualLogsLoading, + manualLogsError, showOvertimeWarning, onToggleLang, onPinSubmit, @@ -1079,6 +1303,12 @@ function TimeTrackingScreens({ onChangeSelectedTaskId, onChangeLunchMinutes, onChangeNotes, + onChangeManualDraft, + onHubTap, + onSubmitManualLog, + onAnotherManualLog, + onHome, + onReloadHistory, }: { lang: "en" | "es"; screen: Screen; @@ -1092,6 +1322,12 @@ function TimeTrackingScreens({ error: string | null; submitting: boolean; T: Presentation; + workerName: string; + manualEntryDraft: State["manualEntryDraft"]; + manualEntryResult: ManualEntryResult | null; + manualLogs: WorkerLogRow[]; + manualLogsLoading: boolean; + manualLogsError: string | null; showOvertimeWarning: boolean; onToggleLang: () => void; onPinSubmit: (pin: string) => Promise; @@ -1104,6 +1340,12 @@ function TimeTrackingScreens({ onChangeSelectedTaskId: (id: string) => void; onChangeLunchMinutes: (m: number) => void; onChangeNotes: (n: string) => void; + onChangeManualDraft: (v: State["manualEntryDraft"]) => void; + onHubTap: (dest: "manual" | "history" | "clock_in" | "view_shift") => void; + onSubmitManualLog: () => void; + onAnotherManualLog: () => void; + onHome: () => void; + onReloadHistory: () => void; }) { // PIN screen owns its full-bleed surface. Other screens sit on the // shared HIG grey with a frosted nav on top. @@ -1185,6 +1427,18 @@ function TimeTrackingScreens({ )} + {screen === "hub" && ( + + )} + {screen === "task_select" && ( )} + {screen === "manual_entry" && ( + + )} + + {screen === "manual_entry_success" && manualEntryResult && ( + + )} + + {screen === "history" && ( + + )} + {screen === "working" && openEntry && ( )} @@ -1798,12 +2085,14 @@ function ClockedOutScreen({ T, payPeriod, clockOutResult, - onBackToPin, + onHome, + onLogout, }: { T: Presentation; payPeriod: PayPeriodHours | null; clockOutResult: { clock_out: string; total_minutes: number }; - onBackToPin: () => void; + onHome: () => void; + onLogout: () => void; }) { // Same staggered ring + check reveal as Water's SuccessScreen, so // a worker who clocks out sees the same confirmation choreography @@ -2009,15 +2298,824 @@ function ClockedOutScreen({
- {T.back_to_login} + {T.clocked_out_home_cta} + {T.clocked_out_sign_out_cta}
); } + +// ── Hub Screen (Cycle 12) ───────────────────────────────────────────────────── +// +// Worker post-PIN home. Shows live status (when on the clock) or a +// "Clock in" CTA, plus two large tiles for Manual Entry and Recent +// Activity. This is where workers start *every* session — the task +// picker is one tap away via the hero card. + +function HubScreen({ + T, + lang, + workerName, + openEntry, + payPeriod, + onHubTap, + onLogout, +}: { + T: Presentation; + lang: "en" | "es"; + workerName: string; + openEntry: OpenEntry | null; + payPeriod: PayPeriodHours | null; + onHubTap: (dest: "manual" | "history" | "clock_in" | "view_shift") => void; + onLogout: () => void; +}) { + const onShift = !!openEntry; + const todayMinutes = payPeriod?.daily_hours != null + ? Math.round(payPeriod.daily_hours * 60) + : 0; + const todayStr = todayMinutes > 0 + ? `${formatHours(todayMinutes)} ${T.hub_logged_today}` + : T.hub_logged_today_none; + + return ( +
+ {/* Greeting / context header */} +
+

+ {T.brand.wordmark} +

+

+ {T.hub_greeting} + {workerName ? `, ${workerName.split(" ")[0]}` : ""} +

+

+ {T.hub_subtitle} +

+
+ + {/* Hero card — live status or call-to-action */} +
+ {/* glossy highlight */} +
+

+ {onShift ? T.hub_status_on : T.hub_status_off} +

+ + {onShift ? ( + <> +
+ + {formatHours(openEntry!.elapsed_minutes)} + + {T.elapsed} +
+

+ {T.clocked_in_at} {formatTime(openEntry!.clock_in)} +

+

+ {openEntry!.task_name} +

+ + + ) : ( + <> +
+ + {T.clock_in} + +
+

+ {todayStr} +

+ + + )} +
+ + {/* Quick actions — grouped list, single white card */} +
+

+ {T.hub_quick_actions} +

+ + onHubTap("manual")} + /> + onHubTap("history")} + last + /> +
+ +
+ {T.hub_sign_out} +
+
+ ); +} + +function HubTile({ + title, + subtitle, + onClick, + accent, + last, + icon, +}: { + title: string; + subtitle?: string; + onClick: () => void; + accent?: boolean; + last?: boolean; + icon: "edit" | "history"; +}) { + return ( + + ); +} + +// ── Manual Entry Screen ─────────────────────────────────────────────────────── + +function ManualEntryScreen({ + T, + draft, + onChangeDraft, + tasks, + lang, + onSubmit, + submitting, +}: { + T: Presentation; + draft: State["manualEntryDraft"]; + onChangeDraft: (v: State["manualEntryDraft"]) => void; + tasks: TimeTaskField[]; + lang: "en" | "es"; + onSubmit: () => void; + submitting: boolean; +}) { + // Local typing state — inputs are uncontrolled for date/time so iOS + // can render its native picker; everything else is controlled. + const setField = (patch: Partial) => { + onChangeDraft({ ...draft, ...patch }); + }; + + return ( +
+ {/* Header */} +
+

+ {T.brand.wordmark} +

+

+ {T.manual_title} +

+

+ {T.manual_subtitle} +

+
+ + {/* WHEN section */} +
+

+ {T.manual_section_when} +

+ + setField({ date: e.target.value })} + max={new Date().toISOString().slice(0, 10)} + className="w-full bg-transparent text-[15px] text-[#1d1d1f] outline-none" + style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif" }} + /> + + + setField({ clockInTime: e.target.value })} + className="w-full bg-transparent text-[15px] text-[#1d1d1f] outline-none" + style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif" }} + /> + + + setField({ clockOutTime: e.target.value })} + className="w-full bg-transparent text-[15px] text-[#1d1d1f] outline-none" + style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif" }} + /> + + + setField({ lunchMinutes: Math.max(0, Math.min(240, Number(e.target.value) || 0)) })} + className="w-full bg-transparent text-right text-[15px] text-[#1d1d1f] tabular-nums outline-none" + style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif" }} + /> + +
+ + {/* WHO section */} +
+

+ {T.manual_section_who} +

+ + setField({ taskLabel: e.target.value })} + className="w-full bg-transparent text-[15px] text-[#1d1d1f] outline-none placeholder:text-[rgba(60,60,67,0.4)]" + style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif" }} + /> + + + +
+ + {/* WHY section */} +
+

+ {T.manual_section_why} +

+ + setField({ reason: e.target.value })} + className="w-full bg-transparent text-[15px] text-[#1d1d1f] outline-none placeholder:text-[rgba(60,60,67,0.4)]" + style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif" }} + /> + + +