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 TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient";
|
||||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||||
|
import {
|
||||||
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
TUXEDO_BRAND_ID,
|
||||||
const BRAND_NAME = "Tuxedo Corn";
|
TUXEDO_BRAND_NAME,
|
||||||
const BRAND_ACCENT = "green";
|
TUXEDO_BRAND_ACCENT,
|
||||||
|
} from "@/lib/water-log/brand";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Worker Clock — Tuxedo Corn",
|
title: `Worker Clock — ${TUXEDO_BRAND_NAME}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function TuxedoTimeClockPage() {
|
export default async function TuxedoTimeClockPage() {
|
||||||
@@ -15,9 +16,9 @@ export default async function TuxedoTimeClockPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TimeTrackingFieldClient
|
<TimeTrackingFieldClient
|
||||||
brandId={BRAND_ID}
|
brandId={TUXEDO_BRAND_ID}
|
||||||
brandName={BRAND_NAME}
|
brandName={TUXEDO_BRAND_NAME}
|
||||||
brandAccent={BRAND_ACCENT}
|
brandAccent={TUXEDO_BRAND_ACCENT}
|
||||||
logoUrl={logoUrl}
|
logoUrl={logoUrl}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
* Tuxedo Water Log experience.
|
* Tuxedo Water Log experience.
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* pin ─verify─▶ headgates ─pick─▶ log ─submit─▶ success
|
* pin ─verify─▶ tabbed shell
|
||||||
* ▲ │ │
|
* │
|
||||||
* │ └───────────────(logout)─────────────────┤
|
* ├──[Headgates]── headgates ─pick─▶ log ─submit─▶ success
|
||||||
* └─(logout)─────(anywhere)──────────────────────────────┘
|
* │ ▲ │
|
||||||
* │
|
* │ │ │
|
||||||
* success ── Log Another ─▶ log (cleared) ─┘
|
* │ └───────── (logout) ────────────┤
|
||||||
* success ── Back to Headgates ─▶ headgates
|
* │
|
||||||
|
* └──[Time]──── TimeTrackingFieldClient (clock-in / clock-out)
|
||||||
*
|
*
|
||||||
* Hydration:
|
* Hydration:
|
||||||
* - On mount we peek at `document.cookie` for the `wl_session`
|
* - 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
|
* who already signed in once in their shift shouldn't have to
|
||||||
* re-enter their PIN every time they reopen the page.
|
* 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:
|
* Server actions used:
|
||||||
* - `verifyWaterPin` → signs the irrigator in, sets cookie
|
* - `verifyWaterPin` → signs the irrigator in, sets cookie
|
||||||
* - `getWaterHeadgates` → loads the list of active gates
|
* - `getWaterHeadgates` → loads the list of active gates
|
||||||
* - `submitWaterEntry` → posts a new reading
|
* - `submitWaterEntry` → posts a new reading
|
||||||
* - `logoutWater` → clears the cookie + session row
|
* - `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 { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -34,13 +43,28 @@ import {
|
|||||||
getWaterHeadgates,
|
getWaterHeadgates,
|
||||||
logoutWater,
|
logoutWater,
|
||||||
} from "@/actions/water-log/field";
|
} 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 { MobileWaterPinScreen } from "./PinScreen";
|
||||||
import { MobileWaterHeadgateList } from "./HeadgateList";
|
import { MobileWaterHeadgateList } from "./HeadgateList";
|
||||||
import { MobileWaterLogForm } from "./LogForm";
|
import { MobileWaterLogForm } from "./LogForm";
|
||||||
import { MobileWaterSuccessScreen } from "./SuccessScreen";
|
import { MobileWaterSuccessScreen } from "./SuccessScreen";
|
||||||
import { WL_SESSION_COOKIE } from "@/lib/water-log/session";
|
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";
|
import { useLang } from "@/lib/water-log/lang-context";
|
||||||
|
|
||||||
// Server-side auth errors that should bounce the user to the PIN
|
// Server-side auth errors that should bounce the user to the PIN
|
||||||
@@ -56,6 +80,7 @@ const SESSION_ERRORS = [
|
|||||||
|
|
||||||
export function MobileWaterApp() {
|
export function MobileWaterApp() {
|
||||||
const [screen, setScreen] = useState<Screen>("pin");
|
const [screen, setScreen] = useState<Screen>("pin");
|
||||||
|
const [activeTab, setActiveTab] = useState<ActiveTab>("headgates");
|
||||||
// `useLang()` reads from the <LangProvider> in WaterFieldClient,
|
// `useLang()` reads from the <LangProvider> in WaterFieldClient,
|
||||||
// so it's always up-to-date when the user toggles via the toggle
|
// so it's always up-to-date when the user toggles via the toggle
|
||||||
// in the headgate list header.
|
// in the headgate list header.
|
||||||
@@ -125,6 +150,11 @@ export function MobileWaterApp() {
|
|||||||
setIrrigatorName(result.name ?? null);
|
setIrrigatorName(result.name ?? null);
|
||||||
setScreen("headgates");
|
setScreen("headgates");
|
||||||
void loadHeadgates();
|
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],
|
[loadHeadgates],
|
||||||
);
|
);
|
||||||
@@ -184,19 +214,47 @@ export function MobileWaterApp() {
|
|||||||
|
|
||||||
// ── Logout (from anywhere) ────────────────────────────────────
|
// ── Logout (from anywhere) ────────────────────────────────────
|
||||||
const handleLogout = useCallback(async () => {
|
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 {
|
try {
|
||||||
await logoutWater();
|
await logoutWater();
|
||||||
} catch {
|
} catch {
|
||||||
// Even if the server logout fails, clear local state so the
|
// Even if the server logout fails, clear local state so the
|
||||||
// user isn't stuck.
|
// user isn't stuck.
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await logoutTimeTracking();
|
||||||
|
} catch {
|
||||||
|
// Same — defensive cleanup.
|
||||||
|
}
|
||||||
setIrrigatorName(null);
|
setIrrigatorName(null);
|
||||||
setHeadgates([]);
|
setHeadgates([]);
|
||||||
setSelectedHeadgate(null);
|
setSelectedHeadgate(null);
|
||||||
setSubmittedLog(null);
|
setSubmittedLog(null);
|
||||||
|
setActiveTab("headgates");
|
||||||
setScreen("pin");
|
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 ──────────────────────────────
|
// ── Switch on the current screen ──────────────────────────────
|
||||||
// Compute the screen element first, then wrap it once in
|
// Compute the screen element first, then wrap it once in
|
||||||
// <LangProvider>. Wrapping per-case would remount the provider on
|
// <LangProvider>. Wrapping per-case would remount the provider on
|
||||||
@@ -258,11 +316,113 @@ export function MobileWaterApp() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
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
|
// ── Post-PIN shell ────────────────────────────────────────────
|
||||||
// WaterFieldClient — we just return the screen element here.
|
// The pre-PIN screen is bare (no nav). After PIN we wrap the
|
||||||
return screenElement;
|
// 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;
|
export default MobileWaterApp;
|
||||||
@@ -49,8 +49,16 @@ export function isIntegerUnit(unit: string): boolean {
|
|||||||
return unit === "holes";
|
return unit === "holes";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top-level screen identifiers — drives the state machine. */
|
/** Top-level screen identifiers — drives the state machine.
|
||||||
export type Screen = "pin" | "headgates" | "log" | "success";
|
*
|
||||||
|
* 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). */
|
/** A submitted log entry (only kept locally for the success screen). */
|
||||||
export type SubmittedLog = {
|
export type SubmittedLog = {
|
||||||
|
|||||||
@@ -11,4 +11,13 @@
|
|||||||
* `TUXEDO_BRAND_ID` with `effectiveBrandId` from `getAdminUser()` —
|
* `TUXEDO_BRAND_ID` with `effectiveBrandId` from `getAdminUser()` —
|
||||||
* which is the standard pattern used throughout the rest of the app.
|
* which is the standard pattern used throughout the rest of the app.
|
||||||
*/
|
*/
|
||||||
export const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
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;
|
minutesAgo: (n: number) => string;
|
||||||
hoursAgo: (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> = {
|
export const LABELS: Record<Lang, Labels> = {
|
||||||
@@ -231,6 +238,11 @@ export const LABELS: Record<Lang, Labels> = {
|
|||||||
minutesAgo: (n) => `${n}m ago`,
|
minutesAgo: (n) => `${n}m ago`,
|
||||||
hoursAgo: (n) => `${n}h ago`,
|
hoursAgo: (n) => `${n}h ago`,
|
||||||
},
|
},
|
||||||
|
tab: {
|
||||||
|
headgates: "Headgates",
|
||||||
|
time: "Time",
|
||||||
|
logout: "Log out",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
common: {
|
common: {
|
||||||
@@ -323,6 +335,11 @@ export const LABELS: Record<Lang, Labels> = {
|
|||||||
minutesAgo: (n) => `hace ${n}m`,
|
minutesAgo: (n) => `hace ${n}m`,
|
||||||
hoursAgo: (n) => `hace ${n}h`,
|
hoursAgo: (n) => `hace ${n}h`,
|
||||||
},
|
},
|
||||||
|
tab: {
|
||||||
|
headgates: "Compuertas",
|
||||||
|
time: "Tiempo",
|
||||||
|
logout: "Salir",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user