feat(water-log): cycle 4 — Tuxedo mobile worker Time tab + unified PIN
The mobile `/water` worker experience (Tuxedo-only, matching the
existing water flow) now exposes a "Time" tab alongside "Headgates"
so irrigators can clock in/out from the same phone they use for
water entries — no second PIN.
- MobileWaterApp: new `activeTab` state, sticky TabBar rendered only
after PIN, screen='time' case embeds <TimeTrackingFieldClient>
with hardcoded Tuxedo brand constants.
- handlePinSubmit best-effort calls `verifyTimeTrackingPin` after
the water PIN succeeds, so the Time tab never re-prompts a worker
who is also set up in `time_tracking_workers`.
- handleLogout clears BOTH `wl_session` and `time_tracking_session`
cookies in independent try/catch blocks.
- Tab switching to Time drops in-flight water flow state; switching
back to Headgates restores the list without reload.
- i18n: added `tab.{headgates,time,logout}` strings (en + es); lifted
Tuxedo brand display constants into @/lib/water-log/brand so the
standalone /tuxedo/time-clock page and the embedded Time tab share
one source of truth.
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient";
|
||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||
|
||||
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const BRAND_NAME = "Tuxedo Corn";
|
||||
const BRAND_ACCENT = "green";
|
||||
import {
|
||||
TUXEDO_BRAND_ID,
|
||||
TUXEDO_BRAND_NAME,
|
||||
TUXEDO_BRAND_ACCENT,
|
||||
} from "@/lib/water-log/brand";
|
||||
|
||||
export const metadata = {
|
||||
title: "Worker Clock — Tuxedo Corn",
|
||||
title: `Worker Clock — ${TUXEDO_BRAND_NAME}`,
|
||||
};
|
||||
|
||||
export default async function TuxedoTimeClockPage() {
|
||||
@@ -15,9 +16,9 @@ export default async function TuxedoTimeClockPage() {
|
||||
|
||||
return (
|
||||
<TimeTrackingFieldClient
|
||||
brandId={BRAND_ID}
|
||||
brandName={BRAND_NAME}
|
||||
brandAccent={BRAND_ACCENT}
|
||||
brandId={TUXEDO_BRAND_ID}
|
||||
brandName={TUXEDO_BRAND_NAME}
|
||||
brandAccent={TUXEDO_BRAND_ACCENT}
|
||||
logoUrl={logoUrl}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
* Tuxedo Water Log experience.
|
||||
*
|
||||
* Flow:
|
||||
* pin ─verify─▶ headgates ─pick─▶ log ─submit─▶ success
|
||||
* ▲ │ │
|
||||
* │ └───────────────(logout)─────────────────┤
|
||||
* └─(logout)─────(anywhere)──────────────────────────────┘
|
||||
* │
|
||||
* success ── Log Another ─▶ log (cleared) ─┘
|
||||
* success ── Back to Headgates ─▶ headgates
|
||||
* pin ─verify─▶ tabbed shell
|
||||
* │
|
||||
* ├──[Headgates]── headgates ─pick─▶ log ─submit─▶ success
|
||||
* │ ▲ │
|
||||
* │ │ │
|
||||
* │ └───────── (logout) ────────────┤
|
||||
* │
|
||||
* └──[Time]──── TimeTrackingFieldClient (clock-in / clock-out)
|
||||
*
|
||||
* Hydration:
|
||||
* - On mount we peek at `document.cookie` for the `wl_session`
|
||||
@@ -21,11 +22,19 @@
|
||||
* who already signed in once in their shift shouldn't have to
|
||||
* re-enter their PIN every time they reopen the page.
|
||||
*
|
||||
* Cycle 4 — the existing PIN is unified with the time-tracking PIN:
|
||||
* after `verifyWaterPin` succeeds we also best-effort call
|
||||
* `verifyTimeTrackingPin` with the same digits. If the worker exists
|
||||
* in `time_tracking_workers`, the time-tracking session cookie is set
|
||||
* too, so they never re-enter their PIN on the Time tab.
|
||||
*
|
||||
* Server actions used:
|
||||
* - `verifyWaterPin` → signs the irrigator in, sets cookie
|
||||
* - `getWaterHeadgates` → loads the list of active gates
|
||||
* - `submitWaterEntry` → posts a new reading
|
||||
* - `logoutWater` → clears the cookie + session row
|
||||
* - `verifyTimeTrackingPin` → unifies PIN with Time tab (Cycle 4)
|
||||
* - `logoutTimeTracking` → clears the time-tracking cookie on logout
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -34,13 +43,28 @@ import {
|
||||
getWaterHeadgates,
|
||||
logoutWater,
|
||||
} from "@/actions/water-log/field";
|
||||
import type { FieldHeadgate, Screen, SubmittedLog } from "./types";
|
||||
import {
|
||||
verifyTimeTrackingPin,
|
||||
logoutTimeTracking,
|
||||
} from "@/actions/time-tracking/field";
|
||||
import TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient";
|
||||
import type {
|
||||
FieldHeadgate,
|
||||
Screen,
|
||||
SubmittedLog,
|
||||
ActiveTab,
|
||||
} from "./types";
|
||||
import { MobileWaterPinScreen } from "./PinScreen";
|
||||
import { MobileWaterHeadgateList } from "./HeadgateList";
|
||||
import { MobileWaterLogForm } from "./LogForm";
|
||||
import { MobileWaterSuccessScreen } from "./SuccessScreen";
|
||||
import { WL_SESSION_COOKIE } from "@/lib/water-log/session";
|
||||
import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand";
|
||||
import {
|
||||
TUXEDO_BRAND_ID,
|
||||
TUXEDO_BRAND_NAME,
|
||||
TUXEDO_BRAND_ACCENT,
|
||||
TUXEDO_BRAND_LOGO_URL,
|
||||
} from "@/lib/water-log/brand";
|
||||
import { useLang } from "@/lib/water-log/lang-context";
|
||||
|
||||
// Server-side auth errors that should bounce the user to the PIN
|
||||
@@ -56,6 +80,7 @@ const SESSION_ERRORS = [
|
||||
|
||||
export function MobileWaterApp() {
|
||||
const [screen, setScreen] = useState<Screen>("pin");
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>("headgates");
|
||||
// `useLang()` reads from the <LangProvider> in WaterFieldClient,
|
||||
// so it's always up-to-date when the user toggles via the toggle
|
||||
// in the headgate list header.
|
||||
@@ -125,6 +150,11 @@ export function MobileWaterApp() {
|
||||
setIrrigatorName(result.name ?? null);
|
||||
setScreen("headgates");
|
||||
void loadHeadgates();
|
||||
// Cycle 4 — unify the PIN with Time Tracking. Best-effort: a
|
||||
// worker who isn't also set up as a time-tracking worker just
|
||||
// sees the regular PIN screen on the Time tab. Never blocks
|
||||
// the water flow, never surfaces a 500 to the user.
|
||||
void verifyTimeTrackingPin(TUXEDO_BRAND_ID, pin).catch(() => {});
|
||||
},
|
||||
[loadHeadgates],
|
||||
);
|
||||
@@ -184,19 +214,47 @@ export function MobileWaterApp() {
|
||||
|
||||
// ── Logout (from anywhere) ────────────────────────────────────
|
||||
const handleLogout = useCallback(async () => {
|
||||
// Cycle 4 — clear BOTH cookies. The water flow only knew about
|
||||
// `wl_session`; the Time tab introduced `time_tracking_session`.
|
||||
try {
|
||||
await logoutWater();
|
||||
} catch {
|
||||
// Even if the server logout fails, clear local state so the
|
||||
// user isn't stuck.
|
||||
}
|
||||
try {
|
||||
await logoutTimeTracking();
|
||||
} catch {
|
||||
// Same — defensive cleanup.
|
||||
}
|
||||
setIrrigatorName(null);
|
||||
setHeadgates([]);
|
||||
setSelectedHeadgate(null);
|
||||
setSubmittedLog(null);
|
||||
setActiveTab("headgates");
|
||||
setScreen("pin");
|
||||
}, []);
|
||||
|
||||
// ── Tab change (Cycle 4) ──────────────────────────────────────
|
||||
const handleTabChange = useCallback((next: ActiveTab) => {
|
||||
setActiveTab(next);
|
||||
if (next === "time") {
|
||||
// Switching to Time drops the in-flight water flow (selected
|
||||
// headgate + submitted log) so it doesn't linger when the
|
||||
// worker comes back to Headgates. The headgate list itself
|
||||
// is cached and will reload on demand.
|
||||
setSubmittedLog(null);
|
||||
setSelectedHeadgate(null);
|
||||
setScreen("time");
|
||||
} else {
|
||||
// Switching back to Headgates — restore the flow at the
|
||||
// headgate list (we always have that state cached; the
|
||||
// loadHeadgates effect picks up fresh data on mount).
|
||||
if (headgates.length === 0) void loadHeadgates();
|
||||
setScreen("headgates");
|
||||
}
|
||||
}, [headgates.length, loadHeadgates]);
|
||||
|
||||
// ── Switch on the current screen ──────────────────────────────
|
||||
// Compute the screen element first, then wrap it once in
|
||||
// <LangProvider>. Wrapping per-case would remount the provider on
|
||||
@@ -258,11 +316,113 @@ export function MobileWaterApp() {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case "time":
|
||||
// Cycle 4 — Tuxedo-only Time tab. Same brand id / name /
|
||||
// accent as the standalone /tuxedo/time-clock route so the
|
||||
// embedded surface matches the standalone one pixel-for-pixel.
|
||||
screenElement = (
|
||||
<TimeTrackingFieldClient
|
||||
brandId={TUXEDO_BRAND_ID}
|
||||
brandName={TUXEDO_BRAND_NAME}
|
||||
brandAccent={TUXEDO_BRAND_ACCENT}
|
||||
logoUrl={TUXEDO_BRAND_LOGO_URL}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// `initialLang` is computed by the parent <LangProvider> in
|
||||
// WaterFieldClient — we just return the screen element here.
|
||||
return screenElement;
|
||||
// ── Post-PIN shell ────────────────────────────────────────────
|
||||
// The pre-PIN screen is bare (no nav). After PIN we wrap the
|
||||
// screen element in a sticky tab bar so the worker can flip
|
||||
// between Headgates and Time without re-entering credentials.
|
||||
const isPostPin = screen !== "pin";
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{isPostPin && (
|
||||
<TabBar
|
||||
activeTab={activeTab}
|
||||
onChange={handleTabChange}
|
||||
onLogout={handleLogout}
|
||||
irrigatorName={irrigatorName ?? undefined}
|
||||
/>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">{screenElement}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TabBar (Cycle 4) ───────────────────────────────────────────
|
||||
// Sticky two-tab top nav for the Tuxedo worker experience.
|
||||
// Stays visible across Headgates → Log → Success AND on the Time
|
||||
// tab. Extracted as a presentational component (no server actions,
|
||||
// no effects) so it's pure and easy to iterate on visually.
|
||||
function TabBar({
|
||||
activeTab,
|
||||
onChange,
|
||||
onLogout,
|
||||
irrigatorName,
|
||||
}: {
|
||||
activeTab: ActiveTab;
|
||||
onChange: (next: ActiveTab) => void;
|
||||
onLogout: () => void;
|
||||
irrigatorName?: string;
|
||||
}) {
|
||||
const t = useLang().t;
|
||||
return (
|
||||
<div
|
||||
className="sticky top-0 z-30 flex items-center justify-between border-b border-[rgba(60,60,67,0.12)] bg-white/85 px-3 py-2 backdrop-blur"
|
||||
role="tablist"
|
||||
aria-label="Worker navigation"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "headgates"}
|
||||
onClick={() => onChange("headgates")}
|
||||
className={
|
||||
"rounded-full px-3 py-1 text-[13px] font-semibold transition " +
|
||||
(activeTab === "headgates"
|
||||
? "bg-[#1a4d2e] text-white shadow-sm"
|
||||
: "text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]")
|
||||
}
|
||||
>
|
||||
{t.tab.headgates}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "time"}
|
||||
onClick={() => onChange("time")}
|
||||
className={
|
||||
"rounded-full px-3 py-1 text-[13px] font-semibold transition " +
|
||||
(activeTab === "time"
|
||||
? "bg-[#1a4d2e] text-white shadow-sm"
|
||||
: "text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]")
|
||||
}
|
||||
>
|
||||
{t.tab.time}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{irrigatorName && (
|
||||
<span className="hidden text-[12px] font-medium text-[rgba(60,60,67,0.55)] sm:inline">
|
||||
{irrigatorName}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-semibold text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]"
|
||||
aria-label="Log out"
|
||||
>
|
||||
{t.tab.logout}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileWaterApp;
|
||||
@@ -49,8 +49,16 @@ export function isIntegerUnit(unit: string): boolean {
|
||||
return unit === "holes";
|
||||
}
|
||||
|
||||
/** Top-level screen identifiers — drives the state machine. */
|
||||
export type Screen = "pin" | "headgates" | "log" | "success";
|
||||
/** Top-level screen identifiers — drives the state machine.
|
||||
*
|
||||
* Cycle 4 adds "time" for the Tuxedo-only Time tab, which renders
|
||||
* `TimeTrackingFieldClient` instead of the existing water-entry
|
||||
* screens. The "pin" → "headgates"/"log"/"success" flow is unchanged.
|
||||
*/
|
||||
export type Screen = "pin" | "headgates" | "log" | "success" | "time";
|
||||
|
||||
/** Tuxedo-only Time-tab branding (Cycle 4). */
|
||||
export type ActiveTab = "headgates" | "time";
|
||||
|
||||
/** A submitted log entry (only kept locally for the success screen). */
|
||||
export type SubmittedLog = {
|
||||
|
||||
@@ -12,3 +12,12 @@
|
||||
* which is the standard pattern used throughout the rest of the app.
|
||||
*/
|
||||
export const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
/** Display surfaces (Cycle 4) — single source of truth for the
|
||||
* Tuxedo worker experience so the standalone `/tuxedo/time-clock`
|
||||
* page and the embedded Time tab inside `/water` stay in sync.
|
||||
* Bump these together when the brand refreshes its accent/logo. */
|
||||
export const TUXEDO_BRAND_NAME = "Tuxedo Corn";
|
||||
export const TUXEDO_BRAND_ACCENT = "green";
|
||||
/** `null` until the Tuxedo brand gets a logo asset uploaded. */
|
||||
export const TUXEDO_BRAND_LOGO_URL: string | null = null;
|
||||
@@ -138,6 +138,13 @@ export type Labels = {
|
||||
minutesAgo: (n: number) => string;
|
||||
hoursAgo: (n: number) => string;
|
||||
};
|
||||
/** Tuxedo worker post-PIN shell (Cycle 4) — names for the two
|
||||
* top tabs and the inline logout affordance. */
|
||||
tab: {
|
||||
headgates: string;
|
||||
time: string;
|
||||
logout: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const LABELS: Record<Lang, Labels> = {
|
||||
@@ -231,6 +238,11 @@ export const LABELS: Record<Lang, Labels> = {
|
||||
minutesAgo: (n) => `${n}m ago`,
|
||||
hoursAgo: (n) => `${n}h ago`,
|
||||
},
|
||||
tab: {
|
||||
headgates: "Headgates",
|
||||
time: "Time",
|
||||
logout: "Log out",
|
||||
},
|
||||
},
|
||||
es: {
|
||||
common: {
|
||||
@@ -323,6 +335,11 @@ export const LABELS: Record<Lang, Labels> = {
|
||||
minutesAgo: (n) => `hace ${n}m`,
|
||||
hoursAgo: (n) => `hace ${n}h`,
|
||||
},
|
||||
tab: {
|
||||
headgates: "Compuertas",
|
||||
time: "Tiempo",
|
||||
logout: "Salir",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user