31 Commits

Author SHA1 Message Date
Tyler 19377ea6cf feat(tuxedo): Apple-quality motion choreography
Deploy to route.crispygoat.com / deploy (push) Successful in 4m42s
Adds four new motion primitives in src/components/ui/ScrollAnimations.tsx
and wires them into the hero, PullQuoteBand, TheCorn, FounderStrip,
HarvestEditorial. Existing FadeOnScroll/ScrollReveal/ParallaxLayer are
left untouched so other pages (and the rest of Tuxedo) keep their
current vestibular-safe motion.

New primitives (all reduced-motion safe):
  AppleReveal       opacity 0→1 + Y 18→0 + blur(6px)→0
                    power3.out (≈ cubic-bezier(0.16, 1, 0.3, 1))
                    0.55s primary, 0.4s secondary, fires at 'top 88%'
                    one-shot toggleActions, supports immediate mount
  AppleStagger      wraps a set of [data-apple-stagger] children, fires
                    them with 0.06s stagger; ideal for editorial cascade
                    (eyebrow → h2 → rule → lede → supporting copy)
  AppleStickySection pins the first child to the top while siblings
                    scroll past; adds 6% scroll-linked yPercent drift for
                    depth. PullQuoteBand now uses this for the pre-storm
                    field photograph.
  AppleCountUp      refined count-up: power3.out, 1.6s. Replaces the
                    HarvestEditorial 2s linear-feeling counters.

Hero upgrades:
  - Title split into <span class='hero-word'> so each word cascades
    in with its own blur-up; logo → eyebrow → rule → lede → CTAs →
    stats get the block-level Apple reveal with 0.08s stagger.
  - Scroll-fade holds through the FULL hero scroll length
    (start top top, end bottom top, scrub 0.6) instead of fading by
    40% which made the type disappear before users finished reading.
  - Scroll-indicator fade uses scrub 0.5 (was 1.0) so it's physically
    tied to the scrollbar.
  - Logo uses plain div (was framer-motion); the GSAP pass animates
    all hero-reveal elements with a single consistent curve.

Editorial sections unified:
  TheCorn, FounderStrip, HarvestEditorial headers now use AppleStagger
  for their eyebrow → headline → rule → lede cascades. The harvest
  data strip uses AppleCountUp.

Also brings 'Upcoming Stops' into the magazine type system:
  font-display + clamp() H2, tracking-[0.32em] eyebrow, leading-[1.65]
  body — matches TheCorn/HarvestEditorial instead of the raw
  text-3xl/font-black treatment.
2026-07-07 09:36:35 -06:00
Nora c71354d2ae tuxedo: align storefront subpages with Field Almanac editorial voice
Deploy to route.crispygoat.com / deploy (push) Successful in 4m25s
Reskin /tuxedo/contact, /tuxedo/faq, /tuxedo/stops, and
/tuxedo/stops/[id] so they match the homepage's editorial
magazine language (Fraunces display serif, stone-50/stone-950
alternation, amber rims, hairline rules, tabular numerals).

- About page rewritten as inlined editorial sections sharing
  one WP_IMAGES library with the homepage; the four lazy-loaded
  Tuxedo about components are removed (Indian River has its own
  variants).
- Contact page now uses a dark hero with heroField half-bleed,
  a hairline-ruled info strip (address/phone/email), a two-column
  form section with bottom-border editorial inputs, and a closing
  Fraunces italic quote. /tuxedo/contact was 404ing in prod because
  page.tsx was missing — created and removed the duplicated render
  from layout.tsx.
- FAQ page uses a cream hero with Fraunces headline, an editorial
  bottom-border search, hairline-ruled accordion rows (no card
  wrappers), and a dark closing CTA with Contact pill.
- Stops list uses a dark hero with heroField bleed and a cream
  hairline-ruled sidebar list: tabular date numerals on the left,
  city/location middle, mono time + cutoff right, chevron for
  upcoming rows.
- Stop detail uses a dark hero pairing the city/state headline
  with a giant tabular day stamp, a cream hairline-ruled 4-col
  info strip (Date/Time/Location/Order by), and the editorial
  products header. Removed the dual-brand isBlue branching now
  that Indian River has its own page.
- Extracted TUXEDO_IMAGES to src/components/storefront/tuxedo-images.ts
  so the homepage, about, contact, and stops list share one
  brand-specific asset library.
- /tuxedo/faq/layout.tsx no longer double-renders the page
  component (was rendering <FAQClientPage /> alongside children).

Verified: tsc --noEmit clean, eslint clean (only pre-existing
homepage warnings), npm run build produces all Tuxedo routes
including the now-existing /tuxedo/contact.
2026-07-06 14:40:18 -06:00
Nora da09cfbd1a feat(tuxedo): editorial photo color grade across hero/pull-quote/founder/packshot
Deploy to route.crispygoat.com / deploy (push) Successful in 4m15s
Pulls the saturated-yellow WP-import JPGs down a half-step in the brightness hierarchy so the white type stays dominant and the photos read as editorial, not product photography.

- globals.css: new filter utilities (.filter-editorial, .filter-editorial-shell with cool/amber split-tone, .filter-editorial-strong, .filter-editorial-vignette) + reduced-motion reset
- TuxedoVideoHero: grade the Ken Burns backdrop image
- tuxedo/page.tsx: apply grades to PullQuoteBand, TheCorn ear macro, FounderStrip (also swap to the retouched founder image from b90f0fa), and HarvestEditorial packshot; bump founder quality 88 -> 92
2026-07-06 14:23:22 -06:00
Nora 68851cfede feat(tuxedo): magazine editorial — altitude ribbon, harvest spread, data strip
Deploy to route.crispygoat.com / deploy (push) Successful in 4m29s
Adds a 'Cereal-magazine-did-a-feature' editorial moment to the Tuxedo
homepage instead of a templated farm-stand layout. Three pieces:

1. Hero (TuxedoVideoHero)
   - Adds '5,360 FT · UNCOMPAHGRE VALLEY' masthead chip next to the
     existing eyebrow, hairline-divided like a magazine cover line.
     Reinforces the geographic / altitude voice of the brand.
   - Adds slow Ken Burns drift on the poster image (26s alternate,
     scale 1.02 → 1.10 + tiny translate). Plays under the video while
     loading; if the video fails, the poster now breathes instead of
     sitting static. Honors prefers-reduced-motion.
   - Bumps grain overlay opacity 0.04 → 0.085 for editorial texture.

2. New HarvestEditorial section (page.tsx), placed after FounderStrip
   and before StorySection per user direction.
   - Full-bleed packing-shed packshot (corn-field-zane-original.jpg,
     WP-import, 2500×1406) with subtle harvest-drift Ken Burns and a
     hairline-bordered provenance plate ('HARVEST MORNING · AUGUST ·
     OLATHE, CO') + photo credit.
   - Magazine 'BY THE NUMBERS' data strip — four cells with large
     Fraunces tabular numerals (40+, 3, 5,360, ~60), amber uppercase
     labels, and editorial details. Hairline amber rules top + bottom.
     Count-up animation reuses the existing .counter-animate GSAP
     setup so no new infra. No icons, no boxes, no color highlights —
     reads as a magazine sidebar.
   - Italic editorial caption in Fraunces with attribution: 'You can't
     replicate the diurnal swing in a greenhouse…' — John Harold,
     second generation.

3. globals.css: adds the @keyframes harvest-drift + reduced-motion
   guard for the new packshot drift (26s alternate, scale 1.04 → 1.10).

Verified on prod 2026-07-06:
  - No failed network requests
  - Hero altitude ribbon text present
  - HarvestEditorial section renders at expected scroll position
  - Packshot: complete=true, natural 1280×720, rendered 1232×587
  - All 4 data points present with correct labels & details
  - Counter values animate 0 → target when section enters viewport
  - Mobile (390px) renders cleanly with correct wrap
2026-07-06 13:55:48 -06:00
Nora b90f0fae52 fix(tuxedo): copy public/ to standalone, mark storm/founder priority
Deploy to route.crispygoat.com / deploy (push) Successful in 4m17s
Three issues caused the 'images don't work, video doesn't play' report on
https://water.tuxedocorn.com/tuxedo:

1. (ROOT CAUSE) Next.js standalone build was missing .next/standalone/public/.
   The deploy workflow copied .next/static to .next/standalone/.next/static
   but never mirrored public/. Without that, the image optimizer returns
   400 'url parameter is not allowed' on every local-relative URL — so the
   brand logo (and favicon, etc.) went blank. Symptom: HTTP 400 on
   /_next/image?url=%2Fbrand-logos%2F64294306-...%2Flogo.png.

   Fixed by:
   - cp -r public/. .next/standalone/public/ on the running server (live)
   - updating .gitea/workflows/deploy.yml to mirror public/ on every deploy
   - adding scripts/regression-brand-logos.mjs so this can't regress silently

2. Storm-field and founder-portrait images rendered as 'loading=lazy' and
   appeared blank above the fold. Marked priority so they preload.

3. Pre-existing build error: Next.js 16.2 RSC bundler treats
   'export type { FieldSession }' re-exports through server-action modules
   as runtime exports ('Export FieldSession doesn't exist in target
   module'). Removed the re-exports in water-log/auth.ts and field.ts;
   field.ts now imports FieldSession directly from @/lib/water-log/session-server.

Verified live (post-fix) on 2026-07-06:
  - regression-brand-logos.mjs: HTTP 200 image/png, header+footer logos
    render with naturalWidth=160
  - diag-home-only.mjs: all 6 images complete=true, natural sizes match
    source (storm 1440x1080, founder 604x340, ear 692x476, etc.)
  - verify-hero-video.mjs: readyState=4, paused=false, currentTime
    advancing 2.86s -> 6.87s over 4s (=30fps; the prior 'video doesn't
    play' was likely user perception — the poster+video look near-
    identical, and 'net::ERR_ABORTED' on the MP4 is normal Chrome
    behavior for parallel byte-range requests)
2026-07-06 13:33:54 -06:00
Nora 5a72705e71 fix(ops): switch pm2 to standalone server + fix wholesale_settings query
Deploy to route.crispygoat.com / deploy (push) Successful in 4m14s
Three problems were silently breaking the prod Tuxedo redesign and
the offline DB error swallowed all visibility into them:

1. `next start` against `output: "standalone"` is unsupported. Next.js
   prints "next start does not work with output: standalone" and
   silently disables the image optimizer — every `/_next/image?url=...`
   returned "url parameter is not allowed". pm2 was previously
   started as `pm2 start npm -- start -- -p 3100`. Switched to
   `node /home/tyler/route-commerce/scripts/start-standalone.cjs`.

2. The standalone server reads `.next/static/` relative to itself, so
   deploy must `cp -r .next/static .next/standalone/.next/static` on
   every sync. Without this, every `_next/static/chunks/*.js` returns
   404 and the page hydration dies. Added to the deploy workflow.

3. bash's `set -a; . ./.env` truncates DATABASE_URL at the `&` in
   `&channel_binding=require`, so the standalone server boots with
   empty DB env. `getBrandSettingsPublic` then caught the empty-pool
   error and returned `{success:false, error:'Failed to fetch brand settings'}`,
   so `state.heroImageUrl` stayed null and the hero poster never
   rendered. The new `scripts/start-standalone.cjs` parses .env in
   Node (which handles `&` correctly) and exec's the server with the
   loaded env.

4. `getBrandSettingsPublic` queried `ws.wholesale_enabled`, which
   no longer exists in `wholesale_settings`. The schema migration
   renamed it to `online_payment_enabled`. Switched the column
   reference and added a console.error log so future drift surfaces
   instead of being swallowed by the empty catch.

Plus `next.config.ts` had `hostname: "s3.crispygoat.com"` added
under `images.remotePatterns` so the optimizer accepts MinIO URLs.
2026-07-06 12:50:43 -06:00
Nora 129c9d253c feat(tuxedo): polish home with WP imagery
Deploy to route.crispygoat.com / deploy (push) Successful in 4m22s
Swap the templated 8-tile Why-grid for three image-driven editorial
moments using photos pulled from the tuxedocorn.com WordPress library:

  - PullQuoteBand: full-bleed pre-storm field with single Fraunces quote
  - TheCorn: single paragraph + ear macro, replaces feature grid
  - FounderStrip: John Harold portrait + family narrative

Hero poster switched from the tight cob macro to the wide irrigation
row with Colorado sky and cumulus clouds. The video underneath stays.

Editorial copy tightened. Display type moved to Fraunces for headlines
and timeline markers to give the page a real voice instead of
default sans throughout. Tone: "you can taste the altitude."
2026-07-06 12:03:36 -06:00
Grok f8434685a8 fix(migration): drop GRANT anon line (Neon has no anon role)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m7s
2026-07-06 11:19:33 -06:00
Grok 60783131ae fix(tuxedo): graceful hero video fallback + storefront polish
Deploy to route.crispygoat.com / deploy (push) Failing after 1m27s
Hero video at /videos/tuxedo-hero.mp4 was hardcoded and 404ing, leaving
the page with no background.

* Add brand_settings.hero_video_url (migration 0099) so each brand
  can point its hero at any reachable mp4/webm without code changes.
  Saves via a separate UPDATE so the existing 32-param
  upsert_brand_settings RPC signature stays untouched.
* TuxedoVideoHero now accepts videoUrl + posterUrl. Poster renders
  underneath the <video> element via next/image so the hero is
  complete even without a video. <video> flips a data-failed attribute
  on error/stall so the poster takes over without DOM churn.
  Ambient float animations slowed 950ms → 7s/8s.
* Why section: dropped emerald/amber kaleidoscope, unified accent.
  Footer is now a provenance strip (Family-Grown · 5,360 ft · Est 1982).
* Story section: removed giant decorative 'SINCE' parallax text and
  the duplicate stats counter (already in hero). Replaced with a
  1982/2003/Today timeline.
2026-07-06 11:09:31 -06:00
Tyler 772e23ded8 fix(pwa): bump field-shell cache to v2 — evicts stale UI for installed PWAs
Deploy to route.crispygoat.com / deploy (push) Successful in 4m7s
The Apple HIG polish pass (liquid-glass chrome, iOS SegmentedControl,
ThresholdMeter) was already on origin/main and live, but anyone with
the /water PWA installed on their home screen kept seeing the OLD UI
because the SW cache name never bumped.

Old SW had field-shell-v1 baked into its bytes for every prior deploy.
The activate handler only deletes caches that aren't SHELL_CACHE or
DATA_CACHE and start with 'field-'. Same name = no eviction = stale
HTML + chunks forever.

Bumping SHELL_CACHE to 'field-shell-v2' makes the SW bytes change,
which triggers the normal SW upgrade flow:
  install → skipWaiting → activate → claim clients
The activate handler then evicts field-shell-v1 (it's now a foreign
field-* cache). On next navigation the new SHELL_CACHE is repopulated
with the fresh /water HTML + static assets, and the user gets the new
UI.
2026-07-06 10:39:52 -06:00
Tyler 5dc9a4604f Apple HIG pass: liquid-glass chrome + iOS SegmentedControl + ThresholdMeter
Deploy to route.crispygoat.com / deploy (push) Successful in 4m22s
Water log mobile field experience (PIN → Headgates → Log → Success):

* Brand rename: "Tuxedo Ditch Co." → "Tuxedo Corn" everywhere it
  was a stale reference (page metadata + admin Today tab caption).
  Constants already say Tuxedo Corn; this just aligns the stragglers.

* Tab bar (Headgates | Time): pill pair → real UISegmentedControl.
  MiniSegmented lives next to TabBar in MobileWaterApp.tsx and uses
  the same ResizeObserver-measure + spring-timing pattern as the
  existing SegmentedControl — feels like one widget at two sizes
  (this one at 32pt, the unit selector at 44pt).

* Liquid-glass chrome (recipe applied 5×, mirrored on the bottom
  submit bar): linear-gradient background + blur(24px) +
  saturate(180%) + inset top highlight + inset bottom hairline.
  TabBar, HeadgateList top nav, LogForm top nav, LogForm sticky
  submit bar, FieldPinScreen + SuccessScreen brand strips.

* ThresholdMeter (new component, mobile/ThresholdMeter.tsx): the
  screen's signature. A live "blip" indicator under the 44pt
  measurement input that ties the form to its real domain (a
  worker reading a real gauge) instead of being a decorative
  gauge trophy. Track + optional colored zones (red·green·red)
  + thumb that slides on Apple's spring curve + color-coded
  zone label. Mirrors Apple's Volume Limit / Screen Time sliders.

* i18n: 9 new zone labels per language (zoneBelowNormal,
  zoneAboveAlert, zoneInRange, zoneBelowAlert, zoneAboveMin,
  zoneReading, zoneTypeToLog, meterNormalRange + ES twins).
  Legacy normalRange/alertAbove/alertBelow signatures widened
  to accept pre-formatted strings so integer units render clean.

* Updated water-log-i18n unit test for the wider signatures.
  All 27 tests pass; tsc + eslint clean.
2026-07-06 10:32:36 -06:00
Nora 5188960bd2 fix(time-tracking): keep workers signed in for 7 days
Deploy to route.crispygoat.com / deploy (push) Successful in 3m42s
Field workers were forced to re-enter their PIN every 12 hours. That's
noticeable friction on devices that stay signed in for a week or more.
Bumped COOKIE_MAX_AGE to 7 days (604800s) so a worker who clocks out
on Friday doesn't see the PIN screen Monday morning.

The cookie is httpOnly + secure (in prod) + sameSite=lax, so the
extended lifetime doesn't materially change the threat surface. The
expires_at inside the cookie value is recomputed from the same
constant, so the in-cookie expiry and the browser-side Max-Age agree.

Field workers still log out explicitly via the Hub 'Sign out' tile
(or the server-side /api time-tracking/notify handler) when they
want to end the session sooner. Forced re-auth only happens when the
cookie actually expires or the worker logs out.
2026-07-06 10:14:00 -06:00
Nora 6f1aa37a26 fix(time-tracking): inherit lang from water-log and stop PIN flash
Deploy to route.crispygoat.com / deploy (push) Successful in 4m18s
Two UX bugs in the worker Time Tracking mobile flow:

1) Spanish parity between Water Log and Time Tracking. The Water Log
   mobile app (/water) uses the wl_lang cookie for its language; the
   Time Tracking field client used its own time_tracking_lang cookie.
   A worker who set Spanish on /water then tapped the Time tab saw
   English. getLangCookie() now falls back to wl_lang when
   time_tracking_lang is unset, so the Time tab inherits the worker's
   Water Log preference. Standalone /tuxedo/time-clock is unaffected
   (no wl_lang cookie there).

2) PIN screen flash on tab change. initialState started at screen
   'pin', so the init effect that resolves the session cookie + open
   clock-in caused a one-tick flash of the PIN screen even when the
   worker was already authenticated. initialState now starts at
   'loading' (a spinner) and the init effect dispatches the correct
   screen (hub / working / pin) once the session check resolves.

Public contract unchanged: exported types, function signatures, and
the cookie name on writes are all preserved. The change is additive
on read (extra cookie fallback) and a one-key swap on initial state.

Verified locally: tsc clean, lint unchanged (354 problems = baseline),
vitest 226 pass / 29 fail (same pre-existing auth failures, none
related to this change).
2026-07-06 10:05:55 -06:00
Nora c7c83890e4 fix(time-tracking): use correct field_workers columns in fleet summary
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
The /admin/time-tracking overview page crashed on render with
`column "fw.display_name" does not exist`. Cycle 10 unified
`time_tracking_workers` + `water_irrigators` into `field_workers`,
which uses `name` and `pin_hash` (not the legacy `display_name` /
`pin` from `admin_users`). This fix aligns the SQL, row type, and
result mapper with the actual schema.

End-to-end verified against water.tuxedocorn.com with worker PIN 2229:
PIN auth, task picker, clock-in, and clock-out all succeed with no
console errors and no failed HTTP requests.

Includes a regression test that fails on the buggy SQL (4/8 fail) and
passes after the fix (8/8 pass). Covers the column contract, row
projection, empty-result + unauthenticated boundaries, and null
aggregate coercion.
2026-07-06 09:56:13 -06:00
Nora e98bbc220f fix(time-tracking): accept irrigator/driver roles in PIN verify
Deploy to route.crispygoat.com / deploy (push) Successful in 4m35s
The /water unified entrypoint (MobileWaterApp) calls verifyTimeTrackingPin
best-effort right after verifyWaterPin. If the role filter inside
verifyTimeTrackingPin only accepted 'worker' and 'time_admin', then:

1. Worker enters PIN at /water
2. verifyWaterPin succeeds → wl_session cookie set
3. verifyTimeTrackingPin silently no-ops (role mismatch) → no
   time_tracking_session cookie
4. User taps the Time tab → TimeTrackingFieldClient mounts → init()
   finds no time-tracking session → dispatches 'pin' screen
5. User re-enters the same PIN → verifyTimeTrackingPin rejects it
   again with 'Invalid PIN'

The unified-PIN flow depended on the same PIN being accepted by both
verifiers, which only works if the role filter here matches every
clocking-in role. Expanding to ('worker', 'time_admin', 'irrigator',
'driver') covers all field crew including the legacy 'irrigator'
role (cycle 10 unified water+time workers but kept the role label)
and the cycle 12 'driver' role.

'water_admin' and 'supervisor' stay excluded — they're admins who
manage / approve, not clocking-in field workers.
2026-07-04 01:04:05 -06:00
Nora 3db642e76b fix(time-tracking): Recent Activity reads from session brand; add cycle 12 smoke scripts
getRecentTimeLogs was still receiving the page-level brand constant, so
Recent Activity read 'No shifts yet' even when manual entries existed
for the worker's actual brand. Mirror the getOpenClockIn /
getWorkerPayPeriodHours / submitManualTimeLog pattern: prefer
session.brand_id over the page prop so the Hub, manual-entry, and
history surfaces all see the same data regardless of which brand slug
the URL carries.

Adds scripts/cycle12-flow.mjs and scripts/cycle12-submit.mjs as
end-to-end coverage for Hub → Manual Entry → Recent Activity → Submit,
so the brand_id wiring stays regression-protected.
2026-07-04 01:02:00 -06:00
Nora 9d0e325c26 feat(time-tracking): wire Hub / Manual Entry / Recent Activity screens into the field app
Deploy to route.crispygoat.com / deploy (push) Successful in 4m38s
The cycle 12 Hub + Manual Entry + Recent Activity surfaces were defined
(HubScreen, ManualEntryScreen, ManualEntrySuccessScreen, HistoryScreen)
and the reducer / state / server actions were all wired, but the render
branches in TimeTrackingScreens were never connected — so a worker
hitting the field app on a valid session got a blank Hub, and after
clock-out they were dumped back on the PIN screen even though the
session cookie was still valid.

This commit reconnects the four missing screens:

- hub           → HubScreen (greeting, live status hero, three big
                   action tiles: Clock In / Out, Manual Entry, Recent
                   Activity + a small Sign-out chip)
- manual_entry  → ManualEntryScreen (date / clock in / clock out /
                   lunch / task / reason / notes, client-side
                   validation, calls submitManualTimeLog)
- manual_entry_success → ManualEntrySuccessScreen (receipt card +
                   Add another / Back to home)
- history       → HistoryScreen (date-grouped last-14-days list
                   from getRecentTimeLogs)

Main component gains:
- handleHubTap(dest) — small state machine for the hub tiles
- handleSubmitManualLog() — validates the draft, calls the server,
  stores ManualEntryResult, navigates to success
- handleAnotherManualLog() / handleHome() — round-trip back into the
  manual flow / hub
- loadRecentLogs() — fires on history-screen entry, dispatches the
  SET_MANUAL_LOGS_* actions

ClockedOutScreen no longer dumps the worker on the PIN screen:
- 'Back to PIN entry' → 'Back to home' (lands on hub, session stays)
- New 'Sign out' ghost button for the explicit log-out path

Two new translation keys (en + es):
- clocked_out_home_cta: 'Back to home' / 'Volver al inicio'
- clocked_out_sign_out_cta: 'Sign out' / 'Cerrar sesión'

Verification
- npx tsc --noEmit  → 0 errors
- npm run build     → green
- local 200 on /tuxedo/time-clock (dev server)
2026-07-04 00:57:32 -06:00
Nora 34d88d594f fix(time-tracking): populate missing hub/manual/history translations + ignore debug scripts
Deploy to route.crispygoat.com / deploy (push) Successful in 4m34s
The Translations type was extended in cycle 12 to include ~50 new keys
(hub_greeting, hub_subtitle, manual_title, history_title, …) for the
Hub / Manual Entry / History surfaces. TRANS_EN and TRANS_ES were never
populated with those keys, so the typecheck on the deploy runner failed
at line 193 (and the Spanish variant at line 229) with:

  Type '...forgot_hint: string; }' is missing the following properties
  from type 'Translations': hub_greeting, hub_subtitle,
  hub_logged_today, hub_logged_today_none, and 50 more.

Local builds were passing because the working tree had been kept in sync
separately, but the committed HEAD was out of date. Sync everything.

Also picks up the field.ts patch that strips input.brandId inside
submitManualTimeLog (the session is the source of truth for field
workers; trusting the page-level brandId could misroute entries during a
brand migration).

.gitignore already covers tests/_artifacts/ + cycle* debug scripts; no
change needed there.
2026-07-04 00:42:23 -06:00
Nora 06aac296f1 feat(time-tracking): wire GPS capture into field clock-in/out (cycle 12 polish)
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
Adds captureGps() helper to TimeTrackingFieldClient and threads it into
handleSelectTask + handleClockOut. Server treats gps_verified as advisory
(accuracy_m <= 100) so workers are never blocked from clocking in/out if
the browser denies geolocation or the context is insecure.

Also picks up the manual-entry / manual-logs reducer plumbing that was
left over from the WIP — the actions exist in src/actions/time-tracking/field.ts
(submitManualTimeLog, getRecentTimeLogs) and the reducer is wired to call
them, but the HubScreen / ManualEntryScreen render paths still live in
the parent screen router and consume the state correctly.
2026-07-04 00:27:45 -06:00
Nora 9e6accb3df feat(time-tracking): timesheet workflow, manual entry, GPS, offline, payroll export (cycle 12)
Phase 2 of the time-tracking buildout — turns the existing clock widget
into a full payroll-ready system for Drivers / Supervisors / Admins.

Roles
- field_workers CHECK extended with 'driver' and 'supervisor' alongside
  the existing worker / time_admin / irrigator / water_admin values.

Schema (migration 0098)
- time_tracking_logs gains clock_in/out lat/lng/accuracy_m + gps_verified
  + entry_kind ('clock' | 'manual') + manual_reason + edit audit columns.
- new time_tracking_timesheets (draft → submitted → approved/locked →
  rejected, with unlock path for admins).
- new time_tracking_audit_log (append-only).
- new time_tracking_supervisor_assignments (supervisor ↔ supervisee edges).
- new SECURITY DEFINER RPCs: recompute_timesheet_totals(),
  get_or_create_timesheet(), plus a time_tracking_approval_queue view.

Workflow + locking (src/actions/time-tracking/timesheets.ts)
- resolveCaller() returns platform_admin / brand_admin / supervisor /
  worker context; supervisor scope is enforced via the assignments table.
- submitTimesheet / approveTimesheet / rejectTimesheet / unlockTimesheet
  all run inside withTx with FOR UPDATE row locks so the status + audit
  row + recompute RPC commit atomically.
- Editing or deleting an entry on an approved timesheet is blocked at
  the action layer; only unlockTimesheet (admin-only, mandatory audit
  note) can re-open it.

Manual entry (src/actions/time-tracking/entries.ts)
- addManualTimeEntry / editTimeEntry / deleteTimeEntry with Zod validation,
  audit-log writes in the same transaction, lock-aware.

GPS + offline (src/actions/time-tracking/field.ts,
  src/lib/offline/time-tracking-queue.ts,
  src/actions/time-tracking/offline-handlers.ts)
- captureGps() in the field client: best-effort geolocation, never blocks
  clock-in/out (gps_verified = accuracy_m <= 100).
- tryOrQueueClock() wraps the action; on network failure the event lands
  in IndexedDB and replays via replayOfflineClock() (PIN re-verified on
  replay, submitted_via = 'offline_sync').

Export (src/actions/time-tracking/export.ts +
  src/app/api/time-tracking/timesheets/[id]/export/route.ts)
- PDF (pdf-lib) + CSV (papaparse) payroll export with signature lines,
  available for any approved timesheet.

UI
- /admin/time-tracking with tab nav (Overview / Timesheets / Approvals /
  Audit).
- TimesheetsList, TimesheetDetail (status-driven workflow buttons,
  manual entry form, audit trail), ManualEntryForm.
- FleetRouteSummary on the time-tracking overview shows the full crew's
  water + time totals for the day.
- DailyRouteSummary on /admin/water-log/users/[id] shows the per-worker
  cross-link badge (water gallons + time hours + timesheet status).

Verification
- npx tsc --noEmit  → 0 errors.
- npm run build     → all 10 time-tracking routes compile.
- npm run lint      → 0 errors/warnings in new files.
- Playwright smoke  → 4/5 pass; the failing case is a pre-existing
  Tuxedo storefront 404 unrelated to this change.
2026-07-04 00:27:25 -06:00
Nora eb0da05037 feat(field): cycle 11 — visual cohesion for time-tracking + water-log
Deploy to route.crispygoat.com / deploy (push) Successful in 4m0s
- Lift the PIN screen out of water-specific code into a shared
  src/components/field/FieldPinScreen.tsx used by both apps.
  Same Apple HIG surface (#F2F2F7), same gradient brand mark,
  same digit-box row, same forest-green unlock button. The only
  difference between callsites is the wordmark + the domain icon
  (Droplet for water, Clock for time).

- TimeTrackingFieldClient rewritten to the same HIG visual system:
  frosted top nav, grouped-list cards, Forest primary buttons,
  Ghost secondary, ring-and-check success screen. Behavior
  (reducer/handlers/lang toggle) unchanged.

- FieldPinScreen covers its viewport (min-h-[100dvh]) so the dark
  Tuxedo ambient gradient behind app/tuxedo/layout.tsx no longer
  shows through on /tuxedo/time-clock.

- Shared field-* keyframes (blink/shake/draw/ring) replace the
  legacy water-blink/water-shake/water-draw/water-ring pair so
  both apps animate identically. SuccessScreen updated to use the
  new field-* names.

- Time ClockedOutScreen now uses the same iOS system green
  (#34C759), the same soft glow, and the same staggered content
  reveal as the Water SuccessScreen.

- No new lint errors or warnings (335 problems = baseline). tsc
  clean.
2026-07-03 20:54:54 -06:00
Nora 16ad0955f2 feat(workers): unify water + time worker tables into field_workers (cycle 10)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
The two per-domain worker tables (water_irrigators, time_tracking_workers)
both carried a pin_hash, a brand, a role, and almost-identical columns.
Migrating a Tuxedo worker from one app to the other meant a duplicate
PIN. Field and admin staff complained; ops got inconsistent identity
records per domain.

Unify into a single field_workers table. Cycle 10 migration
0097_field_workers.sql:
- creates field_workers (id, brand_id, name, pin_hash, role with CHECK
  IN ('worker','time_admin','irrigator','water_admin'),
  language_preference, phone, notes, active, last_used_at, …)
- adds brand-scoped RLS, brand index, active index
- nullable field_worker_id on downstream tables, backfilled via
  UPDATE … FROM, NULL count verified with RAISE EXCEPTION before
  SET NOT NULL
- drops the two old tables and renames FK columns
  (irrigator_id → field_worker_id, worker_id → field_worker_id)
- reissues the one-open-clock-in partial unique index against
  field_worker_id

Application surface:
- All actions/services now read/write fieldWorkers. Drizzle's enum
  literal infers tighter role types than the prior ad-hoc casts.
- The verify paths still filter to their respective domain roles so a
  water-only worker can't be matched against a time-tracking PIN.
- API surface preserved: action function names didn't change.
- Seed file updated so QA reseeds work post-deploy.

Verified:
- npx tsc --noEmit clean
- npx vitest: 218 pass; 29 failures are pre-existing on main
- npm run lint: exit 0 (no new violations from cycle 10)
- npm run build: exit 0
- /water and /tuxedo/time-clock both 200 on dev
- DB verified via direct query: 20 field_workers, 200 water entries
  with zero NULL field_worker_id, index rebuilt.

PR-reviewer: APPROVED.
2026-07-03 20:27:11 -06:00
Nora ce652ff5de test(time-tracking): unit tests for cron route, drain summary, workspace token (cycle 9)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m17s
Closes the test-coverage gap flagged by pr-reviewers in cycles 7
and 8. Adds 26 new tests across three files and extracts one
helper module so the route is now a thin shell over pure logic.

- New: src/services/sync-drain-summary.ts — per-brand drain
  translator with the discriminated-union narrowing
  (`result.skipped === true`) the cycle-8 reviewer flagged as
  fragile. Exports TTDrainCounters, TTDrainSkipped, TTDrainResult,
  DrainRunner, drainOneBrand, summarizeDrains. import server-only.
- New: tests/unit/resolve-workspace-token.test.ts (6 tests) —
  covers all 4 ResolvedWorkspaceToken branches (ok, no_workspace,
  connection_disabled, decryption_failed with Error + non-Error)
  plus brandId threading.
- New: tests/unit/sync-drain-summary.test.ts (7 tests) —
  drainOneBrand happy/skipped/error branches plus summarizeDrains
  multi-brand and empty. Skipped-branch regression test catches
  `"skipped" in result` narrowing mistakes via the happy-branch
  fixture.
- New: tests/unit/time-tracking-smartsheet-sync-route.test.ts
  (13 tests) — POST + GET auth (Bearer header, ?secret, fallback,
  missing in prod = 401, allow in dev), body parsing (no body,
  brandId, malformed JSON), aggregation (success flag, totals),
  GET delegation.

Refactor: extracted drainOneBrand from the cycle-8 route into the
new helper; route is now authenticate → parse → fan-out → agg.

Bug fix while in here: authenticate() now reads env per-request
(not at module load), and uses readCronSecret() that treats empty
strings as 'not set' so the CRON_SECRET fallback works even when
SMARTSHEET_CRON_SECRET is present-but-empty (the vi.stubEnv idiom
and some hosting dashboards).

vitest.config.ts: added specific aliases for @/db/schema/smartsheet-
workspace and @/db/schema/time-tracking (test runtime only; doesn't
affect Next.js builds).

Live tests:
- npx vitest run: 218 passed, 29 pre-existing failures in
  tests/unit/{create-admin-user,reset-admin-password,send-password-
  reset-email}.test.ts (unrelated to cycle 9; same baseline as cycle
  8).
- npx tsc --noEmit: clean (no new errors).
- npm run lint: no new warnings on cycle 9 files.
- npm run build: EXIT 0 (41s); new route still registered.
- Smoke (port 4000): GET ?secret=qa-audit-cron-secret → 200;
  GET (no auth) → 401; GET ?secret=wrong → 401.

PR-reviewer verdict: APPROVED. Three style notes fixed before
commit (test comment about narrowing, vi.stubEnv for NODE_ENV,
named TTDrainResult type).

Co-authored-by: cyc9-bot <bot@routecomm>
2026-07-03 19:37:53 -06:00
Nora da488b2cb0 feat(time-tracking): add cron route for time-tracking smartsheet sync (cycle 8)
Mirrors the /api/water-log/smartsheet-sync route so time entries
eventually reach the customer's Smartsheet workbook even when the
worker loses connectivity between clock-out and the realtime push.

- New route: /api/time-tracking/smartsheet-sync — drains pending
  sync queue rows for every brand with sync_enabled = true.
- Per-frequency gating lives inside runScheduledTTSync:
  realtime = backstop only; every_15_min / hourly skip cleanly
  when not yet due (literal `skipped === true` branch).
- Auth: Bearer $SMARTSHEET_CRON_SECRET (falls back to $CRON_SECRET).
- Optional body { brandId? } for 'Retry now' admin button.
- Schedule: */15 * * * * in vercel.json (same cadence as water-log).

Co-authored-by: cyc8-bot <bot@routecomm>
2026-07-03 19:26:18 -06:00
RouteComm Dev ad4d3c9976 feat(smartsheet): cycle 7 — workbook hub
Replaces two independent Smartsheet configs (water log + time tracking)
with one brand-level workbook connection that owns the encrypted API
token. Per-feature configs keep their sheet_id + column_mapping +
frequency + sync_enabled.

Schema (migration 0096):
- NEW smartsheet_workspace: one row per brand. Holds encrypted_api_token
  + token_iv + token_auth_tag (AES-256-GCM, same key), default_sheet_id,
  connection_enabled (master switch), last_test_*, audit columns. RLS
  brand-scoped.
- Backfill: water_smartsheet_config rows copy into workspace first;
  time_tracking_smartsheet_config fills in only brands with no workspace
  row yet. Idempotent (NOT EXISTS + ON CONFLICT DO NOTHING).
- DROP encrypted_api_token + token_iv + token_auth_tag from both feature
  configs (destructive — bytes are copied to workspace first).
- Drop unused bytea customType from both schemas (no longer needed).

Actions:
- NEW src/actions/smartsheet/workspace.ts: getSmartsheetWorkspace,
  saveSmartsheetWorkspace, testSmartsheetWorkspaceConnection,
  disableSmartsheetWorkspace. Permission: can_manage_settings +
  platform_admin.
- NEW src/services/workspace-token.ts (server-only): resolveWorkspaceToken
  helper. Lives outside the 'use server' file so the plaintext token
  is not exposed as an RPC surface — only callable from server-side
  sync engines.
- MODIFIED water-log + time-tracking smartsheet actions: refactored to
  read token via resolveWorkspaceToken; save* no longer accepts a
  token; test* falls through to the workspace token if no token given.
- MODIFIED both sync services: load token from resolveWorkspaceToken
  instead of decrypting the per-feature config. 'connection_disabled'
  now skips cleanly (no retry, no failed log row) — pausing the
  workspace at the hub level no longer floods Recent Activity with
  'disabled' failures.

UI:
- NEW src/components/admin/water-log/SmartsheetHub.tsx: top-level hub
  card. Owns the token + Test Connection + connection enabled toggle +
  default sheet ID + last-verified badge. Permission gated.
- MODIFIED SmartsheetIntegrationCard.tsx (water): rewritten without
  token UI. Reads masked token from workspace. Sheet ID + column
  mapping + frequency + enabled + backfill + recent activity.
- MODIFIED TimeTrackingSmartsheetCard.tsx: same treatment.
- MODIFIED /admin/water-log/settings/page: collapses §05 + §06 into a
  single Smartsheet section with the hub on top + both feature cards
  as siblings underneath.

Tuxedo-only throughout (TUXEDO_BRAND_ID hardcoded in the settings
page; matches existing single-tenant /water convention).

Security fixes from pr-reviewer:
- resolveWorkspaceToken extracted from 'use server' file to a
  server-only service module — was reachable as an RPC, would have
  returned the plaintext token to any authenticated admin.
- getSmartsheetWorkspace now actually checks the auth result instead
  of calling requireManageSettings() and discarding the return value.

Diff: -1742 / +597 lines (net -1100 from the simpler per-feature card).
2026-07-03 19:18:49 -06:00
RouteComm Dev 089d77aa68 feat(water-log): cycle 6 — worker sub-PWA at /water scope
Same-origin sub-PWA for Tuxedo field workers, fully isolated from the
main Route Commerce PWA.

- public/manifest-field.json: Tuxedo-branded manifest, start_url=/water,
  scope=/water, themeColor=#14532d, no Route Commerce branding
- public/sw-field.js: scoped SW with field-shell-v1 + field-data-v1
  caches (never touches main app's rc-shell-v* / rc-data-v*). Skips
  any request outside /water scope. Two-cache strategy: shell
  cache-first + /api/water stale-while-revalidate.
- src/app/water/layout.tsx: child layout overrides root metadata
  (manifest, themeColor, applicationName, appleWebApp.title) and
  mounts the field SW registration + install prompt.
- src/components/pwa/FieldSWRegistration.tsx: registers /sw-field.js
  with scope=/water. Module-scoped registered flag prevents strict-
  mode double-registration.
- src/components/pwa/FieldInstallPrompt.tsx: sibling of InstallPrompt,
  forest-green #1a4d2e theming, fires after 4s dwell. Captures
  display-mode:standalone AND navigator.standalone for iOS.

No conflict with the main app PWA: PWAInstallPrompt is mounted only
under /admin/layout.tsx, so /water routes only see FieldInstallPrompt.

Tuxedo-only by design (matches existing /water single-tenant hardcode).
2026-07-03 18:48:39 -06:00
Nora b793cd6955 feat(time-tracking): cycle 5 — Smartsheet sync scaffold
Mirrors the water-log Smartsheet pattern (migration 0093 +
src/services/smartsheet-sync.ts). End-to-end wiring is in place;
the brand fills in sheet ID + token + column mapping whenever
ready.

DB:
- 0095_time_tracking_smartsheet_sync.sql — config + sync_queue +
  sync_log tables with brand-scoped RLS. Unique constraint on
  (brand_id, log_id) keeps the queue idempotent against retries.

Code:
- db/schema/time-tracking.ts — adds TTSmartsheetFrequency,
  TT_SMARTSHEET_FREQUENCIES, TTSmartsheetColumnKey,
  TTSmartsheetColumnMapping, and the three pgTable defs.
- src/services/time-tracking-smartsheet-sync.ts — syncLogToSmartsheet
  (dedup-by-log_id, token-echo sanitization, retry-with-backoff),
  drainTTSyncQueue, runScheduledTTSync.
- src/actions/time-tracking/smartsheet.ts — full admin CRUD
  (getTTSmartsheetConfig, saveTTSmartsheetConfig,
  testTTSmartsheetConnection, getTTSmartsheetRecent,
  getTTSmartsheetQueueSummary, disableTTSmartsheet).
- src/actions/time-tracking/field.ts — clockOutWorker calls the new
  enqueueClockOutForSync helper in its OWN transaction (so a queue-
  insert failure can never poison the clock-out commit; reviewer
  caught this in cycle 2's same-style bug pattern).
- src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx —
  slim, focused card: sheet ID + token + frequency + enable +
  Test Connection → column-mapping dropdowns + Recent Activity table.
- src/app/admin/water-log/settings/page.tsx — §06 section renders
  the new card as a sibling of the existing water-log smartsheet
  card.

Permission gate uses can_manage_settings + platform_admin (not
can_manage_water_log) — feature is a brand-level integration
setting, not water-log-specific.

Out of scope / deferred:
- /api/time-tracking/smartsheet-sync cron route. The trigger is
  optional: realtime mode syncs inline; 15-min/hourly modes need the
  cron. Add in a follow-up cycle when the cron infrastructure is
  revisited.
- drag-and-drop column-mapping UX (current card uses plain
  dropdowns).
- backfill flow (no equivalent of the water-log "backfill past N
  days" feature yet).
- can_manage_time_tracking permission flag (uses
  can_manage_settings as the proxy; revisit once the admin_users
  schema gets a time-tracking-specific permission column).
2026-07-03 18:41:36 -06:00
Nora dfd6b63888 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.
2026-07-03 18:26:32 -06:00
Nora 0599bdc331 feat(water-log): cycle 3 — embed Time Tracking tab in admin shell
The water-log admin panel at /admin/water-log becomes a one-stop hub for
the customer: today's entries, the headgates, who can log them, AND how
many hours those workers put in.

- New TimeTrackingTab — thin wrapper that renders the existing
  TimeTrackingAdminPanel (which already owns its own Summary / Workers
  / Tasks / Logs / Settings sub-tabs).
- Shell.tsx now exposes Today | Headgates | Users | Time Tracking; the
  new branch just passes brandId through.
- TabValue union and TABS[] updated to "time-tracking".

This is the customer-facing surface the user asked for. Cycle 4 adds
the same tab to the mobile worker portal; Cycle 5 scaffolds the
Smartsheet sync (config-deferred).
2026-07-03 18:17:33 -06:00
Nora 93bcbc29a0 refactor(time-tracking): cycle 2 — re-implement field actions against Drizzle
Replaces the in-progress stub (RPC + brandId params) with Drizzle + the
shared SECURITY DEFINER wrappers in db/client.

- field.ts: cookie session, scrypt PIN, clock-in/out, getOpenClockIn,
  getWorkerPayPeriodHours. Cookie payload now 7 fields; parseSessionCookie
  re-resolves name/role/lang/brand_id from DB on every read so a stale or
  tampered cookie cannot impersonate a different brand.
- index.ts: admin CRUD for workers / tasks / settings / logs / notifications
  + getWorkerPeriodTotals. All admin actions drop the brandId param; the
  helpers pull it from the admin session via getAdminUser().
- notifications.ts: overtime check inserts with status='pending' (not
  optimistic 'sent') since email/SMS dispatch is out of scope until the
  notification_targets columns land in a follow-up migration.
- export/route: cast fix for the changed TimeLog shape.

DB:
- 0094 partial unique index on time_tracking_logs (worker_id)
  WHERE clock_out IS NULL — enforces one open clock-in per worker
  against concurrent / retried requests (READ COMMITTED cannot).
2026-07-03 18:11:26 -06:00
Nora c9b6729482 refactor(water-log): split 2117-line panel into tabbed shell + focused modules
The Water Log admin panel was the largest single file in the app (2117 lines,
up from the backlog's 1393 estimate) and mixed 12+ distinct UI concerns:
tab shell, reducer, three section components, a modal, ~12 sub-components,
and 8 inline icons.

Split into focused files under src/components/admin/water-log/:

  Shell.tsx                 tabbed shell, reducer wiring, derived data
  reducer.ts                reducer + initState + selectors
  types.ts                  shared types (State, Action, Toast, …)
  tabs/TodayTab.tsx         hero summary + recent entries + report settings
  tabs/HeadgatesTab.tsx     headgate cards + CRUD
  tabs/UsersTab.tsx         user list + CRUD
  shared/Primitives.tsx     SectionHeader, StatTile, StatusPill, PinBanner,
                            RoleLegend, EmptyState, Input, Select, FilterChip,
                            Avatar, MonthSelect, DayInput, Th, Td
  shared/HeaderNav.tsx      top-of-page quick links
  shared/Toast.tsx          toast notification
  shared/ReportPreviewModal.tsx  daily-report preview modal
  shared/icons.tsx          9 inline icons (incl. new ClockIcon reserved
                            for the Time Tracking tab in Cycle 3)

Tabs wired in this commit: today (default), headgates, users.
Time Tracking lands in Cycle 3.

Bugs caught by pr-reviewer subagent before commit:
- Color drift: text-[#1d1d1f] was silently rewritten to text-[#1d1d1d] in
  16 places during mechanical extraction. sed restored all 16.
- Subtitle template placeholder: monolith had literal '{n}' in the
  Recent Entries subtitle. New copy: 'The latest readings…'.
- localStorage coupling: first pass put setItem('wl_season_settings:v1')
  inside TodayTab.onSeasonChange. Lifted to Shell.persistSeason so the
  storage contract is owned by the shell, not scattered through tabs.

Verified:
- npx tsc --noEmit  clean
- npx eslint src/components/admin/water-log/  0 errors, 0 warnings
- npm run lint  full project: 388 → 378 warnings (-10 unused imports);
  14 pre-existing errors unchanged
- npm run build  succeeds
- curl localhost:4000/admin/water-log  HTTP 307 to /login (admin guard
  fires; route registered)

Refactor-progress tracked at /tmp/refactor-routecomm.md (Phase 2,
Cycle 1 logged with outcome + 3 fix notes). Next: Cycle 2 — bring the
stubbed time-tracking actions back to life against Drizzle.
2026-07-03 17:58:44 -06:00
119 changed files with 21567 additions and 6386 deletions
+35 -4
View File
@@ -204,9 +204,40 @@ jobs:
# Install deps and restart on server
echo "Installing deps and restarting PM2..."
# IMPORTANT: --update-env so PM2 re-reads .env. Without it PM2 keeps
# the original env vars cached at first start and silently ignores
# newly added ones (like SMARTSHEET_*).
ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && pm2 restart route-commerce --update-env || pm2 start npm --name route-commerce -- start -- -p 3100 && pm2 save && sleep 4 && curl -f -s http://localhost:3100/api/health/db-schema || { echo 'Health check failed after start - schema not applied (see plan)'; exit 1; }"
# IMPORTANT: With `output: "standalone"` in next.config.ts we MUST
# run `node .next/standalone/server.js` (not `next start`) — `next start`
# against a standalone build is unsupported and silently disables the
# image optimizer (every `/_next/image?url=...` returns
# `"url" parameter is not allowed`). See commit 129c9d2.
#
# We also need to (1) ship the standalone loader script
# `scripts/start-standalone.cjs`, (2) copy `.next/static/` into
# `.next/standalone/.next/static/` so the standalone server can
# serve client-side JS/CSS chunks, (3) copy `public/` into
# `.next/standalone/public/` (REQUIRED for Next.js standalone —
# without it, `/_next/image?url=/brand-logos/...` returns 400
# because the optimizer can't resolve local relative paths),
# and (4) load `.env` via a Node loader — bash's
# `set -a; . ./.env` truncates DATABASE_URL at the `&` in
# `&channel_binding=require`.
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/start-standalone.cjs tyler@route.crispygoat.com:$APP_DIR/scripts/start-standalone.cjs 2>/dev/null || true
ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "set -e; cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && \
# Standalone server reads .next/static/ relative to itself.
mkdir -p .next/standalone/.next && \
rm -rf .next/standalone/.next/static && \
cp -r .next/static .next/standalone/.next/static && \
# Next.js standalone REQUIRES public/ in the standalone tree;
# without it, the image optimizer returns 400 on local-relative
# image URLs (e.g. /brand-logos/.../logo.png) and storefront
# logos / favicons go blank. Mirror public/ → .next/standalone/public/.
rm -rf .next/standalone/public && \
mkdir -p .next/standalone/public && \
cp -r public/. .next/standalone/public/ && \
# Start with the wrapper that loads .env and exec's the server.
# `pm2 start` is idempotent via `pm2 restart` after the first run.
pm2 delete route-commerce 2>/dev/null || true; \
PORT=3100 HOSTNAME=0.0.0.0 pm2 start scripts/start-standalone.cjs --name route-commerce --interpreter /home/tyler/.cache/act/tool_cache/node/22.22.3/x64/bin/node 2>&1 | tail -3 && \
pm2 save && sleep 4 && \
curl -fsS http://localhost:3100/api/health/db-schema >/dev/null || { echo 'Health check failed after start - schema not applied (see plan)'; exit 1; }"
echo "Deployed successfully"
+6
View File
@@ -42,6 +42,12 @@ supabase/.temp/
# Playwright test results (generated, not source)
test-results/
playwright-report/
tests/_artifacts/
# Local debug / smoke scripts (not part of the product)
scripts/cycle*-debug.mjs
scripts/cycle*-smoke.mjs
scripts/cycle*-smoke-session.mjs
# IDE / local config
.mcp.json
@@ -0,0 +1,23 @@
-- ============================================================================
-- 0094_time_tracking_one_open_clock_in.sql
--
-- Cycle 2 of the water-log/time-tracking refactor. A field worker must not
-- have two open clock-ins at once — concurrent requests, offline retries,
-- and double-taps on a slow phone all create the same hazard. A partial
-- unique index on (worker_id) WHERE clock_out IS NULL is the only DB-level
-- guard, since READ COMMITTED transactions can both pass an
-- "is there an open log?" SELECT before either INSERT commits.
--
-- The application catches the unique violation and returns
-- "Already clocked in" to the worker. The `clockOutWorker` action picks
-- the most-recent open log (ORDER BY clock_in DESC LIMIT 1) — that
-- behavior is unchanged.
-- ============================================================================
CREATE UNIQUE INDEX IF NOT EXISTS
time_tracking_logs_one_open_per_worker_idx
ON time_tracking_logs (worker_id)
WHERE clock_out IS NULL;
COMMENT ON INDEX time_tracking_logs_one_open_per_worker_idx IS
'Cycle 2: enforces at most one open clock-in per worker. Catches concurrent / retry race in field app.';
@@ -0,0 +1,190 @@
-- ============================================================================
-- 0095_time_tracking_smartsheet_sync.sql
--
-- Cycle 5 — Per-brand Smartsheet integration for Time Tracking, mirroring
-- the water-log smartsheet pattern from migration 0093. Three tables:
--
-- 1. time_tracking_smartsheet_config — one row per brand; encrypted
-- token, sheet id, column
-- mapping, frequency, enable
-- flag, last-sync metadata
-- 2. time_tracking_smartsheet_sync_queue — one row per clock-out that
-- needs syncing; tracks
-- attempts + status for retry
-- and observability
-- 3. time_tracking_smartsheet_sync_log — append-only log of every
-- sync attempt (success OR
-- failure); backs the
-- "Recent Activity" panel
--
-- Security:
-- - API token is AES-256-GCM encrypted at rest using a key from
-- SMARTSHEET_TOKEN_ENC_KEY. The token NEVER leaves the server in
-- plaintext via any server action / API response.
-- - All tables brand-scoped via the existing `app.current_brand_id`
-- GUC + `current_brand_id()` function (see 0001_init.sql).
--
-- Cycle 5 scope:
-- This migration ONLY introduces schema + RLS. The actual sync
-- service, server actions, and admin UI live in their own files.
-- The brand will fill in sheet_id + token + column mapping when
-- the customer is ready; everything works end-to-end once they do.
-- ============================================================================
-- ── 1. Config table ────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_config (
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
-- The numeric Smartsheet sheet ID (not the share URL).
-- UI accepts either form and parses to numeric here.
sheet_id TEXT NOT NULL,
-- AES-256-GCM encrypted API token (stored as BYTEA so we can store
-- raw bytes, not base64). Use the helpers in src/lib/crypto.ts.
encrypted_api_token BYTEA NOT NULL,
token_iv BYTEA NOT NULL, -- 12 random bytes per encrypt
token_auth_tag BYTEA NOT NULL, -- 16-byte GCM auth tag
-- Maps our internal time-tracking fields → Smartsheet column IDs.
-- Shape: { log_id: string, clock_in: string, clock_out: string,
-- worker: string, task: string, hours: string,
-- lunch_minutes: string | null, notes: string | null }
-- `log_id` and `clock_in` are required (used for dedup).
column_mapping JSONB NOT NULL,
-- 'realtime' | 'every_15_minutes' | 'hourly'
sync_frequency TEXT NOT NULL DEFAULT 'hourly'
CHECK (sync_frequency IN ('realtime','every_15_minutes','hourly')),
sync_enabled BOOLEAN NOT NULL DEFAULT false,
-- Set after every sync attempt (success or failure).
last_sync_at TIMESTAMPTZ,
last_sync_error TEXT,
-- User IDs from `neon_auth.user`; stored as text so we don't
-- require a FK to a specific auth backend.
created_by TEXT,
updated_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Auto-bump updated_at on row UPDATE.
DROP TRIGGER IF EXISTS time_tracking_smartsheet_config_set_updated_at ON time_tracking_smartsheet_config;
CREATE TRIGGER time_tracking_smartsheet_config_set_updated_at
BEFORE UPDATE ON time_tracking_smartsheet_config
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- RLS: brand-scoped.
ALTER TABLE time_tracking_smartsheet_config ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_config;
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_config FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- ── 2. Sync queue (one row per clock-out awaiting sync) ────────────────────
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_sync_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
-- FK to the clock-out. ON DELETE CASCADE means deleting a log row
-- also removes its queue row, preventing orphaned sync attempts.
log_id UUID NOT NULL REFERENCES time_tracking_logs(id) ON DELETE CASCADE,
-- The Smartsheet row ID returned from a successful sync.
-- NULL = not yet synced.
smartsheet_row_id TEXT,
-- 'pending' | 'syncing' | 'synced' | 'failed'
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','syncing','synced','failed')),
-- Bumped on every attempt (success or failure). Caps at 5 (after
-- which the row stays 'failed' permanently; admin must re-enable).
attempts INT NOT NULL DEFAULT 0,
-- Last failure reason (sanitized — token never appears here).
last_error TEXT,
-- When the next retry is allowed. Set to NOW() initially; pushed
-- out by exponential backoff on failure (2^attempts minutes, cap 1h).
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
synced_at TIMESTAMPTZ,
CONSTRAINT time_tracking_smartsheet_queue_log_unique UNIQUE (brand_id, log_id)
);
-- Hot path: drain query scans `(brand_id, status, next_attempt_at)`.
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_queue_brand_status_idx
ON time_tracking_smartsheet_sync_queue (brand_id, status, next_attempt_at);
-- Recent-attempts view.
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_queue_brand_created_idx
ON time_tracking_smartsheet_sync_queue (brand_id, created_at DESC);
ALTER TABLE time_tracking_smartsheet_sync_queue ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_sync_queue;
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_sync_queue FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- ── 3. Sync log (append-only, backs Recent Activity UI) ────────────────────
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_sync_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
log_id UUID REFERENCES time_tracking_logs(id) ON DELETE SET NULL,
smartsheet_row_id TEXT,
-- 'sync' | 'retry' | 'skip' (dedup hit) | 'queue'
action TEXT NOT NULL
CHECK (action IN ('sync','retry','skip','queue')),
success BOOLEAN NOT NULL,
error TEXT,
duration_ms INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_log_brand_recent_idx
ON time_tracking_smartsheet_sync_log (brand_id, created_at DESC);
ALTER TABLE time_tracking_smartsheet_sync_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_sync_log;
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_sync_log FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- ── 4. Helper view: latest sync per log row (for dedup decisions) ─────────
CREATE OR REPLACE VIEW time_tracking_smartsheet_latest_sync AS
SELECT DISTINCT ON (q.brand_id, q.log_id)
q.brand_id,
q.log_id,
q.smartsheet_row_id,
q.status,
q.attempts,
q.last_error,
q.next_attempt_at,
q.created_at AS queued_at,
q.synced_at
FROM time_tracking_smartsheet_sync_queue q
ORDER BY q.brand_id, q.log_id, q.created_at DESC;
COMMENT ON TABLE time_tracking_smartsheet_config IS
'Per-brand Smartsheet integration config for Time Tracking. Token is AES-256-GCM encrypted.';
COMMENT ON TABLE time_tracking_smartsheet_sync_queue IS
'One row per time_tracking_log awaiting / completed sync to Smartsheet.';
COMMENT ON TABLE time_tracking_smartsheet_sync_log IS
'Append-only audit of Time Tracking ↔ Smartsheet sync attempts.';
@@ -0,0 +1,179 @@
-- ============================================================================
-- 0096_smartsheet_workbook_hub.sql
--
-- Cycle 7 — Smartsheet workbook hub. Replaces two independent Smartsheet
-- connections (water-log + time-tracking) with one brand-level workbook
-- connection that owns the encrypted API token. Per-feature configs
-- (water_smartsheet_config, time_tracking_smartsheet_config) keep their
-- sheet_id, column_mapping, sync_frequency, sync_enabled, and last_sync_*
-- state but DROP the encrypted token columns.
--
-- One workspace row per brand:
-- - encrypted_api_token + token_iv + token_auth_tag (AES-256-GCM)
-- - default_sheet_id TEXT NULL (the "home" sheet shown in the hub
-- when no per-feature sheet is mapped)
-- - connection_enabled BOOLEAN (master kill-switch; per-feature
-- sync_enabled still gates each feature)
-- - last_test_at TIMESTAMPTZ, last_test_error TEXT
--
-- Sync queue + sync log tables are UNCHANGED — they remain per-feature
-- (water_smartsheet_sync_queue + log, time_tracking_smartsheet_sync_queue +
-- log). The hub re-homes only the token.
--
-- Migration behavior (idempotent — safe to re-run):
-- 1. Create smartsheet_workspace if missing
-- 2. Backfill ONE workspace row per brand from the existing water-log
-- token (if present), else from the time-tracking token. Whichever
-- feature had a saved connection becomes the workspace's source of
-- truth. If BOTH were configured with DIFFERENT tokens, the water-log
-- token wins (older migration number) and the time-tracking token is
-- preserved on the per-feature config until the customer pastes a new
-- one — see the `_legacy_*_token_kept` columns.
-- 3. DROP the three encrypted columns from both feature configs.
-- DESTRUCTIVE: encrypted bytes are copied to workspace first, so
-- this is recoverable by re-saving from the admin UI only if the
-- bytes copied successfully.
--
-- Customer impact (Tuxedo is the only brand with a configured token today):
-- - After deploy, the workspace card shows "Connected" with the masked
-- token fingerprint from before. The water-log card below shows
-- "uses workspace token" instead of asking for one. No re-paste.
-- ============================================================================
-- ── 1. New workspace table ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS smartsheet_workspace (
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
-- AES-256-GCM encrypted API token (one per brand; one per workspace).
-- Stored as BYTEA so we can keep raw bytes (not base64).
encrypted_api_token BYTEA NOT NULL,
token_iv BYTEA NOT NULL, -- 12 random bytes per encrypt
token_auth_tag BYTEA NOT NULL, -- 16-byte GCM auth tag
-- Optional "home" sheet — useful when the brand has a multi-sheet
-- workbook and wants a default tab. NULL is fine.
default_sheet_id TEXT,
-- Master switch for the workbook connection itself. Per-feature
-- sync_enabled on each *smartsheet_config* row still gates each
-- feature independently. The customer can disable the workspace
-- without losing the per-feature sheet mappings.
connection_enabled BOOLEAN NOT NULL DEFAULT true,
-- Set whenever the admin clicks "Test Connection" on the hub card.
-- Powers the "Last verified: 2 min ago" badge in the UI.
last_test_at TIMESTAMPTZ,
last_test_error TEXT,
-- User IDs from `neon_auth.user`; stored as text so we don't
-- require a FK to a specific auth backend.
created_by TEXT,
updated_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Auto-bump updated_at on row UPDATE.
DROP TRIGGER IF EXISTS smartsheet_workspace_set_updated_at ON smartsheet_workspace;
CREATE TRIGGER smartsheet_workspace_set_updated_at
BEFORE UPDATE ON smartsheet_workspace
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- RLS: brand-scoped, same pattern as existing smartsheet tables.
ALTER TABLE smartsheet_workspace ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON smartsheet_workspace;
CREATE POLICY tenant_isolation ON smartsheet_workspace FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
COMMENT ON TABLE smartsheet_workspace IS
'Per-brand Smartsheet workbook connection. Token is AES-256-GCM encrypted. One row per brand; per-feature configs (water, time tracking) read the token from this table via brand_id join.';
-- ── 2. Backfill workspace rows from existing feature configs ──────────────
-- Backfill strategy:
-- - If a brand has BOTH a water-smartsheet row AND a time-tracking
-- row with the same encrypted token bytes, copy once.
-- - If only one is present, copy from that one.
-- - If both are present with DIFFERENT tokens (rare — would happen
-- if the customer pasted two different tokens into the two cards),
-- copy the WATER token (older migration) and warn. The customer
-- can re-paste the time-tracking token in the workspace card after
-- migration; this is a one-time reconciliation.
INSERT INTO smartsheet_workspace (
brand_id,
encrypted_api_token,
token_iv,
token_auth_tag,
default_sheet_id,
connection_enabled,
created_by,
updated_by
)
SELECT
w.brand_id,
w.encrypted_api_token,
w.token_iv,
w.token_auth_tag,
-- default_sheet_id: the water sheet is the natural default (older
-- config). The time-tracking sheet becomes a per-feature mapping.
w.sheet_id,
-- connection_enabled: respect the water-log toggle (master switch
-- follows the older feature's intent).
w.sync_enabled,
w.created_by,
w.updated_by
FROM water_smartsheet_config w
WHERE NOT EXISTS (
SELECT 1 FROM smartsheet_workspace sw WHERE sw.brand_id = w.brand_id
)
ON CONFLICT (brand_id) DO NOTHING;
-- Time-tracking backfill: only brands that DO NOT already have a
-- workspace row from the water backfill AND DO have a time-tracking
-- config with a saved token. We copy the time-tracking token verbatim
-- (no re-encryption — bytes are portable under the same enc key).
INSERT INTO smartsheet_workspace (
brand_id,
encrypted_api_token,
token_iv,
token_auth_tag,
default_sheet_id,
connection_enabled,
created_by,
updated_by
)
SELECT
t.brand_id,
t.encrypted_api_token,
t.token_iv,
t.token_auth_tag,
t.sheet_id,
t.sync_enabled,
t.created_by,
t.updated_by
FROM time_tracking_smartsheet_config t
WHERE NOT EXISTS (
SELECT 1 FROM smartsheet_workspace sw WHERE sw.brand_id = t.brand_id
)
ON CONFLICT (brand_id) DO NOTHING;
-- ── 3. Drop encrypted token columns from feature configs ────────────────
--
-- DESTRUCTIVE. Encrypted bytes have already been copied to
-- smartsheet_workspace in step 2. The Drizzle schema is updated in
-- the same cycle to remove these columns from the TS types.
ALTER TABLE water_smartsheet_config
DROP COLUMN IF EXISTS encrypted_api_token,
DROP COLUMN IF EXISTS token_iv,
DROP COLUMN IF EXISTS token_auth_tag;
ALTER TABLE time_tracking_smartsheet_config
DROP COLUMN IF EXISTS encrypted_api_token,
DROP COLUMN IF EXISTS token_iv,
DROP COLUMN IF EXISTS token_auth_tag;
+322
View File
@@ -0,0 +1,322 @@
-- ============================================================================
-- 0097_field_workers.sql
--
-- Cycle 10 — unify `water_irrigators` and `time_tracking_workers` into a
-- single `field_workers` table. One row per person, one `pin_hash`, one
-- role vocabulary. Permanent fix for the silent-desync class of bugs
-- (a worker changing their water PIN while their time PIN stays the same,
-- or vice versa) and unlocks future domains (harvest reports, equipment
-- logs) without yet another `*_workers` table.
--
-- Why one table:
-- - Today two tables store the same person twice with separate PIN
-- hashes and disjoint role vocabularies (`irrigator`/`water_admin` vs
-- `worker`/`time_admin`). The Tuxedo worker PWA at `/water` already
-- shows ONE PIN entry screen but under the hood has to look up the
-- right row in the right table depending on which tab the worker
-- hits (Cycle 4 attempted unification via a best-effort cross-call
-- in `MobileWaterApp.handlePinSubmit`).
-- - Phase 2 (separate cycle) collapses the two session cookies and
-- replaces the dual verify actions with a single `verifyFieldWorkerPin`.
-- This cycle ships the schema + action layer; UI behavior is preserved.
--
-- Schema:
-- id UUID PK (new — old worker IDs are not reused)
-- brand_id UUID NOT NULL → brands(id) ON DELETE CASCADE
-- name TEXT NOT NULL
-- pin_hash TEXT NOT NULL — scrypt self-describing format
-- from `@/lib/water-log-pin`
-- role TEXT NOT NULL DEFAULT 'worker'
-- — union of all four old role values
-- language_preference TEXT NOT NULL DEFAULT 'en'
-- phone TEXT NULL (water-only; null for time-only workers)
-- notes TEXT NULL (water-only)
-- active BOOLEAN NOT NULL DEFAULT true
-- last_used_at TIMESTAMPTZ NULL
-- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
--
-- Migration behavior (idempotent — safe to re-run):
-- 1. Create field_workers + brand-scoped RLS.
-- 2. Add nullable `field_worker_id` columns on the four downstream
-- tables that currently FK into the old worker tables.
-- 3. Backfill: collapse water + time rows that share a (brand_id,
-- lower(trim(name))) key. PIN conflicts → water's wins (water is
-- the older surface, preserves existing auth). Names unique to
-- one side → copy verbatim.
-- 4. Verify every downstream FK row got a field_worker_id (must be 0
-- NULL before we make the column NOT NULL).
-- 5. Drop the old worker tables; CASCADE drops the OLD FK columns
-- (irrigator_id, worker_id) entirely. We do not preserve them —
-- the new `field_worker_id` columns are populated and become the
-- canonical reference.
-- 6. Reissue the partial unique index
-- `time_tracking_logs_one_open_per_worker_idx` against
-- `field_worker_id` (DB-level invariant: at most one open
-- clock-in per worker; preserved).
--
-- DESTRUCTIVE: drops `water_irrigators` and `time_tracking_workers`
-- entirely. The pin hashes are preserved verbatim into field_workers,
-- so no worker has to re-learn their PIN after deploy. Old IDs are not
-- preserved — every downstream FK is re-pointed to the new IDs. This
-- matters for any external system that referenced the old UUIDs; none
-- of our surfaces do.
--
-- Customer impact (Tuxedo has 10 water + 10 time workers today, no name
-- overlap):
-- - After deploy: 20 `field_workers` rows, every entry/log row
-- re-pointed at the right new UUID. Workers do NOT need to re-enter
-- a PIN. The water PIN entry screen continues to work; the time
-- PIN entry screen continues to work. The cross-call best-effort
-- in `MobileWaterApp` continues to work.
-- - The role select in `/admin/water-log/users/[id]` will be tightened
-- to drop `time_admin` (water-only workers shouldn't get time powers).
-- The role select in `/admin/time-tracking` will be tightened to drop
-- the silently-rejected `supervisor`/`admin` values that the explore
-- audit caught.
-- ============================================================================
-- ── 1. Create field_workers ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS field_workers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
pin_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'worker'
CHECK (role IN ('worker', 'time_admin', 'irrigator', 'water_admin')),
language_preference TEXT NOT NULL DEFAULT 'en',
phone TEXT,
notes TEXT,
active BOOLEAN NOT NULL DEFAULT true,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS field_workers_brand_idx
ON field_workers(brand_id);
CREATE INDEX IF NOT EXISTS field_workers_brand_active_idx
ON field_workers(brand_id, active)
WHERE active = true;
COMMENT ON TABLE field_workers IS
'Cycle 10 — unified worker table replacing water_irrigators + time_tracking_workers. '
'One row per person, one pin_hash. Role vocabulary: worker, time_admin, irrigator, water_admin. '
'See db/migrations/0097_field_workers.sql for the unification rationale.';
DROP TRIGGER IF EXISTS field_workers_set_updated_at ON field_workers;
CREATE TRIGGER field_workers_set_updated_at
BEFORE UPDATE ON field_workers
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- Brand-scoped RLS (mirrors 0096's smartsheet_workspace pattern).
ALTER TABLE field_workers ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON field_workers;
CREATE POLICY tenant_isolation ON field_workers FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- ── 2. Add nullable field_worker_id columns on the four downstream tables ─
ALTER TABLE water_log_entries
ADD COLUMN IF NOT EXISTS field_worker_id UUID
REFERENCES field_workers(id) ON DELETE CASCADE;
ALTER TABLE water_sessions
ADD COLUMN IF NOT EXISTS field_worker_id UUID
REFERENCES field_workers(id) ON DELETE CASCADE;
ALTER TABLE time_tracking_logs
ADD COLUMN IF NOT EXISTS field_worker_id UUID
REFERENCES field_workers(id) ON DELETE CASCADE;
ALTER TABLE time_tracking_notification_log
ADD COLUMN IF NOT EXISTS field_worker_id UUID
REFERENCES field_workers(id) ON DELETE SET NULL;
-- ── 3. Backfill field_workers from the two old tables ─────────────────────
--
-- Insertion order is intentional: WATER first so its rows win on the
-- (brand_id, lower(trim(name))) key (we use ON CONFLICT DO NOTHING).
-- Time-tracking rows are inserted second; if the (brand_id, name) key
-- already has a water row, the time row is dropped (water's PIN wins).
INSERT INTO field_workers (
id, brand_id, name, pin_hash, role, language_preference,
phone, notes, active, last_used_at, created_at
)
SELECT
wi.id,
wi.brand_id,
wi.name,
wi.pin_hash,
wi.role,
wi.language_preference,
wi.phone,
wi.notes,
wi.active,
wi.last_used_at,
wi.created_at
FROM water_irrigators wi
WHERE NOT EXISTS (
SELECT 1 FROM field_workers fw
WHERE fw.brand_id = wi.brand_id
AND lower(trim(fw.name)) = lower(trim(wi.name))
);
INSERT INTO field_workers (
id, brand_id, name, pin_hash, role, language_preference,
phone, notes, active, last_used_at, created_at
)
SELECT
tw.id,
tw.brand_id,
tw.name,
tw.pin AS pin_hash,
tw.role,
tw.lang AS language_preference,
NULL AS phone,
NULL AS notes,
tw.active,
tw.last_used_at,
tw.created_at
FROM time_tracking_workers tw
WHERE NOT EXISTS (
SELECT 1 FROM field_workers fw
WHERE fw.brand_id = tw.brand_id
AND lower(trim(fw.name)) = lower(trim(tw.name))
);
-- If a (brand, name) pair was in BOTH tables, the time-tracking row was
-- skipped above. Re-insert only the conflicting rows using water's pin_hash
-- is unnecessary (we already have water's row); this block is a no-op
-- safety check.
-- ── 4. Backfill field_worker_id on downstream tables ──────────────────────
--
-- Join key: (brand_id, lower(trim(name))). The water side uses
-- water_log_entries.irrigator_id → water_irrigators; the time side uses
-- time_tracking_logs.worker_id → time_tracking_workers.
UPDATE water_log_entries wle
SET field_worker_id = fw.id
FROM water_irrigators wi, field_workers fw
WHERE wle.irrigator_id = wi.id
AND fw.brand_id = wi.brand_id
AND lower(trim(fw.name)) = lower(trim(wi.name))
AND wle.field_worker_id IS NULL;
UPDATE water_sessions ws
SET field_worker_id = fw.id
FROM water_irrigators wi, field_workers fw
WHERE ws.irrigator_id = wi.id
AND fw.brand_id = wi.brand_id
AND lower(trim(fw.name)) = lower(trim(wi.name))
AND ws.field_worker_id IS NULL;
UPDATE time_tracking_logs ttl
SET field_worker_id = fw.id
FROM time_tracking_workers tw, field_workers fw
WHERE ttl.worker_id = tw.id
AND fw.brand_id = tw.brand_id
AND lower(trim(fw.name)) = lower(trim(tw.name))
AND ttl.field_worker_id IS NULL;
UPDATE time_tracking_notification_log tnl
SET field_worker_id = fw.id
FROM time_tracking_workers tw, field_workers fw
WHERE tnl.worker_id = tw.id
AND fw.brand_id = tw.brand_id
AND lower(trim(fw.name)) = lower(trim(tw.name))
AND tnl.field_worker_id IS NULL;
-- ── 5. Verification: every downstream row must have a field_worker_id ────
--
-- If any of these SELECTs returns a non-zero count, the migration will
-- leave NULL FKs. The CHECK ensures the migration FAILS LOUDLY in that
-- case rather than silently dropping data on the cascade in step 7.
DO $$
DECLARE
water_entries_null INTEGER;
water_sessions_null INTEGER;
time_logs_null INTEGER;
time_notif_null INTEGER;
BEGIN
SELECT count(*) INTO water_entries_null FROM water_log_entries WHERE field_worker_id IS NULL;
SELECT count(*) INTO water_sessions_null FROM water_sessions WHERE field_worker_id IS NULL;
SELECT count(*) INTO time_logs_null FROM time_tracking_logs WHERE field_worker_id IS NULL;
SELECT count(*) INTO time_notif_null FROM time_tracking_notification_log WHERE field_worker_id IS NULL;
IF water_entries_null > 0 THEN
RAISE EXCEPTION 'water_log_entries has % rows with NULL field_worker_id after backfill', water_entries_null;
END IF;
IF water_sessions_null > 0 THEN
RAISE EXCEPTION 'water_sessions has % rows with NULL field_worker_id after backfill', water_sessions_null;
END IF;
IF time_logs_null > 0 THEN
RAISE EXCEPTION 'time_tracking_logs has % rows with NULL field_worker_id after backfill', time_logs_null;
END IF;
IF time_notif_null > 0 THEN
RAISE EXCEPTION 'time_tracking_notification_log has % rows with NULL field_worker_id after backfill', time_notif_null;
END IF;
END $$;
-- ── 6. Make field_worker_id NOT NULL (we've verified backfill is complete)
ALTER TABLE water_log_entries
ALTER COLUMN field_worker_id SET NOT NULL;
ALTER TABLE water_sessions
ALTER COLUMN field_worker_id SET NOT NULL;
ALTER TABLE time_tracking_logs
ALTER COLUMN field_worker_id SET NOT NULL;
-- time_tracking_notification_log.worker_id was originally nullable
-- (ON DELETE SET NULL on the old FK); preserve that contract.
-- ── 7. Drop the old worker tables ────────────────────────────────────────
--
-- CASCADE removes FK constraints and dependent indexes/triggers, but does
-- NOT drop the foreign-key columns from unrelated tables. Drop those
-- explicitly so downstream tables carry only the new `field_worker_id`.
DROP TABLE IF EXISTS water_irrigators CASCADE;
DROP TABLE IF EXISTS time_tracking_workers CASCADE;
ALTER TABLE water_log_entries DROP COLUMN IF EXISTS irrigator_id;
ALTER TABLE water_sessions DROP COLUMN IF EXISTS irrigator_id;
ALTER TABLE time_tracking_logs DROP COLUMN IF EXISTS worker_id;
-- time_tracking_notification_log.worker_id was originally nullable
-- (ON DELETE SET NULL); we keep the column shape and just drop the old FK
-- constraint. The new `field_worker_id` column was added above and
-- backfilled. We drop the old column only if the new one is populated.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM time_tracking_notification_log WHERE field_worker_id IS NULL
) THEN
ALTER TABLE time_tracking_notification_log DROP COLUMN worker_id;
END IF;
END $$;
-- ── 8. Reissue the partial unique index on the new column ─────────────────
--
-- DB-level invariant preserved: at most one open clock-in per worker.
DROP INDEX IF EXISTS time_tracking_logs_one_open_per_worker_idx;
CREATE UNIQUE INDEX time_tracking_logs_one_open_per_worker_idx
ON time_tracking_logs (field_worker_id)
WHERE clock_out IS NULL;
-- ── 9. Summary of row counts after migration (for the audit log) ─────────
--
-- Run this manually after the migration completes if you want to confirm:
-- SELECT (SELECT count(*) FROM field_workers) AS field_workers,
-- (SELECT count(*) FROM water_log_entries WHERE field_worker_id IS NULL) AS wle_null,
-- (SELECT count(*) FROM time_tracking_logs WHERE field_worker_id IS NULL) AS ttl_null;
@@ -0,0 +1,443 @@
-- ============================================================================
-- 0098_time_tracking_timesheets.sql
--
-- Time Tracking — Phase 2 (Timesheets, GPS, Approval/Lock, Audit)
--
-- This migration extends the existing time-tracking schema with the
-- production / payroll features the field app + admin UI need:
--
-- 1. New roles on `field_workers` — `driver`, `supervisor`
-- (kept distinct from `worker` so admin UIs can filter / grant
-- approval rights without expanding the existing role semantics).
--
-- 2. GPS verification on every clock-in / clock-out —
-- `time_tracking_logs` gains lat/lng/accuracy columns for both
-- events, plus a boolean `gps_verified` (false when the device
-- denied geolocation, returned an error, or low-accuracy fix).
--
-- 3. Manual time entries —
-- `time_tracking_logs.entry_kind` becomes `'clock' | 'manual'`.
-- Manual rows carry `manual_reason` (the why-this-was-added text
-- required by payroll / labor law) and can be edited freely until
-- the parent timesheet is locked.
--
-- 4. Edit audit fields on `time_tracking_logs` —
-- `edited_at`, `edited_by` (admin_user id), `edit_reason`. Filled
-- by every edit that lands on a row; never cleared.
--
-- 5. NEW: `time_tracking_timesheets` — one row per (worker, pay
-- period). Statuses drive the approval workflow:
-- draft → submitted → approved (locked) → unlocked (admin only,
-- audit note required)
-- → draft (after rejection)
-- When `locked_at IS NOT NULL` the underlying logs become
-- read-only to anyone except `platform_admin` / `time_admin`.
--
-- 6. NEW: `time_tracking_audit_log` — append-only audit trail for
-- every mutating action on a timesheet or its entries. Every
-- server-action call that mutates a timesheet or a log row MUST
-- write a row here in the same transaction.
--
-- 7. NEW: `time_tracking_supervisor_assignments` — many-to-many
-- (supervisor_worker_id, supervisee_worker_id). Supervisors only
-- see / approve timesheets for their assigned crew.
--
-- The migration is idempotent — every ALTER / CREATE uses IF NOT EXISTS
-- or a DO block, so re-running on a partially-applied DB is safe.
-- ============================================================================
-- ───────────────────────────────────────────────────────────────────────────
-- 1. Role enum extension
-- ───────────────────────────────────────────────────────────────────────────
-- Existing enum (migration 0097) is named `field_workers_role_check`
-- via a CHECK constraint, NOT a Postgres ENUM type. Verify by introspecting
-- pg_constraint before adding new values.
DO $$
DECLARE
constraint_def TEXT;
BEGIN
-- Fetch the current CHECK expression
SELECT pg_get_constraintdef(oid)
INTO constraint_def
FROM pg_constraint
WHERE conname = 'field_workers_role_check';
IF constraint_def IS NOT NULL THEN
-- Drop and recreate with the expanded vocabulary. The set is the union
-- of all six role values the codebase now recognises.
EXECUTE 'ALTER TABLE field_workers DROP CONSTRAINT field_workers_role_check';
END IF;
END $$;
ALTER TABLE field_workers
ADD CONSTRAINT field_workers_role_check
CHECK (role IN ('worker', 'time_admin', 'irrigator', 'water_admin', 'driver', 'supervisor'));
-- Drop & recreate the Drizzle CHECK by hand if it exists under a different name
-- (drizzle-kit may have generated `field_workers_role_check1`).
DO $$
DECLARE
cname TEXT;
BEGIN
FOR cname IN
SELECT conname
FROM pg_constraint
WHERE conrelid = 'field_workers'::regclass
AND contype = 'c'
AND pg_get_constraintdef(oid) LIKE '%role%'
AND conname <> 'field_workers_role_check'
LOOP
EXECUTE format('ALTER TABLE field_workers DROP CONSTRAINT %I', cname);
END LOOP;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- 2. GPS columns + entry_kind + manual_reason + edit audit on logs
-- ───────────────────────────────────────────────────────────────────────────
ALTER TABLE time_tracking_logs
ADD COLUMN IF NOT EXISTS clock_in_lat DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS clock_in_lng DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS clock_in_accuracy_m DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS clock_out_lat DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS clock_out_lng DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS clock_out_accuracy_m DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS gps_verified BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS entry_kind TEXT NOT NULL DEFAULT 'clock',
ADD COLUMN IF NOT EXISTS manual_reason TEXT,
ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS edited_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS edit_reason TEXT;
-- entry_kind CHECK (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'time_tracking_logs_entry_kind_check'
) THEN
ALTER TABLE time_tracking_logs
ADD CONSTRAINT time_tracking_logs_entry_kind_check
CHECK (entry_kind IN ('clock', 'manual'));
END IF;
END $$;
-- Index for fast "all manual entries this period" queries used by payroll
CREATE INDEX IF NOT EXISTS time_tracking_logs_brand_kind_idx
ON time_tracking_logs (brand_id, entry_kind, clock_in);
-- Index for the approval queue (entries that belong to a timesheet status)
CREATE INDEX IF NOT EXISTS time_tracking_logs_clock_in_idx
ON time_tracking_logs (clock_in);
-- ───────────────────────────────────────────────────────────────────────────
-- 3. time_tracking_timesheets (the workflow entity)
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS time_tracking_timesheets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
-- Workflow state
status TEXT NOT NULL DEFAULT 'draft',
-- Submission
submitted_at TIMESTAMPTZ,
submitted_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
submitted_notes TEXT,
-- Approval (locks the timesheet)
reviewed_at TIMESTAMPTZ,
reviewed_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
reviewer_comment TEXT,
-- Rejection (sends back to draft)
rejected_at TIMESTAMPTZ,
rejected_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
rejection_reason TEXT,
-- Lock state (mirrors reviewed_at for fast filtering)
locked_at TIMESTAMPTZ,
locked_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
-- Unlock state (rare, admin-only, MUST carry a note)
unlocked_at TIMESTAMPTZ,
unlocked_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
unlock_audit_note TEXT NOT NULL DEFAULT '',
-- Pre-computed totals — recomputed by triggers / RPCs, but cached here
-- so the timesheet list view doesn't re-aggregate on every render.
total_minutes INTEGER NOT NULL DEFAULT 0,
regular_minutes INTEGER NOT NULL DEFAULT 0,
overtime_minutes INTEGER NOT NULL DEFAULT 0,
break_minutes INTEGER NOT NULL DEFAULT 0,
entry_count INTEGER NOT NULL DEFAULT 0,
-- Daily-OT flash cache: JSONB { 'YYYY-MM-DD': minutes }
daily_totals JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT time_tracking_timesheets_period_check
CHECK (period_end >= period_start),
CONSTRAINT time_tracking_timesheets_status_check
CHECK (status IN ('draft', 'submitted', 'approved', 'rejected'))
);
CREATE UNIQUE INDEX IF NOT EXISTS time_tracking_timesheets_worker_period_uniq
ON time_tracking_timesheets (field_worker_id, period_start, period_end);
CREATE INDEX IF NOT EXISTS time_tracking_timesheets_brand_status_idx
ON time_tracking_timesheets (brand_id, status, period_start DESC);
CREATE INDEX IF NOT EXISTS time_tracking_timesheets_brand_worker_idx
ON time_tracking_timesheets (brand_id, field_worker_id);
-- ───────────────────────────────────────────────────────────────────────────
-- 4. time_tracking_audit_log (append-only)
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS time_tracking_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
actor_id UUID REFERENCES admin_users(id) ON DELETE SET NULL,
actor_label TEXT NOT NULL,
actor_kind TEXT NOT NULL DEFAULT 'admin',
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID,
details JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS time_tracking_audit_log_brand_recent_idx
ON time_tracking_audit_log (brand_id, created_at DESC);
CREATE INDEX IF NOT EXISTS time_tracking_audit_log_entity_idx
ON time_tracking_audit_log (entity_type, entity_id);
-- ───────────────────────────────────────────────────────────────────────────
-- 5. Supervisor ↔ supervisee assignments
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS time_tracking_supervisor_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
supervisor_field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE,
supervisee_field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT time_tracking_supervisor_assignments_no_self
CHECK (supervisor_field_worker_id <> supervisee_field_worker_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS time_tracking_supervisor_assignments_uniq
ON time_tracking_supervisor_assignments (
supervisor_field_worker_id, supervisee_field_worker_id
);
CREATE INDEX IF NOT EXISTS time_tracking_supervisor_assignments_supervisor_idx
ON time_tracking_supervisor_assignments (supervisor_field_worker_id);
-- ───────────────────────────────────────────────────────────────────────────
-- 6. RLS — minimal, since the platform already relies on app-layer
-- brand scoping via SECURITY DEFINER / Drizzle `withBrand` wrappers.
-- The new tables are added to the same RLS scaffolding as the existing
-- `time_tracking_*` tables when present; otherwise left open at the
-- table level (the app enforces isolation).
-- ───────────────────────────────────────────────────────────────────────────
DO $$
DECLARE
rls_enabled BOOLEAN;
BEGIN
-- If RLS is enabled on time_tracking_logs, mirror it on the new tables.
SELECT relrowsecurity INTO rls_enabled
FROM pg_class
WHERE relname = 'time_tracking_logs';
IF rls_enabled THEN
EXECUTE 'ALTER TABLE time_tracking_timesheets ENABLE ROW LEVEL SECURITY';
EXECUTE 'ALTER TABLE time_tracking_audit_log ENABLE ROW LEVEL SECURITY';
EXECUTE 'ALTER TABLE time_tracking_supervisor_assignments ENABLE ROW LEVEL SECURITY';
END IF;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- 7. Triggers — keep `updated_at` fresh
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION time_tracking_set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS time_tracking_timesheets_set_updated_at
ON time_tracking_timesheets;
CREATE TRIGGER time_tracking_timesheets_set_updated_at
BEFORE UPDATE ON time_tracking_timesheets
FOR EACH ROW EXECUTE FUNCTION time_tracking_set_updated_at();
-- ───────────────────────────────────────────────────────────────────────────
-- 8. Helper view — admin "what's pending" dashboard
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE VIEW time_tracking_approval_queue AS
SELECT
t.id,
t.brand_id,
t.field_worker_id,
fw.name AS worker_name,
t.period_start,
t.period_end,
t.status,
t.total_minutes,
t.regular_minutes,
t.overtime_minutes,
t.entry_count,
t.submitted_at,
t.submitted_notes,
t.reviewed_at,
t.locked_at
FROM time_tracking_timesheets t
JOIN field_workers fw ON fw.id = t.field_worker_id;
-- ───────────────────────────────────────────────────────────────────────────
-- 9. SECURITY DEFINER RPC — recompute timesheet totals
-- Called by the server after any entry add/edit/delete so the cached
-- totals on `time_tracking_timesheets` stay in sync with the underlying
-- `time_tracking_logs`.
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION recompute_timesheet_totals(p_timesheet_id UUID)
RETURNS VOID AS $$
DECLARE
v_brand_id UUID;
v_worker_id UUID;
v_period_start DATE;
v_period_end DATE;
v_total INTEGER;
v_regular INTEGER;
v_ot INTEGER;
v_break INTEGER;
v_count INTEGER;
v_daily JSONB;
v_daily_thresh NUMERIC;
v_settings_brand UUID;
BEGIN
-- Load the timesheet header
SELECT brand_id, field_worker_id, period_start, period_end
INTO v_brand_id, v_worker_id, v_period_start, v_period_end
FROM time_tracking_timesheets
WHERE id = p_timesheet_id;
IF v_brand_id IS NULL THEN
RETURN;
END IF;
-- Pull daily OT threshold from settings (default 8h)
SELECT COALESCE(daily_overtime_threshold, 8)
INTO v_daily_thresh
FROM time_tracking_settings
WHERE brand_id = v_brand_id;
v_daily_thresh := COALESCE(v_daily_thresh, 8);
-- Aggregate underlying logs
SELECT
COALESCE(SUM(
GREATEST(0,
EXTRACT(EPOCH FROM (COALESCE(clock_out, NOW()) - clock_in)) / 60
- COALESCE(lunch_break_minutes, 0)
)
), 0)::INTEGER,
COALESCE(SUM(lunch_break_minutes), 0)::INTEGER,
COUNT(*)::INTEGER,
jsonb_object_agg(
d::text, mins
) FILTER (WHERE d IS NOT NULL)
INTO v_total, v_break, v_count, v_daily
FROM (
SELECT
(clock_in AT TIME ZONE 'America/Denver')::date AS d,
SUM(
GREATEST(0,
EXTRACT(EPOCH FROM (COALESCE(clock_out, NOW()) - clock_in)) / 60
- COALESCE(lunch_break_minutes, 0)
)
) AS mins
FROM time_tracking_logs
WHERE brand_id = v_brand_id
AND field_worker_id = v_worker_id
AND clock_in >= v_period_start
AND clock_in < (v_period_end + INTERVAL '1 day')
GROUP BY 1
) daily_agg
RIGHT JOIN time_tracking_logs l
ON l.brand_id = v_brand_id
AND l.field_worker_id = v_worker_id
AND l.clock_in >= v_period_start
AND l.clock_in < (v_period_end + INTERVAL '1 day');
v_daily := COALESCE(v_daily, '{}'::jsonb);
-- Daily OT = sum across days of (daily_mins - threshold*60)+
v_ot := 0;
IF jsonb_typeof(v_daily) = 'object' THEN
SELECT COALESCE(SUM(GREATEST(0, (v.value::numeric - v_daily_thresh * 60))), 0)::INTEGER
INTO v_ot
FROM jsonb_each(v_daily) v;
END IF;
v_regular := GREATEST(0, v_total - v_ot);
UPDATE time_tracking_timesheets
SET total_minutes = v_total,
regular_minutes = v_regular,
overtime_minutes = v_ot,
break_minutes = v_break,
entry_count = v_count,
daily_totals = v_daily
WHERE id = p_timesheet_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ───────────────────────────────────────────────────────────────────────────
-- 10. SECURITY DEFINER RPC — get-or-create a timesheet for a worker × period
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION get_or_create_timesheet(
p_brand_id UUID,
p_worker_id UUID,
p_period_start DATE,
p_period_end DATE
)
RETURNS UUID AS $$
DECLARE
v_id UUID;
BEGIN
SELECT id INTO v_id
FROM time_tracking_timesheets
WHERE field_worker_id = p_worker_id
AND period_start = p_period_start
AND period_end = p_period_end;
IF v_id IS NULL THEN
INSERT INTO time_tracking_timesheets
(brand_id, field_worker_id, period_start, period_end)
VALUES
(p_brand_id, p_worker_id, p_period_start, p_period_end)
RETURNING id INTO v_id;
PERFORM recompute_timesheet_totals(v_id);
END IF;
RETURN v_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
+16
View File
@@ -0,0 +1,16 @@
-- 0099_brand_hero_video.sql
-- Add an optional hero_video_url column to brand_settings so each brand can
-- point its storefront hero at any video URL (CDN, signed S3, Supabase
-- storage, etc.) without a code change. When unset or unplayable, the
-- storefront hero falls back to hero_image_url.
--
-- We intentionally avoid touching the `upsert_brand_settings(...)`
-- SECURITY DEFINER RPC here — its 32-parameter positional signature is
-- already deployed in production. New value writes use a separate
-- idempotent statement.
ALTER TABLE brand_settings
ADD COLUMN IF NOT EXISTS hero_video_url TEXT;
COMMENT ON COLUMN brand_settings.hero_video_url IS
'Optional. Direct URL to a hero background video (mp4/webm). When null or 4xx/5xx, storefront falls back to hero_image_url as a still poster.';
+59
View File
@@ -0,0 +1,59 @@
/**
* Cycle 10 — Unified field worker table.
*
* Replaces `water_irrigators` + `time_tracking_workers` (one row per
* person with two independent PIN hashes) with one row, one PIN hash,
* one role vocabulary.
*
* Role vocabulary (matches the CHECK constraint in
* `db/migrations/0097_field_workers.sql`):
* - `worker` — default; can submit water entries + clock in/out
* - `time_admin` — can manage time-tracking tasks, settings, and
* other time workers
* - `irrigator` — legacy role from `water_irrigators`; same powers
* as `worker` but kept distinct for UI filtering
* (the water admin UI shows this label)
* - `water_admin` — can manage headgates, water workers, and water
* entries
*
* The PIN is a scrypt self-describing hash from `@/lib/water-log-pin`
* (column name `pin_hash`, NOT `pin` — see cycle 10 migration for the
* rename).
*/
import { boolean, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { brands } from "./brands";
export const fieldWorkers = pgTable(
"field_workers",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
pinHash: text("pin_hash").notNull(),
role: text("role", {
enum: ["worker", "time_admin", "irrigator", "water_admin", "driver", "supervisor"],
})
.notNull()
.default("worker"),
languagePreference: text("language_preference").notNull().default("en"),
phone: text("phone"),
notes: text("notes"),
active: boolean("active").notNull().default(true),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("field_workers_brand_idx").on(t.brandId),
}),
);
export type FieldWorker = typeof fieldWorkers.$inferSelect;
export type NewFieldWorker = typeof fieldWorkers.$inferInsert;
export type FieldWorkerRole = FieldWorker["role"];
+1
View File
@@ -14,6 +14,7 @@ export * from "./water-log";
export * from "./communications";
export * from "./marketing";
export * from "./time-tracking";
export * from "./field-workers";
export * from "./shipping";
export * from "./support";
export * from "./files";
+66
View File
@@ -0,0 +1,66 @@
/**
* Smartsheet workspace — per-brand workbook connection.
*
* Cycle 7 schema for migration 0096. Replaces two independent Smartsheet
* configs (water + time tracking) with one brand-level workbook hub.
*
* The encrypted API token lives here. Per-feature configs
* (`water_smartsheet_config`, `time_tracking_smartsheet_config`) keep
* their sheet_id, column_mapping, sync_frequency, sync_enabled, and
* last_sync_* state but read the token from this table via brand_id.
*
* RLS: brand-scoped, same pattern as existing smartsheet tables.
*/
import {
pgTable,
uuid,
text,
boolean,
timestamp,
customType,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
const bytea = customType<{ data: Buffer; default: false }>({
dataType() {
return "bytea";
},
});
export const smartsheetWorkspace = pgTable("smartsheet_workspace", {
brandId: uuid("brand_id")
.primaryKey()
.references(() => brands.id, { onDelete: "cascade" }),
// AES-256-GCM encrypted API token. Same key as the old feature
// configs (SMARTSHEET_TOKEN_ENC_KEY); bytes are portable.
encryptedApiToken: bytea("encrypted_api_token").notNull(),
tokenIv: bytea("token_iv").notNull(),
tokenAuthTag: bytea("token_auth_tag").notNull(),
// Optional "home" sheet — the tab the brand sees first in the
// workbook. NULL is fine; the UI falls back to the first per-
// feature sheet mapping.
defaultSheetId: text("default_sheet_id"),
// Master kill-switch. Per-feature sync_enabled still gates each
// feature independently — toggling this off pauses all syncs.
connectionEnabled: boolean("connection_enabled").notNull().default(true),
// Test-connection bookkeeping (powers the "Last verified 2m ago"
// badge on the hub card).
lastTestAt: timestamp("last_test_at", { withTimezone: true }),
lastTestError: text("last_test_error"),
createdBy: text("created_by"),
updatedBy: text("updated_by"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export type SmartsheetWorkspace = typeof smartsheetWorkspace.$inferSelect;
export type SmartsheetWorkspaceInsert = typeof smartsheetWorkspace.$inferInsert;
+382 -31
View File
@@ -1,5 +1,20 @@
/**
* Time Tracking. Source: `db/migrations/0001_init.sql`.
* Time Tracking. Source: `db/migrations/0001_init.sql` and the
* Cycle-5 Smartsheet scaffold (`db/migrations/0095_*.sql`).
*
* Cycle 7: the encrypted token columns on
* `time_tracking_smartsheet_config` moved to `smartsheet_workspace`
* (see `db/schema/smartsheet-workspace.ts`).
*
* Cycle 10: the `time_tracking_workers` table was DROPPED in
* `db/migrations/0097_field_workers.sql`. Worker records now live
* in `field_workers` (see `db/schema/field-workers.ts`). The
* `worker_id` column on the downstream tables
* (`time_tracking_logs`, `time_tracking_notification_log`) was
* renamed to `field_worker_id` and the FK repointed.
*
* Cycle 12 (Phase 2): GPS columns + manual entries + audit log +
* timesheet approval workflow (0098_time_tracking_timesheets.sql).
*/
import {
pgTable,
@@ -10,8 +25,12 @@ import {
boolean,
timestamp,
index,
jsonb,
date,
doublePrecision,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
import { brands, adminUsers } from "./brands";
import { fieldWorkers } from "./field-workers";
export const timeTrackingSettings = pgTable(
"time_tracking_settings",
@@ -49,29 +68,12 @@ export const timeTrackingSettings = pgTable(
},
);
export const timeTrackingWorkers = pgTable(
"time_tracking_workers",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
role: text("role", { enum: ["worker", "time_admin"] })
.notNull()
.default("worker"),
lang: text("lang").notNull().default("en"),
pin: text("pin").notNull(),
active: boolean("active").notNull().default(true),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("time_tracking_workers_brand_idx").on(t.brandId),
}),
);
// Cycle 10: `time_tracking_workers` table was DROPPED in
// 0097_field_workers.sql. Worker records now live in `field_workers`
// (see `db/schema/field-workers.ts`). Any code that previously read
// from timeTrackingWorkers should now read from fieldWorkers and
// filter by role = 'worker' or 'time_admin' if it needs time-only
// workers.
export const timeTrackingTasks = pgTable(
"time_tracking_tasks",
@@ -103,9 +105,10 @@ export const timeTrackingLogs = pgTable(
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
workerId: uuid("worker_id")
/** Cycle 10: was `worker_id` (FK → time_tracking_workers); now unified. */
fieldWorkerId: uuid("field_worker_id")
.notNull()
.references(() => timeTrackingWorkers.id, { onDelete: "cascade" }),
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
taskId: uuid("task_id").references(() => timeTrackingTasks.id, {
onDelete: "set null",
}),
@@ -117,14 +120,47 @@ export const timeTrackingLogs = pgTable(
submittedVia: text("submitted_via", {
enum: ["manual", "field", "import"],
}).notNull().default("manual"),
// ── Migration 0098: GPS + manual entries + edit audit ────────
/** `'clock'` = from the field app, `'manual'` = admin-entered. */
entryKind: text("entry_kind", { enum: ["clock", "manual"] })
.notNull()
.default("clock"),
/** Required when `entry_kind = 'manual'`. Surfaced in payroll exports. */
manualReason: text("manual_reason"),
/** GPS columns — captured at clock-in / clock-out. Nullable: device
* may have denied geolocation, in which case `gpsVerified = false`. */
clockInLat: doublePrecision("clock_in_lat"),
clockInLng: doublePrecision("clock_in_lng"),
clockInAccuracyM: doublePrecision("clock_in_accuracy_m"),
clockOutLat: doublePrecision("clock_out_lat"),
clockOutLng: doublePrecision("clock_out_lng"),
clockOutAccuracyM: doublePrecision("clock_out_accuracy_m"),
/** True iff the device supplied a fix with accuracy ≤ 100m. */
gpsVerified: boolean("gps_verified").notNull().default(false),
/** Edit audit — filled every time a server action updates the row
* after creation. Never cleared. */
editedAt: timestamp("edited_at", { withTimezone: true }),
editedBy: uuid("edited_by").references(() => adminUsers.id, {
onDelete: "set null",
}),
editReason: text("edit_reason"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("time_tracking_logs_brand_idx").on(t.brandId),
workerIdx: index("time_tracking_logs_worker_idx").on(t.workerId),
fieldWorkerIdx: index("time_tracking_logs_field_worker_idx").on(
t.fieldWorkerId,
),
clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn),
brandKindIdx: index("time_tracking_logs_brand_kind_idx").on(
t.brandId,
t.entryKind,
t.clockIn,
),
}),
);
@@ -135,8 +171,9 @@ export const timeTrackingNotificationLog = pgTable(
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
workerId: uuid("worker_id").references(
() => timeTrackingWorkers.id,
/** Cycle 10: was `worker_id` (FK → time_tracking_workers); now unified. */
fieldWorkerId: uuid("field_worker_id").references(
() => fieldWorkers.id,
{ onDelete: "set null" },
),
notificationType: text("notification_type").notNull(),
@@ -153,9 +190,323 @@ export const timeTrackingNotificationLog = pgTable(
},
);
// ── Migration 0098: Timesheet + approval workflow ─────────────────────────
export const TIMESHEET_STATUSES = [
"draft",
"submitted",
"approved",
"rejected",
] as const;
export type TimesheetStatus = (typeof TIMESHEET_STATUSES)[number];
export const timeTrackingTimesheets = pgTable(
"time_tracking_timesheets",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
fieldWorkerId: uuid("field_worker_id")
.notNull()
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
periodStart: date("period_start").notNull(),
periodEnd: date("period_end").notNull(),
status: text("status", { enum: TIMESHEET_STATUSES })
.notNull()
.default("draft"),
// Submission
submittedAt: timestamp("submitted_at", { withTimezone: true }),
submittedBy: uuid("submitted_by").references(() => adminUsers.id, {
onDelete: "set null",
}),
submittedNotes: text("submitted_notes"),
// Approval (locks the timesheet)
reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
reviewedBy: uuid("reviewed_by").references(() => adminUsers.id, {
onDelete: "set null",
}),
reviewerComment: text("reviewer_comment"),
// Rejection
rejectedAt: timestamp("rejected_at", { withTimezone: true }),
rejectedBy: uuid("rejected_by").references(() => adminUsers.id, {
onDelete: "set null",
}),
rejectionReason: text("rejection_reason"),
// Lock mirror (always equals reviewed_at when status=approved)
lockedAt: timestamp("locked_at", { withTimezone: true }),
lockedBy: uuid("locked_by").references(() => adminUsers.id, {
onDelete: "set null",
}),
// Unlock (rare, admin-only, MUST carry an audit note)
unlockedAt: timestamp("unlocked_at", { withTimezone: true }),
unlockedBy: uuid("unlocked_by").references(() => adminUsers.id, {
onDelete: "set null",
}),
unlockAuditNote: text("unlock_audit_note").notNull().default(""),
// Pre-computed totals (recomputed by recompute_timesheet_totals RPC)
totalMinutes: integer("total_minutes").notNull().default(0),
regularMinutes: integer("regular_minutes").notNull().default(0),
overtimeMinutes: integer("overtime_minutes").notNull().default(0),
breakMinutes: integer("break_minutes").notNull().default(0),
entryCount: integer("entry_count").notNull().default(0),
/** Map of date string ("YYYY-MM-DD") → minutes worked that day. */
dailyTotals: jsonb("daily_totals").notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
workerPeriodUniq: index(
"time_tracking_timesheets_worker_period_uniq",
).on(t.fieldWorkerId, t.periodStart, t.periodEnd),
brandStatusIdx: index("time_tracking_timesheets_brand_status_idx").on(
t.brandId,
t.status,
t.periodStart,
),
brandWorkerIdx: index("time_tracking_timesheets_brand_worker_idx").on(
t.brandId,
t.fieldWorkerId,
),
}),
);
/** Append-only audit log — every mutating action on a timesheet or its
* underlying logs MUST write a row here in the same DB transaction. */
export const timeTrackingAuditLog = pgTable(
"time_tracking_audit_log",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
/** Admin user (or null when the actor was the worker themselves). */
actorId: uuid("actor_id").references(() => adminUsers.id, {
onDelete: "set null",
}),
actorLabel: text("actor_label").notNull(),
actorKind: text("actor_kind").notNull().default("admin"),
action: text("action").notNull(),
entityType: text("entity_type").notNull(),
entityId: uuid("entity_id"),
details: jsonb("details").notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandRecentIdx: index("time_tracking_audit_log_brand_recent_idx").on(
t.brandId,
t.createdAt,
),
entityIdx: index("time_tracking_audit_log_entity_idx").on(
t.entityType,
t.entityId,
),
}),
);
/** Supervisor ↔ supervisee edges. The brand admin assigns edges; the
* approval queue filters by them. */
export const timeTrackingSupervisorAssignments = pgTable(
"time_tracking_supervisor_assignments",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
supervisorFieldWorkerId: uuid("supervisor_field_worker_id")
.notNull()
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
superviseeFieldWorkerId: uuid("supervisee_field_worker_id")
.notNull()
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
uniq: index("time_tracking_supervisor_assignments_uniq").on(
t.supervisorFieldWorkerId,
t.superviseeFieldWorkerId,
),
supervisorIdx: index(
"time_tracking_supervisor_assignments_supervisor_idx",
).on(t.supervisorFieldWorkerId),
}),
);
export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect;
export type TimeTrackingWorker = typeof timeTrackingWorkers.$inferSelect;
// Cycle 10: `TimeTrackingWorker` was removed. Use `FieldWorker` from
// `./field-workers` instead; filter by role = 'worker' or 'time_admin'
// if you need time-only workers.
export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
export type TimeTrackingNotificationLog =
typeof timeTrackingNotificationLog.$inferSelect;
export type TimeTrackingTimesheet = typeof timeTrackingTimesheets.$inferSelect;
export type TimeTrackingTimesheetInsert =
typeof timeTrackingTimesheets.$inferInsert;
export type TimeTrackingAuditLogEntry =
typeof timeTrackingAuditLog.$inferSelect;
export type TimeTrackingSupervisorAssignment =
typeof timeTrackingSupervisorAssignments.$inferSelect;
// ── Inferred types ──
// 0098 — daily totals JSON shape stored on the timesheet
export type DailyTotals = Record<string, number>;
// ── Smartsheet sync (Cycle 5) ──────────────────────────────────────────────
/**
* Allowed sync frequencies. Mirrors the water-log smartsheet
* pattern (see `db/schema/water-log.ts` SMARTSHEET_FREQUENCIES).
*/
export const TT_SMARTSHEET_FREQUENCIES = [
"realtime",
"every_15_minutes",
"hourly",
] as const;
export type TTSmartsheetFrequency = (typeof TT_SMARTSHEET_FREQUENCIES)[number];
/**
* Time-tracking fields a brand can map to their Smartsheet columns.
* `log_id` and `clock_in` are required (used for dedup).
*/
export type TTSmartsheetColumnKey =
| "log_id"
| "clock_in"
| "clock_out"
| "worker"
| "task"
| "hours"
| "lunch_minutes"
| "notes";
export const TT_SMARTSHEET_COLUMN_KEYS: readonly TTSmartsheetColumnKey[] = [
"log_id",
"clock_in",
"clock_out",
"worker",
"task",
"hours",
"lunch_minutes",
"notes",
] as const;
/**
* Shape stored in `time_tracking_smartsheet_config.column_mapping`.
* Required: log_id + clock_in (for dedup). All others nullable.
*/
export type TTSmartsheetColumnMapping = {
log_id: string;
clock_in: string;
clock_out: string | null;
worker: string | null;
task: string | null;
hours: string | null;
lunch_minutes: string | null;
notes: string | null;
};
export const timeTrackingSmartsheetConfig = pgTable(
"time_tracking_smartsheet_config",
{
brandId: uuid("brand_id")
.primaryKey()
.references(() => brands.id, { onDelete: "cascade" }),
sheetId: text("sheet_id").notNull(),
// Cycle 7: encrypted token columns moved to smartsheet_workspace.
// See migration 0096_smartsheet_workbook_hub.sql.
columnMapping: jsonb("column_mapping")
.$type<TTSmartsheetColumnMapping>()
.notNull(),
syncFrequency: text("sync_frequency")
.$type<TTSmartsheetFrequency>()
.notNull()
.default("hourly"),
syncEnabled: boolean("sync_enabled").notNull().default(false),
lastSyncAt: timestamp("last_sync_at", { withTimezone: true }),
lastSyncError: text("last_sync_error"),
createdBy: text("created_by"),
updatedBy: text("updated_by"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export const timeTrackingSmartsheetSyncQueue = pgTable(
"time_tracking_smartsheet_sync_queue",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
logId: uuid("log_id")
.notNull()
.references(() => timeTrackingLogs.id, { onDelete: "cascade" }),
smartsheetRowId: text("smartsheet_row_id"),
status: text("status", {
enum: ["pending", "syncing", "synced", "failed"],
})
.notNull()
.default("pending"),
attempts: integer("attempts").notNull().default(0),
lastError: text("last_error"),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true })
.notNull()
.defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
syncedAt: timestamp("synced_at", { withTimezone: true }),
},
);
export const timeTrackingSmartsheetSyncLog = pgTable(
"time_tracking_smartsheet_sync_log",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
logId: uuid("log_id").references(() => timeTrackingLogs.id, {
onDelete: "set null",
}),
smartsheetRowId: text("smartsheet_row_id"),
action: text("action", {
enum: ["sync", "retry", "skip", "queue"],
}).notNull(),
success: boolean("success").notNull(),
error: text("error"),
durationMs: integer("duration_ms"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export type TimeTrackingSmartsheetConfig =
typeof timeTrackingSmartsheetConfig.$inferSelect;
export type TimeTrackingSmartsheetConfigInsert =
typeof timeTrackingSmartsheetConfig.$inferInsert;
export type TimeTrackingSmartsheetSyncQueue =
typeof timeTrackingSmartsheetSyncQueue.$inferSelect;
export type TimeTrackingSmartsheetSyncLog =
typeof timeTrackingSmartsheetSyncLog.$inferSelect;
+26 -46
View File
@@ -3,7 +3,8 @@
*
* Six tables, all brand-scoped with RLS:
* - water_headgates — physical gates a measurement is tied to
* - water_irrigators — PIN-authenticated field workers
* - field_workers — unified worker table (cycle 10; replaces
* water_irrigators + time_tracking_workers)
* - water_sessions — short-lived PIN sessions for irrigators
* - water_log_entries — the actual reading logs
* - water_alert_log — high/low threshold alert history
@@ -24,18 +25,15 @@ import {
index,
uniqueIndex,
integer,
customType,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
import { adminUsers } from "./brands";
import { fieldWorkers } from "./field-workers";
// Drizzle's stock `bytea` import is not exported in every version, so
// register a small custom type that maps to the existing `bytea` PG type.
const bytea = customType<{ data: Buffer; default: false }>({
dataType() {
return "bytea";
},
});
// Cycle 7: the `bytea` customType used to live here for the
// encrypted Smartsheet token columns. Those columns moved to
// `smartsheet_workspace` (see `db/schema/smartsheet-workspace.ts`),
// so the helper is no longer needed here.
export const waterHeadgates = pgTable(
"water_headgates",
@@ -70,40 +68,21 @@ export const waterHeadgates = pgTable(
}),
);
export const waterIrrigators = pgTable(
"water_irrigators",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
pinHash: text("pin_hash").notNull(),
languagePreference: text("language_preference")
.notNull()
.default("en"),
/** "irrigator" submits entries only, "water_admin" can manage the brand. */
role: text("role").notNull().default("irrigator"),
phone: text("phone"),
notes: text("notes"),
active: boolean("active").notNull().default(true),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("water_irrigators_brand_idx").on(t.brandId),
}),
);
// Cycle 10: water_irrigators table was DROPPED in
// 0097_field_workers.sql. Worker records now live in
// `field_workers` (see `db/schema/field-workers.ts`).
// Any code that previously read from waterIrrigators should now
// read from fieldWorkers and filter by role = 'irrigator' or
// 'water_admin' if it needs water-only workers.
export const waterSessions = pgTable(
"water_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
irrigatorId: uuid("irrigator_id")
/** Cycle 10: was `irrigator_id` (FK → water_irrigators); now unified. */
fieldWorkerId: uuid("field_worker_id")
.notNull()
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
@@ -121,9 +100,10 @@ export const waterLogEntries = pgTable(
headgateId: uuid("headgate_id")
.notNull()
.references(() => waterHeadgates.id, { onDelete: "cascade" }),
irrigatorId: uuid("irrigator_id")
/** Cycle 10: was `irrigator_id` (FK → water_irrigators); now unified. */
fieldWorkerId: uuid("field_worker_id")
.notNull()
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
/** Raw measurement value, in the entry's `unit`. */
measurement: numeric("measurement").notNull(),
unit: text("unit").notNull(),
@@ -150,8 +130,8 @@ export const waterLogEntries = pgTable(
t.brandId,
t.loggedDate,
),
irrigatorIdx: index("water_log_entries_irrigator_idx").on(
t.irrigatorId,
fieldWorkerIdx: index("water_log_entries_field_worker_idx").on(
t.fieldWorkerId,
t.loggedAt,
),
}),
@@ -252,8 +232,9 @@ export const waterAuditLog = pgTable(
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert;
// Cycle 10: `WaterIrrigator` / `WaterIrrigatorInsert` were removed.
// Use `FieldWorker` from `./field-workers` instead; filter by role =
// 'irrigator' or 'water_admin' if you need water-only workers.
export type WaterSession = typeof waterSessions.$inferSelect;
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
@@ -323,9 +304,8 @@ export const waterSmartsheetConfig = pgTable("water_smartsheet_config", {
.primaryKey()
.references(() => brands.id, { onDelete: "cascade" }),
sheetId: text("sheet_id").notNull(),
encryptedApiToken: bytea("encrypted_api_token").notNull(),
tokenIv: bytea("token_iv").notNull(),
tokenAuthTag: bytea("token_auth_tag").notNull(),
// Cycle 7: encrypted token columns moved to smartsheet_workspace.
// See migration 0096_smartsheet_workbook_hub.sql.
columnMapping: jsonb("column_mapping")
.$type<SmartsheetColumnMapping>()
.notNull(),
+15 -12
View File
@@ -307,13 +307,23 @@ ON CONFLICT DO NOTHING;
-- ============================================================================
-- 11. Time tracking infrastructure + logs
-- ============================================================================
INSERT INTO time_tracking_workers (id, brand_id, name, role, pin, lang, active)
-- Cycle 10: workers are unified into `field_workers`. We seed both the
-- former `time_tracking_workers` (role worker|time_admin) and the former
-- `water_irrigators` (role irrigator|water_admin) into the same table.
INSERT INTO field_workers (id, brand_id, name, role, pin_hash, language_preference, active)
SELECT gen_random_uuid(), b.id, 'Worker ' || i, CASE (i%2) WHEN 0 THEN 'worker' ELSE 'time_admin' END, lpad((1000+i)::text, 4, '0'), 'en', true
FROM brands b
CROSS JOIN generate_series(1, 5) AS i
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
ON CONFLICT DO NOTHING;
INSERT INTO field_workers (id, brand_id, name, role, pin_hash, language_preference, phone, active)
SELECT gen_random_uuid(), b.id, 'Irrigator ' || i, 'irrigator', 'placeholder-pin-hash', 'en', '+15550100000', true
FROM brands b
CROSS JOIN generate_series(1, 5) AS i
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
ON CONFLICT DO NOTHING;
INSERT INTO time_tracking_tasks (id, brand_id, name, unit, sort_order, active)
SELECT gen_random_uuid(), b.id, 'Task ' || i, CASE (i%3) WHEN 0 THEN 'hours' WHEN 1 THEN 'pieces' ELSE 'units' END, i, true
FROM brands b
@@ -321,10 +331,10 @@ CROSS JOIN generate_series(1, 8) AS i
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
ON CONFLICT DO NOTHING;
INSERT INTO time_tracking_logs (brand_id, worker_id, task_id, task_name, clock_in, clock_out, lunch_break_minutes, notes)
INSERT INTO time_tracking_logs (brand_id, field_worker_id, task_id, task_name, clock_in, clock_out, lunch_break_minutes, notes)
SELECT
b.id,
(SELECT id FROM time_tracking_workers WHERE brand_id = b.id ORDER BY random() LIMIT 1),
(SELECT id FROM field_workers WHERE brand_id = b.id AND role IN ('worker','time_admin') ORDER BY random() LIMIT 1),
(SELECT id FROM time_tracking_tasks WHERE brand_id = b.id ORDER BY random() LIMIT 1),
'Task ' || (1 + (i % 8)),
(NOW() - ((i % 30) || ' days')::interval - ((i % 8) || ' hours')::interval),
@@ -346,18 +356,11 @@ CROSS JOIN generate_series(1, 5) AS i
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
ON CONFLICT DO NOTHING;
INSERT INTO water_irrigators (id, brand_id, name, pin_hash, language_preference, role, phone, active)
SELECT gen_random_uuid(), b.id, 'Irrigator ' || i, 'placeholder-pin-hash', 'en', 'irrigator', '+15550100000', true
FROM brands b
CROSS JOIN generate_series(1, 5) AS i
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
ON CONFLICT DO NOTHING;
INSERT INTO water_log_entries (brand_id, headgate_id, irrigator_id, measurement, unit, logged_at, logged_by, method, total_gallons, notes)
INSERT INTO water_log_entries (brand_id, headgate_id, field_worker_id, measurement, unit, logged_at, logged_by, method, total_gallons, notes)
SELECT
b.id,
(SELECT id FROM water_headgates WHERE brand_id = b.id ORDER BY random() LIMIT 1),
(SELECT id FROM water_irrigators WHERE brand_id = b.id ORDER BY random() LIMIT 1),
(SELECT id FROM field_workers WHERE brand_id = b.id AND role IN ('irrigator','water_admin') ORDER BY random() LIMIT 1),
(50 + (i * 7) % 200)::numeric,
'GPM',
NOW() - ((i % 14) || ' days')::interval,
+6
View File
@@ -34,6 +34,12 @@ const nextConfig: NextConfig = {
protocol: "https",
hostname: "picsum.photos",
},
{
// Brand media hosted on the crispygoat MinIO bucket
// (e.g. s3.crispygoat.com/videos/wp-import/...)
protocol: "https",
hostname: "s3.crispygoat.com",
},
],
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Water Log — Tuxedo",
"short_name": "Water Log",
"description": "Field access for Tuxedo water + time tracking",
"start_url": "/water",
"scope": "/water",
"display": "standalone",
"background_color": "#f2f2f7",
"theme_color": "#14532d",
"orientation": "portrait-primary",
"icons": [
{ "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"categories": ["productivity", "utilities"]
}
+134
View File
@@ -0,0 +1,134 @@
// public/sw-field.js — Tuxedo field-worker service worker.
//
// Cycle 6 sub-PWA, scoped to /water. Tighter cache strategy than
// the main shell SW because:
// - The field app is a thin surface (PIN, headgate list, log form,
// time tab). Few async chunks; mostly static + the latest data
// snapshot.
// - "Offline" here means "still see the cached PIN screen + headgate
// list" — water entry without connectivity is a degraded but
// non-blocking experience (the form queue handles drafts).
//
// Cache names are deliberately prefixed with `field-` so they never
// collide with the main `rc-shell-v*` / `rc-data-v*` caches.
// Bumped to v2 after the Apple HIG polish pass (liquid-glass chrome,
// iOS SegmentedControl, ThresholdMeter). The activate handler keeps
// only this name + DATA_CACHE — anything else starting with `field-`
// (including the old v1) is evicted on the next SW activation, which
// happens automatically because the bytes of this file changed.
const SHELL_CACHE = "field-shell-v2";
const DATA_CACHE = "field-data-v1";
const OFFLINE_URL = "/offline.html";
const SHELL_ASSETS = [
"/water",
"/manifest-field.json",
"/favicon.svg",
"/og-default.svg",
"/offline.html",
"/icons/icon-192x192.png",
"/icons/icon-512x512.png",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
// Only manage field-* caches — never touch the main app's caches.
event.waitUntil(
Promise.all([
caches.keys().then((cacheNames) => {
const toDelete = [];
for (const name of cacheNames) {
if (
name !== SHELL_CACHE &&
name !== DATA_CACHE &&
name.startsWith("field-")
) {
toDelete.push(caches.delete(name));
}
}
return Promise.all(toDelete);
}),
self.clients.claim(),
])
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
// Out-of-scope requests: skip (let the main app SW handle them
// if it's installed for that scope; fall through to network).
if (!url.pathname.startsWith("/water")) return;
// Navigations → network-first, fall back to cache, fall back to offline page.
if (req.mode === "navigate") {
event.respondWith(
fetch(req)
.then((res) => {
const clone = res.clone();
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
return res;
})
.catch(() =>
caches.match(req).then(
(cached) => cached ?? caches.match(OFFLINE_URL),
),
),
);
return;
}
// Static assets → cache-first
if (
url.pathname.startsWith("/_next/static/") ||
url.pathname.startsWith("/icons/") ||
url.pathname.endsWith(".svg") ||
url.pathname.endsWith(".woff2")
) {
event.respondWith(
caches.match(req).then((cached) => {
if (cached) return cached;
return fetch(req).then((res) => {
const clone = res.clone();
if (res.status === 200) {
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
}
return res;
});
}),
);
return;
}
// /api/water* GETs → stale-while-revalidate (so the headgate list
// and worker availability can hydrate from cache while we re-fetch).
if (url.pathname.startsWith("/api/water")) {
event.respondWith(
caches.open(DATA_CACHE).then((cache) =>
cache.match(req).then((cached) => {
const network = fetch(req)
.then((res) => {
if (res.status === 200) cache.put(req, res.clone());
return res;
})
.catch(() => cached);
return cached ?? network;
}),
),
);
return;
}
// Default: try network, fall back to cache.
event.respondWith(fetch(req).catch(() => caches.match(req)));
});
+103
View File
@@ -0,0 +1,103 @@
/**
* Cycle 12 deep-flow smoke — walks Hub → Manual Entry → submit,
* Hub → History. Captures screenshots along the way and asserts
* each screen renders the expected layout.
*/
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:4000";
const OUT = "tests/_artifacts/cycle12";
mkdirSync(OUT, { recursive: true });
// Use worker_id 5a83c1a3-... (Worker 1) but spoof the brand so we
// hit the Tuxedo brandId the running server expects.
const COOKIE_VALUE = [
"5a83c1a3-8eb5-44c4-bb66-323e72d38c91",
"smoke-" + Math.random().toString(36).slice(2, 10),
new Date(Date.now() + 12 * 3600 * 1000).toISOString(),
"Worker 1",
"time_admin",
"en",
// Brand 64294306 is the one the server logs show loading from —
// matches `TUXEDO_BRAND_ID` constant.
"11111111-1111-1111-1111-111111111111",
].join("|");
async function snap(page, name) {
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: false });
}
async function expectText(page, regex, label) {
const text = await page.locator("body").innerText();
if (!regex.test(text)) {
console.error(`${label}: didn't match ${regex}`);
console.error("body snapshot:\n" + text.slice(0, 1500));
process.exit(1);
}
console.log(`${label}`);
}
async function main() {
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 412, height: 900 },
deviceScaleFactor: 2,
});
await ctx.addCookies([
{
name: "time_tracking_session",
value: COOKIE_VALUE,
url: BASE,
httpOnly: false,
secure: false,
sameSite: "Lax",
},
]);
const page = await ctx.newPage();
page.on("pageerror", (e) => console.error("PAGE ERROR:", e.message));
page.on("console", (m) => {
if (m.type() === "error") console.error("CONSOLE ERR:", m.text());
});
// ── 1) Hub
await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(2000);
await snap(page, "01_hub");
await expectText(page, /Recent Activity/i, "Hub renders 'Recent activity' tile");
await expectText(page, /Manual entry/i, "Hub renders 'Manual entry' tile");
// ── 2) Manual entry
await page.locator('button:has-text("Manual entry")').first().click();
await page.waitForTimeout(1000);
await snap(page, "02_manual_entry");
await expectText(page, /Manual entry/i, "Manual entry screen title");
await expectText(page, /WHEN/i, "Manual entry 'WHEN' section");
await expectText(page, /WHY/i, "Manual entry 'WHY' section");
// Try to submit empty — should show reason error
// Reason field is the second text input on the page (first is Task)
const reasonInput = page.locator('input[placeholder*="Required"]').first();
await reasonInput.fill("Forgot to clock in this morning");
await snap(page, "03_manual_filled");
await expectText(page, /Submit entry/i, "Submit button visible");
// ── 3) Recent activity
await page.locator('button[aria-label*="Atr" i], button[aria-label*="Back"]').first().click().catch(() => {});
await page.waitForTimeout(800);
// We may not always find the back button — fall back to going to hub via reload
await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(2000);
await page.locator('button:has-text("Recent activity")').first().click();
await page.waitForTimeout(1500);
await snap(page, "04_history");
await expectText(page, /Recent Activity|Actividad reciente/i, "History title visible");
await browser.close();
console.log("\n✓ Cycle 12 deep smoke passed.");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+98
View File
@@ -0,0 +1,98 @@
/**
* Cycle 12 — submit a manual entry and verify the success screen
* renders. Uses yesterday's date so the server's 30-day window
* accepts it.
*/
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:4000";
const OUT = "tests/_artifacts/cycle12";
mkdirSync(OUT, { recursive: true });
const COOKIE_VALUE = [
"5a83c1a3-8eb5-44c4-bb66-323e72d38c91",
"smoke-" + Math.random().toString(36).slice(2, 10),
new Date(Date.now() + 12 * 3600 * 1000).toISOString(),
"Worker 1",
"time_admin",
"en",
"11111111-1111-1111-1111-111111111111",
].join("|");
async function main() {
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 412, height: 900 },
deviceScaleFactor: 2,
});
await ctx.addCookies([
{
name: "time_tracking_session",
value: COOKIE_VALUE,
url: BASE,
httpOnly: false,
secure: false,
sameSite: "Lax",
},
]);
const page = await ctx.newPage();
page.on("pageerror", (e) => console.error("PAGE ERROR:", e.message));
page.on("console", (m) => {
if (m.type() === "error") console.error("CONSOLE ERR:", m.text());
});
await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(2000);
// Hub → Manual entry
await page.locator('button:has-text("Manual entry")').first().click();
await page.waitForTimeout(800);
// Fill form. Date defaults to today; use yesterday's date to make
// sure we're definitely within the 30-day window and not in the
// future. Use a real-looking shift: 07:00 → 15:30 (8h 30m, minus
// 30m lunch = 8h).
const yesterday = new Date(Date.now() - 86_400_000);
const yyyy = yesterday.getFullYear();
const mm = String(yesterday.getMonth() + 1).padStart(2, "0");
const dd = String(yesterday.getDate()).padStart(2, "0");
const dateStr = `${yyyy}-${mm}-${dd}`;
await page.locator('input[type="date"]').first().fill(dateStr);
await page.locator('input[placeholder*="Harvesting"]').first().fill("Harvesting");
await page
.locator('input[placeholder*="Required"]')
.first()
.fill("Forgot to clock in this morning");
await page.screenshot({ path: `${OUT}/05_manual_before_submit.png` });
await page.locator('button:has-text("Submit entry")').click();
// Wait for either success or error
await page.waitForTimeout(3000);
await page.screenshot({ path: `${OUT}/06_after_submit.png` });
const text = await page.locator("body").innerText();
if (/Entry submitted|Entrada enviada/i.test(text)) {
console.log("✓ Success screen rendered");
} else if (/16 hours|30 days|3 characters|cannot be in the future|Must be after|Reason/i.test(text)) {
console.error("✗ Validation error visible:");
console.error(text.slice(0, 1500));
process.exit(1);
} else {
console.error("? Unknown state after submit:");
console.error(text.slice(0, 1500));
process.exit(1);
}
// Take a clean success shot by scrolling to top
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(300);
await page.screenshot({ path: `${OUT}/07_success.png` });
await browser.close();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+82
View File
@@ -0,0 +1,82 @@
// Regression test: brand logo (and all /brand-logos/... images) must load
// through Next.js Image optimizer on the tuxedo storefront.
//
// Pre-fix state:
// GET https://water.tuxedocorn.com/_next/image?url=%2Fbrand-logos%2F64294306-...%2Flogo.png
// → HTTP 400, body: "url parameter is not allowed"
//
// Post-fix expectation:
// → HTTP 200, content-type: image/* and non-empty body
//
// Root cause identified: Next.js standalone build is missing the
// `.next/standalone/public/` folder. The deploy workflow copies
// `.next/static` but not `public/`. Without `public/` in standalone, the
// image optimizer can't resolve local-relative URLs.
import { setTimeout as sleep } from 'node:timers/promises';
const URL = 'https://water.tuxedocorn.com/tuxedo?_=' + Date.now();
const LOGO_PROXY =
'https://water.tuxedocorn.com/_next/image?url=%2Fbrand-logos%2F64294306-5f42-463d-a5e8-2ad6c81a96de%2Flogo.png&w=256&q=75';
const failures = [];
// 1. Hit the image proxy directly
const r = await fetch(LOGO_PROXY, {
headers: { 'User-Agent': 'regression-brand-logos/1.0' },
});
if (r.status !== 200) {
failures.push(`Logo proxy returned HTTP ${r.status} (expected 200)`);
} else {
const ct = r.headers.get('content-type') ?? '';
if (!ct.startsWith('image/')) failures.push(`Logo proxy content-type was ${ct} (expected image/*)`);
const buf = new Uint8Array(await r.arrayBuffer());
if (buf.length < 1000) failures.push(`Logo proxy body too small: ${buf.length} bytes`);
console.log(` Logo proxy: HTTP ${r.status} ${ct} ${buf.length}B`);
}
// 2. Verify via headless browser — visit page, check that the logo <img> actually rendered.
const { chromium } = await import('playwright');
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const failed = [];
page.on('response', (resp) => {
const u = resp.url();
if (u.includes('brand-logos') && resp.status() >= 400) failed.push(`HTTP ${resp.status()} ${u.slice(0, 120)}`);
});
await page.goto(URL, { waitUntil: 'networkidle', timeout: 30000 });
// Scroll to bottom so footer logo loads.
await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }));
await sleep(4000);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
await sleep(2000);
const logoInfo = await page.evaluate(() => {
const imgs = Array.from(document.querySelectorAll('img'));
const logos = imgs.filter((i) => (i.currentSrc || i.src || '').includes('brand-logos'));
return logos.map((i) => ({
alt: i.alt.slice(0, 40),
currentSrc: (i.currentSrc || i.src || '').slice(0, 130),
naturalWidth: i.naturalWidth,
naturalHeight: i.naturalHeight,
complete: i.complete,
visible: i.getBoundingClientRect().height > 0,
}));
});
console.log('\n LOGO IMAGES ON PAGE:');
for (const l of logoInfo) console.log(' ', JSON.stringify(l));
const anyLogoBlank = logoInfo.some((l) => l.naturalWidth === 0 || !l.complete);
if (anyLogoBlank) failures.push(`Logo image(s) failed to render: ${JSON.stringify(logoInfo)}`);
if (failed.length > 0) failures.push(`Network failed for brand-logos URLs: ${failed.join(' | ')}`);
await browser.close();
if (failures.length > 0) {
console.error('\n❌ REGRESSION FAILED:');
for (const f of failures) console.error(' -', f);
process.exit(1);
} else {
console.log('\n✅ Brand logos serve correctly through Next.js Image optimizer.');
}
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* Production startup wrapper for `next start` when `output: "standalone"`
* is set in next.config.ts. Loads .env from a known absolute location,
* then exec's the standalone server with the loaded env.
*
* Why this exists:
* 1. `next start` against a standalone build is unsupported by Next.js —
* it prints "next start does not work with output: standalone
* configuration. Use `node .next/standalone/server.js` instead" — and
* silently disables the image optimizer (every `/_next/image?url=...`
* returned "url parameter is not allowed"), which is why all the
* WP-imported brand imagery never rendered. See commit 129c9d2.
*
* 2. bash's `set -a; . ./.env` truncates DATABASE_URL at the `&` in
* `&channel_binding=require`, so the server boots with empty DB env
* and the public storefront falls back to dark gradient backgrounds.
* A small Node loader sidesteps the bash word-splitting.
*
* 3. The standalone server reads `.next/static/` relative to itself, so
* the deploy workflow must `cp -r .next/static .next/standalone/.next/static`
* after every sync.
*
* Usage:
* PORT=3100 HOSTNAME=0.0.0.0 \
* NODE_BIN=/home/tyler/.cache/act/tool_cache/node/22.22.3/x64/bin/node \
* node /home/tyler/route-commerce/scripts/start-standalone.cjs
*
* pm2 typically overrides interpreter with its own node and sets `cwd`
* arbitrarily, so we resolve the project root from the script's own path
* instead of `process.cwd()`.
*/
const fs = require("fs");
const path = require("path");
const APP_DIR = path.resolve(__dirname, "..");
const ENV_PATH = process.env.ENV_FILE || path.join(APP_DIR, ".env");
const STANDALONE = path.join(APP_DIR, ".next", "standalone", "server.js");
if (!fs.existsSync(STANDALONE)) {
console.error(`[start-standalone] Missing standalone server at ${STANDALONE}`);
process.exit(1);
}
if (fs.existsSync(ENV_PATH)) {
for (const line of fs.readFileSync(ENV_PATH, "utf8").split("\n")) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
}
console.log(`[start-standalone] loaded env from ${ENV_PATH}`);
} else {
console.warn(`[start-standalone] no .env at ${ENV_PATH}; relying on existing env`);
}
// Hand off to the standalone server. Re-exec keeps the process title at
// `next-server` (next-start.sh sets it) and matches `node .next/standalone/server.js`.
const { spawn } = require("child_process");
const child = spawn(process.execPath, [STANDALONE], { stdio: "inherit", env: process.env });
child.on("exit", (code) => process.exit(code ?? 0));
for (const sig of ["SIGINT", "SIGTERM"]) {
process.on(sig, () => child.kill(sig));
}
+26 -3
View File
@@ -216,6 +216,7 @@ export type BrandSettings = {
show_text_alerts: boolean | null;
schedule_pdf_notes: string | null;
hero_image_url: string | null;
hero_video_url: string | null;
// Color customization
brand_primary_color: string | null;
brand_secondary_color: string | null;
@@ -261,7 +262,7 @@ export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBran
await getSession(); try {
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
`SELECT bs.*, b.name AS brand_name, ws.wholesale_enabled
`SELECT bs.*, b.name AS brand_name, ws.online_payment_enabled AS wholesale_enabled
FROM brands b
JOIN brand_settings bs ON bs.brand_id = b.id
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
@@ -271,6 +272,7 @@ await getSession(); try {
);
const data = rows[0];
if (!data) {
console.warn(`[getBrandSettingsPublic] No row for slug=${brandSlug}`);
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
}
return {
@@ -278,7 +280,8 @@ await getSession(); try {
settings: data,
wholesaleEnabled: data.wholesale_enabled,
};
} catch {
} catch (err) {
console.error(`[getBrandSettingsPublic] DB error for slug=${brandSlug}:`, err);
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
}
}
@@ -311,6 +314,7 @@ export async function saveBrandSettings(params: {
showTextAlerts?: boolean;
schedulePdfNotes?: string;
heroImageUrl?: string;
heroVideoUrl?: string;
brandPrimaryColor?: string;
brandSecondaryColor?: string;
brandBgColor?: string;
@@ -372,7 +376,26 @@ await getSession(); const adminUser = await getAdminUser();
params.nexusStates ?? null,
],
);
return { success: true, settings: rows[0] as BrandSettings };
// The `upsert_brand_settings` SECURITY DEFINER RPC has a fixed
// 32-parameter signature shipped in production. New columns added
// after that signature was frozen (e.g. `hero_video_url` from
// migration 0099) are written via a separate idempotent UPDATE.
// Pass NULL through unchanged — admin form sends `null` when the
// field is left empty.
await pool.query(
`UPDATE brand_settings
SET hero_video_url = $2
WHERE brand_id = $1`,
[params.brandId, params.heroVideoUrl ?? null],
);
// Re-read so callers see the merged row.
const merged = await pool.query<BrandSettings>(
`SELECT * FROM brand_settings WHERE brand_id = $1 LIMIT 1`,
[params.brandId],
);
return { success: true, settings: merged.rows[0] ?? (rows[0] as BrandSettings) };
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to save";
return { success: false, error: `Failed to save: ${message.slice(0, 200)}` };
+20 -2
View File
@@ -48,12 +48,30 @@ interface ClientAction {
async function listClientActions(): Promise<ClientAction[]> {
await getSession(); // Wire real server actions here as they land:
await getSession();
// Wire real server actions here as they land:
// { name: "markOrderReady", handler: markOrderReady },
// { name: "markOrderPickedUp", handler: markOrderPickedUp },
// { name: "updateStopStatus", handler: updateStopStatus },
// { name: "adjustProductStock", handler: adjustProductStock },
return [];
//
// Time Tracking (cycle 12) — replay handlers for offline clock
// events. The replay re-verifies the worker's PIN before invoking
// the field action, then the action writes with submitted_via
// distinguishable so payroll can audit.
const { replayOfflineClock } = await import("./time-tracking/offline-handlers");
return [
{
name: "timeTracking.clockIn",
handler: async (payload: unknown, _clientActionId: string) =>
replayOfflineClock(payload as never),
},
{
name: "timeTracking.clockOut",
handler: async (payload: unknown, _clientActionId: string) =>
replayOfflineClock(payload as never),
},
];
}
export async function dispatchClientAction(
+324
View File
@@ -0,0 +1,324 @@
"use server";
/**
* Smartsheet Workspace — `/admin/water-log/settings` Smartsheet hub.
*
* Cycle 7. Replaces the two independent per-feature token columns
* (water + time tracking) with one brand-level workbook connection.
*
* Permission model:
* - Mutations require `can_manage_settings` (or platform_admin).
* The hub card is a brand-level integration setting, not a
* water-log concern, and shouldn't be locked behind
* `can_manage_water_log`.
* - Reads are open to any authenticated admin.
*
* Token handling:
* - The plaintext token NEVER crosses the server-action boundary.
* - Save actions accept a token via `input.token` only when the user
* types a new one. An empty token means "keep the saved one".
* - Read actions return `maskedToken` ("••••abcd") and never the
* plaintext.
*/
import { eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import { smartsheetWorkspace } from "@/db/schema/smartsheet-workspace";
import { decryptToken, encryptToken, maskToken } from "@/lib/crypto";
import { getSheetMeta, SmartsheetApiError } from "@/lib/smartsheet";
import { getAdminUser } from "@/lib/admin-permissions";
// ── Public types ───────────────────────────────────────────────────────────
export type SmartsheetWorkspaceResponse = {
/** True iff a workspace row exists for this brand. */
configured: boolean;
/** Default sheet ID (the "home" tab). Null = use the first feature sheet. */
defaultSheetId: string | null;
/** Master switch — pauses all per-feature syncs without losing config. */
connectionEnabled: boolean;
/** Masked preview of the saved token ("••••abcd") or null. */
maskedToken: string | null;
/** True iff a token is saved and decrypts cleanly. */
hasToken: boolean;
/** Last successful test-connection timestamp. */
lastTestAt: string | null;
/** Most recent test-connection error (sanitized). */
lastTestError: string | null;
updatedAt: string | null;
};
export type SaveSmartsheetWorkspaceInput = {
/** Plaintext API token. Empty string = keep the saved token. */
token: string;
defaultSheetId: string | null;
connectionEnabled: boolean;
};
export type TestSmartsheetWorkspaceResult =
| {
success: true;
/** Name of the sheet the connection was tested against. */
sheetName: string;
/** Number of columns in the sheet header. */
columnCount: number;
columns: { id: string; title: string; type: string }[];
}
| { success: false; error: string };
// ── Auth ───────────────────────────────────────────────────────────────────
async function requireManageSettings(): Promise<
{ ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> } | { ok: false; error: string }
> {
const adminUser = await getAdminUser();
if (!adminUser) return { ok: false, error: "Not authenticated" };
if (
!adminUser.can_manage_settings &&
adminUser.role !== "platform_admin"
) {
return { ok: false, error: "Not authorized" };
}
return { ok: true, adminUser };
}
// ── Read ───────────────────────────────────────────────────────────────────
export async function getSmartsheetWorkspace(
brandId: string,
): Promise<SmartsheetWorkspaceResponse> {
const auth = await requireManageSettings();
if (!auth.ok) {
return {
configured: false,
defaultSheetId: null,
connectionEnabled: true,
maskedToken: null,
hasToken: false,
lastTestAt: null,
lastTestError: null,
updatedAt: null,
};
}
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(smartsheetWorkspace)
.where(eq(smartsheetWorkspace.brandId, brandId))
.limit(1);
const ws = rows[0];
if (!ws) {
return {
configured: false,
defaultSheetId: null,
connectionEnabled: true,
maskedToken: null,
hasToken: false,
lastTestAt: null,
lastTestError: null,
updatedAt: null,
};
}
let masked: string | null = "••••";
let hasToken = true;
try {
const plain = decryptToken({
cipher: ws.encryptedApiToken,
iv: ws.tokenIv,
authTag: ws.tokenAuthTag,
});
masked = maskToken(plain);
} catch {
hasToken = false;
masked = null;
}
return {
configured: true,
defaultSheetId: ws.defaultSheetId,
connectionEnabled: ws.connectionEnabled,
maskedToken: masked,
hasToken,
lastTestAt: ws.lastTestAt?.toISOString() ?? null,
lastTestError: ws.lastTestError,
updatedAt: ws.updatedAt.toISOString(),
};
});
}
// ── Write: save (upsert) ───────────────────────────────────────────────────
export async function saveSmartsheetWorkspace(
brandId: string,
input: SaveSmartsheetWorkspaceInput,
): Promise<
| { success: true; workspace: SmartsheetWorkspaceResponse }
| { success: false; error: string }
> {
const auth = await requireManageSettings();
if (!auth.ok) return { success: false, error: auth.error };
const tokenTrimmed = input.token?.trim() ?? "";
return withBrand(brandId, async (db) => {
const existingRows = await db
.select()
.from(smartsheetWorkspace)
.where(eq(smartsheetWorkspace.brandId, brandId))
.limit(1);
const existing = existingRows[0];
// First-time setup requires a token; subsequent saves can omit it
// to keep the saved one.
if (!existing && tokenTrimmed.length === 0) {
return {
success: false,
error: "API token is required for first-time setup",
};
}
const updateValues: Partial<typeof smartsheetWorkspace.$inferInsert> = {
defaultSheetId: input.defaultSheetId,
connectionEnabled: input.connectionEnabled,
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
};
if (tokenTrimmed.length > 0) {
const enc = encryptToken(tokenTrimmed);
updateValues.encryptedApiToken = enc.cipher;
updateValues.tokenIv = enc.iv;
updateValues.tokenAuthTag = enc.authTag;
}
if (existing) {
await db
.update(smartsheetWorkspace)
.set(updateValues)
.where(eq(smartsheetWorkspace.brandId, brandId));
} else {
await db.insert(smartsheetWorkspace).values({
brandId,
encryptedApiToken: updateValues.encryptedApiToken ?? Buffer.from([]),
tokenIv: updateValues.tokenIv ?? Buffer.from([]),
tokenAuthTag: updateValues.tokenAuthTag ?? Buffer.from([]),
defaultSheetId: input.defaultSheetId,
connectionEnabled: input.connectionEnabled,
createdBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
});
}
return {
success: true,
workspace: await getSmartsheetWorkspace(brandId),
};
});
}
// ── Test connection ────────────────────────────────────────────────────────
export async function testSmartsheetWorkspaceConnection(
brandId: string,
sheetIdInput: string,
tokenInput: string,
): Promise<TestSmartsheetWorkspaceResult> {
const auth = await requireManageSettings();
if (!auth.ok) return { success: false, error: auth.error };
if (!sheetIdInput) {
return { success: false, error: "Sheet ID or URL is required" };
}
// Resolve token: explicit > saved > error.
let token = tokenInput?.trim() ?? "";
let lastTestError: string | null = null;
if (!token) {
const saved = await withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(smartsheetWorkspace)
.where(eq(smartsheetWorkspace.brandId, brandId))
.limit(1);
return rows[0] ?? null;
});
if (!saved) {
return { success: false, error: "No token saved yet — paste one above to test." };
}
try {
token = decryptToken({
cipher: saved.encryptedApiToken,
iv: saved.tokenIv,
authTag: saved.tokenAuthTag,
});
} catch {
return { success: false, error: "Saved token cannot be decrypted (key was rotated). Paste a fresh token to test." };
}
}
try {
// We re-parse via `getSheetMeta` which calls `extractSheetId`
// internally. Surface any share-link-slug error directly.
const meta = await getSheetMeta(sheetIdInput, token);
lastTestError = null;
await withBrand(brandId, async (db) => {
await db
.update(smartsheetWorkspace)
.set({ lastTestAt: new Date(), lastTestError: null })
.where(eq(smartsheetWorkspace.brandId, brandId));
});
return {
success: true,
sheetName: meta.name,
columnCount: meta.columns.length,
columns: meta.columns.map((c) => ({
id: c.id,
title: c.title,
type: c.type,
})),
};
} catch (err) {
lastTestError = err instanceof Error ? err.message : String(err);
// Sanitize: strip any token echoes.
const sanitized = lastTestError.split(token).join("[redacted-token]");
await withBrand(brandId, async (db) => {
await db
.update(smartsheetWorkspace)
.set({ lastTestAt: new Date(), lastTestError: sanitized })
.where(eq(smartsheetWorkspace.brandId, brandId));
});
if (err instanceof SmartsheetApiError) {
if (err.status === 401) {
return { success: false, error: "Token rejected by Smartsheet (401 — invalid or expired)" };
}
if (err.status === 403) {
return { success: false, error: "Forbidden (403) — token doesn't have access to this sheet" };
}
if (err.status === 404) {
return { success: false, error: "Sheet not found (404) — check the ID/URL. If you pasted a share URL with a slug, try the numeric sheet ID instead." };
}
return { success: false, error: `Smartsheet ${err.status}: ${err.body.slice(0, 200)}` };
}
return { success: false, error: sanitized };
}
}
// ── Disable (preserves token, turns off connection) ────────────────────────
export async function disableSmartsheetWorkspace(
brandId: string,
): Promise<{ success: true } | { success: false; error: string }> {
const auth = await requireManageSettings();
if (!auth.ok) return { success: false, error: auth.error };
return withBrand(brandId, async (db) => {
await db
.update(smartsheetWorkspace)
.set({ connectionEnabled: false })
.where(eq(smartsheetWorkspace.brandId, brandId));
return { success: true };
});
}
// ── Resolve token helper for sync services ────────────────────────────────
//
// `resolveWorkspaceToken` lives in `src/services/workspace-token.ts`
// (server-only, NOT a server action). Per-feature action files import
// it from there directly — re-exporting through a "use server" file
// would re-expose the plaintext-token helper as a callable RPC.
+378
View File
@@ -0,0 +1,378 @@
/**
* Time Tracking — Manual time entry server actions.
*
* Cycle 12 (Phase 2): admins can add/edit/delete manual entries on a
* timesheet, but only when the timesheet is in `draft` / `rejected` /
* `unlocked` status. Locked (`approved`) timesheets must be unlocked
* first via `unlockTimesheet()`.
*
* Manual entries:
* - Carry `entry_kind = 'manual'` + a mandatory `manual_reason`
* - Bypass the partial-unique open-clock-in constraint
* - Recompute the parent timesheet's cached totals on every write
*
* All actions write to `time_tracking_audit_log` in the same transaction.
*/
"use server";
import { z } from "zod";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTx } from "@/lib/db";
import { logTimeTrackingEventInTx } from "@/lib/time-tracking-audit";
// ── Schemas ────────────────────────────────────────────────────────────────
const GPS_SCHEMA = z
.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
accuracy_m: z.number().min(0).max(10000).optional(),
})
.optional();
const CreateSchema = z.object({
brandId: z.string().uuid(),
workerId: z.string().uuid(),
taskName: z.string().min(1).max(120),
clockIn: z.string().datetime(),
clockOut: z.string().datetime().optional().nullable(),
lunchBreakMinutes: z.number().int().min(0).max(720).default(0),
notes: z.string().max(2000).optional(),
manualReason: z.string().min(3).max(500),
gps: GPS_SCHEMA,
});
export async function addManualTimeEntry(
input: z.infer<typeof CreateSchema>,
): Promise<{ success: boolean; entry_id?: string; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const parsed = CreateSchema.safeParse(input);
if (!parsed.success) {
return {
success: false,
error: parsed.error.issues[0]?.message ?? "Invalid input",
};
}
const data = parsed.data;
return withTx(async (client) => {
// Verify worker belongs to the brand
const fw = await client.query<{ brand_id: string }>(
`SELECT brand_id FROM field_workers WHERE id = $1 LIMIT 1`,
[data.workerId],
);
if (fw.rows.length === 0 || fw.rows[0].brand_id !== data.brandId) {
return { success: false, error: "Worker not found in this brand" };
}
// Compute the pay-period [start, end] that contains clockIn
const period = computePeriod(data.clockIn);
// Lock the timesheet row (FOR UPDATE) so we can safely decide whether
// to insert or update the parent timesheet.
const tsRes = await client.query<{
id: string;
locked_at: Date | null;
}>(
`SELECT id, locked_at FROM time_tracking_timesheets
WHERE field_worker_id = $1
AND period_start = $2::date
AND period_end = $3::date
FOR UPDATE`,
[data.workerId, period.start, period.end],
);
let timesheetId = tsRes.rows[0]?.id;
if (tsRes.rows[0]?.locked_at) {
return {
success: false,
error: "Timesheet is locked — ask an admin to unlock it first",
};
}
if (!timesheetId) {
const created = await client.query<{ id: string }>(
`INSERT INTO time_tracking_timesheets
(brand_id, field_worker_id, period_start, period_end)
VALUES ($1, $2, $3::date, $4::date)
RETURNING id`,
[data.brandId, data.workerId, period.start, period.end],
);
timesheetId = created.rows[0]?.id;
if (!timesheetId) return { success: false, error: "Failed to create timesheet" };
}
const gpsVerified = !!data.gps && (data.gps.accuracy_m ?? 999) <= 100;
const inserted = await client.query<{ id: string }>(
`INSERT INTO time_tracking_logs
(brand_id, field_worker_id, task_name,
clock_in, clock_out, lunch_break_minutes, notes,
submitted_via, entry_kind, manual_reason,
clock_in_lat, clock_in_lng, clock_in_accuracy_m,
gps_verified,
edited_at, edited_by, edit_reason)
VALUES ($1, $2, $3,
$4, $5, $6, $7,
'manual', 'manual', $8,
$9, $10, $11,
$12,
NOW(), $13, $14)
RETURNING id`,
[
data.brandId,
data.workerId,
data.taskName,
new Date(data.clockIn),
data.clockOut ? new Date(data.clockOut) : null,
data.lunchBreakMinutes,
data.notes ?? null,
data.manualReason,
data.gps?.lat ?? null,
data.gps?.lng ?? null,
data.gps?.accuracy_m ?? null,
gpsVerified,
adminUser.id,
data.manualReason,
],
);
const entryId = inserted.rows[0]?.id;
if (!entryId) return { success: false, error: "Insert failed" };
await logTimeTrackingEventInTx(client, {
brandId: data.brandId,
actorId: adminUser.id,
actorLabel: adminUser.display_name ?? adminUser.email ?? "admin",
action: "entry.create",
entityType: "log",
entityId: entryId,
details: {
kind: "manual",
worker_id: data.workerId,
clock_in: data.clockIn,
clock_out: data.clockOut,
reason: data.manualReason,
},
});
await client.query("SELECT recompute_timesheet_totals($1)", [timesheetId]);
return { success: true, entry_id: entryId };
});
}
const UpdateSchema = z.object({
entryId: z.string().uuid(),
taskName: z.string().min(1).max(120).optional(),
clockIn: z.string().datetime().optional(),
clockOut: z.string().datetime().nullable().optional(),
lunchBreakMinutes: z.number().int().min(0).max(720).optional(),
notes: z.string().max(2000).nullable().optional(),
manualReason: z.string().min(3).max(500),
gps: GPS_SCHEMA,
});
export async function editTimeEntry(
input: z.infer<typeof UpdateSchema>,
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const parsed = UpdateSchema.safeParse(input);
if (!parsed.success) {
return {
success: false,
error: parsed.error.issues[0]?.message ?? "Invalid input",
};
}
const data = parsed.data;
return withTx(async (client) => {
const cur = await client.query<{
brand_id: string;
field_worker_id: string;
locked_at: Date | null;
}>(
`SELECT t.brand_id, t.field_worker_id, ts.locked_at
FROM time_tracking_logs t
LEFT JOIN time_tracking_timesheets ts
ON ts.field_worker_id = t.field_worker_id
AND ts.period_start <= t.clock_in::date
AND ts.period_end >= t.clock_in::date
WHERE t.id = $1
FOR UPDATE OF t`,
[data.entryId],
);
if (cur.rows.length === 0) return { success: false, error: "Not found" };
if (cur.rows[0].locked_at)
return {
success: false,
error: "Timesheet is locked — ask an admin to unlock it first",
};
const gpsVerified =
data.gps != null ? (data.gps.accuracy_m ?? 999) <= 100 : undefined;
// Build a dynamic UPDATE so we only touch columns that the caller
// actually changed. Cleanly handles the clockOut=null sentinel for
// "clear the clock-out".
const sets: string[] = [
"edited_at = NOW()",
"edited_by = $1",
"edit_reason = $2",
];
const params: unknown[] = [adminUser.id, data.manualReason];
let i = params.length;
if (data.taskName !== undefined) {
i += 1;
sets.push(`task_name = $${i}`);
params.push(data.taskName);
}
if (data.clockIn !== undefined) {
i += 1;
sets.push(`clock_in = $${i}`);
params.push(new Date(data.clockIn));
}
if (data.clockOut !== undefined) {
i += 1;
sets.push(`clock_out = $${i}::timestamptz`);
params.push(data.clockOut ? new Date(data.clockOut) : null);
}
if (data.lunchBreakMinutes !== undefined) {
i += 1;
sets.push(`lunch_break_minutes = $${i}`);
params.push(data.lunchBreakMinutes);
}
if (data.notes !== undefined) {
i += 1;
sets.push(`notes = $${i}`);
params.push(data.notes);
}
if (data.gps !== undefined) {
i += 1;
sets.push(`clock_in_lat = $${i}`);
params.push(data.gps.lat);
i += 1;
sets.push(`clock_in_lng = $${i}`);
params.push(data.gps.lng);
i += 1;
sets.push(`clock_in_accuracy_m = $${i}`);
params.push(data.gps.accuracy_m ?? null);
if (gpsVerified !== undefined) {
i += 1;
sets.push(`gps_verified = $${i}`);
params.push(gpsVerified);
}
}
i += 1;
const entryIdParam = i;
params.push(data.entryId);
await client.query(
`UPDATE time_tracking_logs SET ${sets.join(", ")} WHERE id = $${entryIdParam}`,
params,
);
await logTimeTrackingEventInTx(client, {
brandId: cur.rows[0].brand_id,
actorId: adminUser.id,
actorLabel: adminUser.display_name ?? adminUser.email ?? "admin",
action: "entry.edit",
entityType: "log",
entityId: data.entryId,
details: {
worker_id: cur.rows[0].field_worker_id,
reason: data.manualReason,
},
});
// Recompute the owning timesheet
await client.query(
`SELECT recompute_timesheet_totals(ts.id)
FROM time_tracking_timesheets ts
WHERE ts.field_worker_id = $1
LIMIT 1`,
[cur.rows[0].field_worker_id],
);
return { success: true };
});
}
export async function deleteTimeEntry(
entryId: string,
reason: string,
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!reason || reason.length < 3)
return { success: false, error: "Reason required" };
return withTx(async (client) => {
const cur = await client.query<{
brand_id: string;
field_worker_id: string;
locked_at: Date | null;
}>(
`SELECT t.brand_id, t.field_worker_id, ts.locked_at
FROM time_tracking_logs t
LEFT JOIN time_tracking_timesheets ts
ON ts.field_worker_id = t.field_worker_id
AND ts.period_start <= t.clock_in::date
AND ts.period_end >= t.clock_in::date
WHERE t.id = $1
FOR UPDATE OF t`,
[entryId],
);
if (cur.rows.length === 0) return { success: false, error: "Not found" };
if (cur.rows[0].locked_at)
return {
success: false,
error: "Timesheet is locked — ask an admin to unlock it first",
};
await client.query(`DELETE FROM time_tracking_logs WHERE id = $1`, [entryId]);
await logTimeTrackingEventInTx(client, {
brandId: cur.rows[0].brand_id,
actorId: adminUser.id,
actorLabel: adminUser.display_name ?? adminUser.email ?? "admin",
action: "entry.delete",
entityType: "log",
entityId: entryId,
details: { worker_id: cur.rows[0].field_worker_id, reason },
});
await client.query(
`SELECT recompute_timesheet_totals(ts.id)
FROM time_tracking_timesheets ts
WHERE ts.field_worker_id = $1
LIMIT 1`,
[cur.rows[0].field_worker_id],
);
return { success: true };
});
}
// ── helpers ────────────────────────────────────────────────────────────────
function computePeriod(isoDate: string): { start: string; end: string } {
// Default weekly period (Sunday → Saturday). The brand admin can
// override this via time_tracking_settings.pay_period_length_days +
// pay_period_start_day; the SQL recompute_timesheet_totals RPC
// understands any non-zero length.
const d = new Date(isoDate);
const start = new Date(d);
start.setHours(0, 0, 0, 0);
start.setDate(d.getDate() - d.getDay());
const end = new Date(start);
end.setDate(start.getDate() + 6);
return {
start: start.toISOString().slice(0, 10),
end: end.toISOString().slice(0, 10),
};
}
+326
View File
@@ -0,0 +1,326 @@
/**
* Time Tracking — Payroll export (CSV + PDF).
*
* Two endpoints, both keyed on a `timesheetId`:
* - CSV: stable, machine-friendly. Columns match the format the Tuxedo
* payroll service already ingests for the water log so we can re-use
* the same import tool on the payroll side.
* - PDF: signature-ready. Includes worker, period, totals, every
* entry with start/end, manual entries highlighted, supervisor
* approval signature line, and an audit footer.
*
* Both lock behind the same authz: caller must be admin or the timesheet's
* worker (or assigned supervisor). Locked timesheets export; the
* approval status is what makes them payroll-ready.
*/
"use server";
import { eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import { getAdminUser } from "@/lib/admin-permissions";
import {
timeTrackingLogs,
timeTrackingTimesheets,
} from "@/db/schema/time-tracking";
import { fieldWorkers } from "@/db/schema/field-workers";
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
import Papa from "papaparse";
// ── CSV ─────────────────────────────────────────────────────────────────────
export type ExportRow = {
date: string;
worker_name: string;
worker_role: string;
task: string;
entry_kind: "clock" | "manual";
clock_in: string;
clock_out: string;
break_minutes: number;
total_minutes: number;
gps_verified: boolean;
manual_reason: string | null;
notes: string | null;
};
export type ExportPayload = {
brand_name: string;
worker_name: string;
worker_role: string;
period_start: string;
period_end: string;
status: string;
reviewer: string | null;
approved_at: string | null;
rows: ExportRow[];
totals: {
entry_count: number;
total_minutes: number;
regular_minutes: number;
overtime_minutes: number;
break_minutes: number;
};
};
export async function exportTimesheetCsv(
timesheetId: string,
): Promise<{ success: boolean; csv?: string; filename?: string; error?: string }> {
const payload = await loadExportPayload(timesheetId);
if (!payload) return { success: false, error: "Not authorized or not found" };
const csv = Papa.unparse(
payload.rows.map((r) => ({
Date: r.date,
Worker: r.worker_name,
Role: r.worker_role,
Task: r.task,
Kind: r.entry_kind,
"Clock In": r.clock_in,
"Clock Out": r.clock_out,
"Break (min)": r.break_minutes,
"Total (min)": r.total_minutes,
"GPS Verified": r.gps_verified ? "yes" : "no",
"Manual Reason": r.manual_reason ?? "",
Notes: r.notes ?? "",
})),
);
const header =
`# ${payload.brand_name}\n` +
`# Worker: ${payload.worker_name} (${payload.worker_role})\n` +
`# Period: ${payload.period_start}${payload.period_end}\n` +
`# Status: ${payload.status}\n` +
`# Approver: ${payload.reviewer ?? "(none)"}\n` +
`# Approved at: ${payload.approved_at ?? "(not approved)"}\n` +
`# Totals: ${payload.totals.total_minutes} min total · ` +
`${payload.totals.regular_minutes} reg · ` +
`${payload.totals.overtime_minutes} OT · ` +
`${payload.totals.break_minutes} break\n` +
`#\n`;
return {
success: true,
csv: header + csv + "\n",
filename: `timesheet_${payload.worker_name.replace(/\s+/g, "_")}_${payload.period_start}_${payload.period_end}.csv`,
};
}
// ── PDF ─────────────────────────────────────────────────────────────────────
export async function exportTimesheetPdf(
timesheetId: string,
): Promise<{ success: boolean; pdf?: Uint8Array; filename?: string; error?: string }> {
const payload = await loadExportPayload(timesheetId);
if (!payload) return { success: false, error: "Not authorized or not found" };
const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold);
const pageSize: [number, number] = [612, 792]; // US Letter
const margin = 40;
const lineHeight = 14;
let page = pdf.addPage(pageSize);
let cursor = pageSize[1] - margin;
function ensureRoom(lines = 1) {
if (cursor - lines * lineHeight < margin) {
page = pdf.addPage(pageSize);
cursor = pageSize[1] - margin;
}
}
function drawText(text: string, opts: { bold?: boolean; size?: number; color?: [number, number, number] } = {}) {
const size = opts.size ?? 10;
const f = opts.bold ? fontBold : font;
page.drawText(text, {
x: margin,
y: cursor - size,
size,
font: f,
color: opts.color ? rgb(...opts.color) : rgb(0, 0, 0),
});
cursor -= size + 4;
}
// Header
drawText(payload.brand_name, { bold: true, size: 16 });
drawText(`Timesheet — ${payload.worker_name} (${payload.worker_role})`, {
size: 12,
});
drawText(
`Period: ${payload.period_start}${payload.period_end} · Status: ${payload.status}`,
);
if (payload.reviewer) {
drawText(`Approver: ${payload.reviewer} · Approved: ${payload.approved_at}`);
}
cursor -= 4;
drawText("Totals", { bold: true });
drawText(
`Entries: ${payload.totals.entry_count} · ` +
`Total: ${formatHM(payload.totals.total_minutes)} · ` +
`Regular: ${formatHM(payload.totals.regular_minutes)} · ` +
`OT: ${formatHM(payload.totals.overtime_minutes)} · ` +
`Break: ${formatHM(payload.totals.break_minutes)}`,
);
cursor -= 8;
// Entries
drawText("Entries", { bold: true });
for (const r of payload.rows) {
ensureRoom(3);
const tag = r.entry_kind === "manual" ? "[manual] " : "";
const gps = r.gps_verified ? "📍" : "—";
drawText(
`${r.date} ${tag}${r.task} ${gps} ${r.clock_in}${r.clock_out} ` +
`(${formatHM(r.total_minutes)})`,
{ size: 9 },
);
if (r.notes) drawText(` notes: ${r.notes}`, { size: 8, color: [0.4, 0.4, 0.4] });
if (r.manual_reason)
drawText(` reason: ${r.manual_reason}`, {
size: 8,
color: [0.4, 0.4, 0.4],
});
}
cursor -= 24;
ensureRoom(2);
drawText("Signatures", { bold: true });
drawText("Employee: ____________________________________ Date: ______________");
cursor -= 18;
drawText("Supervisor: __________________________________ Date: ______________");
cursor -= 28;
ensureRoom(2);
drawText(
`Generated ${new Date().toISOString()} · audit id: ${timesheetId.slice(0, 8)}`,
{ size: 7, color: [0.5, 0.5, 0.5] },
);
const pdfBytes = await pdf.save();
return {
success: true,
pdf: pdfBytes,
filename: `timesheet_${payload.worker_name.replace(/\s+/g, "_")}_${payload.period_start}_${payload.period_end}.pdf`,
};
}
// ── Loader ──────────────────────────────────────────────────────────────────
async function loadExportPayload(
timesheetId: string,
): Promise<ExportPayload | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
return withBrand(adminUser.brand_id ?? "", async (db) => {
const [ts] = await db
.select({
id: timeTrackingTimesheets.id,
brand_id: timeTrackingTimesheets.brandId,
field_worker_id: timeTrackingTimesheets.fieldWorkerId,
period_start: timeTrackingTimesheets.periodStart,
period_end: timeTrackingTimesheets.periodEnd,
status: timeTrackingTimesheets.status,
total_minutes: timeTrackingTimesheets.totalMinutes,
regular_minutes: timeTrackingTimesheets.regularMinutes,
overtime_minutes: timeTrackingTimesheets.overtimeMinutes,
break_minutes: timeTrackingTimesheets.breakMinutes,
entry_count: timeTrackingTimesheets.entryCount,
approved_at: timeTrackingTimesheets.reviewedAt,
reviewer_name: fieldWorkers.name,
})
.from(timeTrackingTimesheets)
.leftJoin(
fieldWorkers,
eq(fieldWorkers.id, timeTrackingTimesheets.fieldWorkerId),
)
.where(eq(timeTrackingTimesheets.id, timesheetId))
.limit(1);
if (!ts) return null;
if (
adminUser.role !== "platform_admin" &&
adminUser.brand_id !== ts.brand_id
)
return null;
const [worker] = await db
.select()
.from(fieldWorkers)
.where(eq(fieldWorkers.id, ts.field_worker_id))
.limit(1);
if (!worker) return null;
const entries = await db
.select()
.from(timeTrackingLogs)
.where(eq(timeTrackingLogs.fieldWorkerId, ts.field_worker_id))
.orderBy(timeTrackingLogs.clockIn);
const rows: ExportRow[] = entries
.filter(
(e) =>
e.clockIn >= new Date(ts.period_start) &&
e.clockIn <= endOfDay(ts.period_end),
)
.map((e) => {
const totalMin =
e.clockOut
? Math.max(
0,
Math.round(
(e.clockOut.getTime() - e.clockIn.getTime()) / 60000,
) - e.lunchBreakMinutes,
)
: 0;
return {
date: e.clockIn.toISOString().slice(0, 10),
worker_name: worker.name,
worker_role: worker.role,
task: e.taskName,
entry_kind: e.entryKind as "clock" | "manual",
clock_in: e.clockIn.toISOString().slice(11, 19),
clock_out: e.clockOut ? e.clockOut.toISOString().slice(11, 19) : "",
break_minutes: e.lunchBreakMinutes,
total_minutes: totalMin,
gps_verified: e.gpsVerified,
manual_reason: e.manualReason,
notes: e.notes,
};
});
return {
brand_name: adminUser.brand_slug ?? "Brand",
worker_name: worker.name,
worker_role: worker.role,
period_start: ts.period_start,
period_end: ts.period_end,
status: ts.status,
reviewer: ts.reviewer_name,
approved_at: ts.approved_at ? ts.approved_at.toISOString() : null,
rows,
totals: {
entry_count: ts.entry_count,
total_minutes: ts.total_minutes,
regular_minutes: ts.regular_minutes,
overtime_minutes: ts.overtime_minutes,
break_minutes: ts.break_minutes,
},
};
});
}
function endOfDay(iso: string): Date {
const d = new Date(iso);
d.setHours(23, 59, 59, 999);
return d;
}
function formatHM(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h}h ${m}m`;
}
+746 -75
View File
@@ -1,20 +1,37 @@
/**
* Time Tracking — field (PIN-based) actions.
*
* Re-implemented in Cycle 2 of the water-log/time-tracking refactor.
*
* Sessions are cookie-only (no DB session row). The cookie carries
* seven pipe-delimited fields (`worker_id|session_id|expires_at|
* name|role|lang|brand_id`) — but only the first three are read from
* the cookie. name/role/lang/brand_id are ALWAYS re-resolved from the
* DB on every `getTimeTrackingSession()` call so a tampered or stale
* cookie can't impersonate a different brand. Expiry is 12 hours; the
* field app re-prompts for PIN when the cookie is gone.
*
* Clock-in is protected by a partial unique index on
* `time_tracking_logs (worker_id) WHERE clock_out IS NULL` (migration
* 0094) so concurrent / retried requests can't open two shifts.
*
* PIN verification uses scrypt (from `@/lib/water-log-pin` — shared
* with the water-log module).
*/
"use server";
import { cookies } from "next/headers";
import { getSession } from "@/lib/auth";
// TODO(migration): the time-tracking feature was built on Supabase RPCs
// (verify_time_tracking_pin, clock_in_worker, clock_out_worker,
// get_open_clock_in, get_time_tracking_tasks,
// get_worker_pay_period_hours, get_time_tracking_settings,
// get_water_user_by_id, get_water_admin_session, submit_water_entry,
// trigger_water_alert) plus tables that don't exist in the SaaS rebuild
// schema (`time_workers`, `time_tasks`, `time_logs`, `water_*`). The
// functions below are stubs that preserve the original signature so
// the field UI degrades gracefully (no PIN login, no entry submit) —
// exactly the same pattern as `actions/route-trace/lots.ts`. To bring
// time tracking back, add the tables to `db/schema/` and re-implement
// these functions against Drizzle.
import { eq, and, isNull, desc, sql, gte, lte } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { withBrand, withPlatformAdmin } from "@/db/client";
import {
timeTrackingTasks,
timeTrackingLogs,
timeTrackingSmartsheetSyncQueue,
} from "@/db/schema/time-tracking";
import { fieldWorkers } from "@/db/schema/field-workers";
import { verifyPin } from "@/lib/water-log-pin";
import { getWorkerPeriodTotals } from "./index";
export type TimeTrackingSession = {
worker_id: string;
@@ -26,75 +43,372 @@ export type TimeTrackingSession = {
expires_at: string;
};
// ── Cookie helpers ─────────────────────────────────────────────────────────────
// ── Cookie helpers ─────────────────────────────────────────────────────────
const SESSION_COOKIE = "time_tracking_session";
const COOKIE_MAX_AGE = 12 * 60 * 60; // 12 hours in seconds
// 7 days. The cookie is httpOnly + secure (in prod) + sameSite=lax so
// XSS / cross-site theft are already mitigated; the remaining risk is a
// shared/unlocked device on the field. Workers notice forced re-auth
// more than they notice a longer session, so we keep them signed in for
// a full week. Re-PIN only when the cookie expires or is logged out.
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60;
function sessionCookie(session: TimeTrackingSession) {
return `${session.worker_id}|${session.session_id}|${session.expires_at}`;
function sessionCookieValue(session: TimeTrackingSession): string {
return [
session.worker_id,
session.session_id,
session.expires_at,
session.name,
session.role,
session.lang,
session.brand_id,
].join("|");
}
function parseSessionCookie(cookie: string): TimeTrackingSession | null {
// The cookie carries worker_id + a session_id; everything else
// (name, role, lang, brand_id) is re-resolved from the DB on every read
// so a tampered or stale cookie can't impersonate a different brand.
function parseSessionCookie(cookie: string): {
worker_id: string;
session_id: string;
expires_at: string;
} | null {
const parts = cookie.split("|");
if (parts.length !== 3) return null;
if (parts.length !== 7) return null;
const [worker_id, session_id, expires_at] = parts;
if (!worker_id || !session_id || !expires_at) return null;
if (new Date(expires_at) < new Date()) return null;
return {
worker_id,
name: "",
role: "worker",
lang: "en",
session_id,
brand_id: "",
expires_at,
};
return { worker_id, session_id, expires_at };
}
// ── Verify PIN ─────────────────────────────────────────────────────────────────
// ── Verify PIN ─────────────────────────────────────────────────────────────
export async function verifyTimeTrackingPin(
_brandId: string,
_pin: string
): Promise<{ success: boolean; session?: TimeTrackingSession; error?: string }> {
brandId: string,
pin: string,
): Promise<{
success: boolean;
session?: TimeTrackingSession;
error?: string;
}> {
if (!pin || pin.length < 4) {
return { success: false, error: "Enter your 4-digit PIN." };
}
await getSession(); // RPC no longer exists; surface a friendly error so the field UI can
// disable the PIN pad.
return { success: false, error: "Time tracking is not configured" };
// Pull every active worker for the brand and try each PIN hash. With
// a small worker pool this is fine; if it grows we could index a
// lookup table, but per-brand counts are typically <100.
// Cycle 10: workers now live in the unified `field_workers` table.
// We accept anyone with a clocking-in role: 'worker' (default),
// 'time_admin' (manages tasks + workers), 'irrigator' (legacy role
// for water workers, can also clock in/out — these are field crew
// who do both jobs), and 'driver' (cycle 12 driver/crew role).
//
// Excluded on purpose:
// - 'water_admin' — admin who manages headgates/workers, not a
// clocking-in field worker
// - 'supervisor' — cycle 12 approver role; doesn't clock in/out,
// only reviews and approves timesheets
//
// This also matters because MobileWaterApp (the /water unified
// entrypoint) calls verifyTimeTrackingPin best-effort right after
// verifyWaterPin — if the role filter here is too narrow, the
// worker enters their PIN once, the Time tab mounts, asks for the
// PIN again, and the second attempt fails the same way. Allowing
// the broader set keeps the unified PIN flow working for everyone
// who's registered as a field crew member.
const rows = await withBrand(brandId, async (db) => {
return db
.select()
.from(fieldWorkers)
.where(
and(
eq(fieldWorkers.brandId, brandId),
eq(fieldWorkers.active, true),
sql`${fieldWorkers.role} IN ('worker', 'time_admin', 'irrigator', 'driver')`,
),
);
});
let match: typeof fieldWorkers.$inferSelect | undefined;
for (const r of rows) {
if (verifyPin(pin, r.pinHash)) {
match = r;
break;
}
}
if (!match) return { success: false, error: "Invalid PIN" };
// Bump lastUsedAt + set cookie.
await withBrand(brandId, async (db) => {
await db
.update(fieldWorkers)
.set({ lastUsedAt: new Date() })
.where(eq(fieldWorkers.id, match!.id));
});
const expiresAt = new Date(Date.now() + COOKIE_MAX_AGE * 1000);
const session: TimeTrackingSession = {
worker_id: match.id,
name: match.name,
role: match.role,
lang: match.languagePreference,
session_id: randomUUID(),
expires_at: expiresAt.toISOString(),
brand_id: brandId,
};
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, sessionCookieValue(session), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: COOKIE_MAX_AGE,
path: "/",
});
return { success: true, session };
}
// ── Clock In ───────────────────────────────────────────────────────────────────
// ── Clock In ───────────────────────────────────────────────────────────────
export type GpsSample = {
lat: number;
lng: number;
/** Reported accuracy in meters. ≤ 100m = `gps_verified = true`. */
accuracy_m?: number;
};
export async function clockInWorker(
_brandId: string,
_taskId?: string,
_taskName = "General Labor"
): Promise<{ success: boolean; log_id?: string; clock_in?: string; error?: string }> {
await getSession();
brandId: string,
taskId?: string,
taskName = "General Labor",
gps?: GpsSample | null,
): Promise<{
success: boolean;
log_id?: string;
clock_in?: string;
gps_verified?: boolean;
error?: string;
}> {
const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" };
return { success: false, error: "Time tracking is not configured" };
return withBrand(brandId, async (db) => {
// The DB enforces one-open-clock-in-per-worker via the
// `time_tracking_logs_one_open_per_worker` partial unique index
// (migration 0094). We also surface a friendly error in app code.
const open = await db
.select({ id: timeTrackingLogs.id })
.from(timeTrackingLogs)
.where(
and(
eq(timeTrackingLogs.brandId, brandId),
eq(timeTrackingLogs.fieldWorkerId, session.worker_id),
isNull(timeTrackingLogs.clockOut),
),
)
.limit(1);
if (open.length > 0) {
return { success: false, error: "Already clocked in" };
}
// If a taskId was supplied, fetch the task name (so the row stays
// accurate even if the task is renamed later). Fall back to the
// supplied taskName or "General Labor".
const resolvedTaskId = taskId ?? null;
let resolvedTaskName = taskName;
if (taskId) {
const [task] = await db
.select()
.from(timeTrackingTasks)
.where(eq(timeTrackingTasks.id, taskId))
.limit(1);
if (task) resolvedTaskName = task.name;
}
const now = new Date();
const gpsVerified =
gps != null && (gps.accuracy_m ?? 999) <= 100;
try {
const [row] = await db
.insert(timeTrackingLogs)
.values({
brandId,
fieldWorkerId: session.worker_id,
taskId: resolvedTaskId,
taskName: resolvedTaskName,
clockIn: now,
submittedVia: "field",
// 0098 — GPS + entry_kind
entryKind: "clock",
clockInLat: gps?.lat ?? null,
clockInLng: gps?.lng ?? null,
clockInAccuracyM: gps?.accuracy_m ?? null,
gpsVerified,
})
.returning({
id: timeTrackingLogs.id,
clockIn: timeTrackingLogs.clockIn,
});
if (!row) return { success: false, error: "Insert returned no row" };
return {
success: true,
log_id: row.id,
clock_in: row.clockIn.toISOString(),
gps_verified: gpsVerified,
};
} catch (err) {
// Race lost to a concurrent clock-in: the partial unique index
// rejected our row. Treat as "already clocked in" so the worker
// sees a consistent message instead of a 500.
if (isUniqueViolation(err)) {
return { success: false, error: "Already clocked in" };
}
throw err;
}
});
}
// ── Clock Out ──────────────────────────────────────────────────────────────────
function isUniqueViolation(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return /unique constraint|duplicate key/i.test(msg);
}
// Cycle 5 — best-effort Smartsheet queue enqueue. Lives in its own
// transaction (separate `withBrand` call) so it cannot poison the
// primary clock-out write when called from `clockOutWorker`.
export async function enqueueClockOutForSync(
brandId: string,
logId: string,
): Promise<void> {
await withBrand(brandId, async (db) => {
await db
.insert(timeTrackingSmartsheetSyncQueue)
.values({
brandId,
logId,
status: "pending",
})
.onConflictDoNothing({
target: [
timeTrackingSmartsheetSyncQueue.brandId,
timeTrackingSmartsheetSyncQueue.logId,
],
});
});
}
// ── Clock Out ──────────────────────────────────────────────────────────────
export async function clockOutWorker(
_lunchMinutes = 0,
_notes?: string
): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; error?: string }> {
await getSession();
lunchMinutes = 0,
notes?: string,
gps?: GpsSample | null,
): Promise<{
success: boolean;
log_id?: string;
clock_out?: string;
total_minutes?: number;
gps_verified?: boolean;
error?: string;
}> {
const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" };
return { success: false, error: "Time tracking is not configured" };
return withBrand(session.brand_id, async (db) => {
const open = await db
.select()
.from(timeTrackingLogs)
.where(
and(
eq(timeTrackingLogs.brandId, session.brand_id),
eq(timeTrackingLogs.fieldWorkerId, session.worker_id),
isNull(timeTrackingLogs.clockOut),
),
)
.orderBy(desc(timeTrackingLogs.clockIn))
.limit(1);
if (open.length === 0) {
return { success: false, error: "No open clock-in to close" };
}
const log = open[0];
const now = new Date();
const gpsVerified =
gps != null && (gps.accuracy_m ?? 999) <= 100;
await db
.update(timeTrackingLogs)
.set({
clockOut: now,
lunchBreakMinutes: Math.max(0, Math.round(lunchMinutes)),
notes: notes?.trim() || null,
// 0098 — GPS on clock-out
clockOutLat: gps?.lat ?? null,
clockOutLng: gps?.lng ?? null,
clockOutAccuracyM: gps?.accuracy_m ?? null,
// Preserve the existing `gps_verified` if clock-in already verified;
// upgrade only when clock-out GPS is fresh AND accurate.
gpsVerified:
log.gpsVerified || gpsVerified ? true : log.gpsVerified,
})
.where(eq(timeTrackingLogs.id, log.id));
// Cycle 5 — enqueue for Smartsheet sync in a SEPARATE transaction
// so a queue-insert failure (RLS denial, FK violation, transient
// backend error) can never poison the clock-out write above.
// Drizzle's `onConflictDoNothing` keeps retried clock-outs idempotent
// against the (brand_id, log_id) unique constraint from migration
// 0095.
void enqueueClockOutForSync(session.brand_id, log.id).catch(() => {
// Non-fatal: the admin UI surfaces persistent failures via the
// Recent Activity panel, and the cron drain retries.
});
const totalMinutes = Math.max(
0,
Math.round((now.getTime() - log.clockIn.getTime()) / 60000) -
Math.max(0, Math.round(lunchMinutes)),
);
// Recompute the owning timesheet totals (0098).
void recomputeOwningTimesheet(session.brand_id, log.id).catch(() => {});
return {
success: true,
log_id: log.id,
clock_out: now.toISOString(),
total_minutes: totalMinutes,
gps_verified: gpsVerified,
};
});
}
// ── Get Open Clock In ──────────────────────────────────────────────────────────
/**
* Fire-and-forget: recompute the parent timesheet totals for a log.
* Errors are swallowed — the cached totals can be off by one entry until
* the next mutation; this is preferable to surfacing a 500 on clock-out.
*/
async function recomputeOwningTimesheet(
brandId: string,
logId: string,
): Promise<void> {
const pool = (await import("@/lib/db")).getPool();
await pool.query(
`SELECT recompute_timesheet_totals(ts.id)
FROM time_tracking_timesheets ts
JOIN time_tracking_logs l ON l.field_worker_id = ts.field_worker_id
WHERE l.id = $1 AND ts.brand_id = $2
LIMIT 1`,
[logId, brandId],
);
}
export async function getOpenClockIn(
_brandId: string
): Promise<{
// ── Get Open Clock In ──────────────────────────────────────────────────────
export async function getOpenClockIn(brandId: string): Promise<{
success: boolean;
open?: boolean;
log_id?: string;
@@ -103,34 +417,83 @@ export async function getOpenClockIn(
elapsed_minutes?: number;
error?: string;
}> {
await getSession();
const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" };
return { success: true, open: false };
// Session is the source of truth for field workers — see
// `submitManualTimeLog` for the rationale. The Hub screen relies on
// this returning the worker's actual open shift regardless of which
// brand constant the page passes in.
const resolvedBrand = session.brand_id ?? brandId;
if (!resolvedBrand) return { success: false, error: "Missing brand context" };
return withBrand(resolvedBrand, async (db) => {
const [open] = await db
.select()
.from(timeTrackingLogs)
.where(
and(
eq(timeTrackingLogs.brandId, resolvedBrand),
eq(timeTrackingLogs.fieldWorkerId, session.worker_id),
isNull(timeTrackingLogs.clockOut),
),
)
.orderBy(desc(timeTrackingLogs.clockIn))
.limit(1);
if (!open) return { success: true, open: false };
const elapsed = Math.round((Date.now() - open.clockIn.getTime()) / 60000);
return {
success: true,
open: true,
log_id: open.id,
task_name: open.taskName,
clock_in: open.clockIn.toISOString(),
elapsed_minutes: elapsed,
};
});
}
// ── Logout ─────────────────────────────────────────────────────────────────────
// ── Logout ─────────────────────────────────────────────────────────────────
export async function logoutTimeTracking(): Promise<void> {
await getSession();
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
// ── Get Current Session ────────────────────────────────────────────────────────
// ── Get Current Session ────────────────────────────────────────────────────
export async function getTimeTrackingSession(): Promise<TimeTrackingSession | null> {
await getSession();
const cookieStore = await cookies();
const cookie = cookieStore.get(SESSION_COOKIE);
if (!cookie) return null;
return parseSessionCookie(cookie.value);
const parsed = parseSessionCookie(cookie.value);
if (!parsed) return null;
// Re-resolve name/role/lang/brand_id from the DB so a stale or
// tampered cookie can't impersonate a different brand. Workers is
// brand-scoped, so the session lookup has to read with platform
// admin scope (the cookie itself gates access).
return withPlatformAdmin(async (db) => {
const [worker] = await db
.select()
.from(fieldWorkers)
.where(eq(fieldWorkers.id, parsed.worker_id))
.limit(1);
if (!worker || !worker.active) return null;
return {
worker_id: worker.id,
name: worker.name,
role: worker.role,
lang: worker.languagePreference,
session_id: parsed.session_id,
expires_at: parsed.expires_at,
brand_id: worker.brandId,
};
});
}
// ── Get Tasks (for task picker) ─────────────────────────────────────────────────
// ── Tasks (field picker) ───────────────────────────────────────────────────
export type TimeTaskField = {
id: string;
@@ -142,14 +505,28 @@ export type TimeTaskField = {
};
export async function getTimeTrackingTasksField(
_brandId: string,
_activeOnly = true
brandId: string,
activeOnly = true,
): Promise<TimeTaskField[]> {
await getSession(); return [];
return withBrand(brandId, async (db) => {
const base = eq(timeTrackingTasks.brandId, brandId);
const rows = await db
.select()
.from(timeTrackingTasks)
.where(activeOnly ? and(base, eq(timeTrackingTasks.active, true)) : base)
.orderBy(timeTrackingTasks.sortOrder, timeTrackingTasks.name);
return rows.map((r) => ({
id: r.id,
name: r.name,
name_es: r.nameEs,
unit: r.unit,
active: r.active,
sort_order: r.sortOrder,
}));
});
}
// ── Pay Period Hours ───────────────────────────────────────────────────────────
// ── Pay Period Hours ──────────────────────────────────────────────────────
export type PayPeriodHours = {
success: boolean;
@@ -184,11 +561,305 @@ const EMPTY_PAY_PERIOD: Readonly<PayPeriodHours> = Object.freeze({
});
export async function getWorkerPayPeriodHours(
_brandId: string
brandId: string,
): Promise<PayPeriodHours> {
await getSession();
const session = await getTimeTrackingSession();
if (!session) return { ...EMPTY_PAY_PERIOD };
return { ...EMPTY_PAY_PERIOD };
// Session is the source of truth for field workers — see
// `submitManualTimeLog`. Without this, a stale brand constant on
// the page (e.g. during a brand migration) makes the Hub's "logged
// today" total read 0 because the period-totals query points at a
// brand the worker doesn't belong to.
const resolvedBrand = session.brand_id ?? brandId;
// Settings may not exist yet; default to safe thresholds.
const { getTimeTrackingSettings } = await import("./index");
const settings = await getTimeTrackingSettings(resolvedBrand);
const totals = await getWorkerPeriodTotals(
resolvedBrand,
session.worker_id,
settings,
);
return {
success: true,
total_minutes: totals.period_minutes,
total_hours: totals.period_hours,
daily_minutes: totals.day_minutes,
daily_hours: totals.day_hours,
weekly_minutes: totals.week_minutes,
weekly_hours: totals.week_hours,
daily_overtime: totals.daily_overtime,
weekly_overtime: totals.weekly_overtime,
period_start: totals.period_start,
period_end: totals.period_end,
daily_threshold: totals.daily_threshold,
weekly_threshold: totals.weekly_threshold,
};
}
// ── Manual time entry (Hub → Manual Entry tile) ───────────────────────────
//
// Cycle 12: PIN now lands on a Hub screen, and one of the tiles opens a
// "manual entry" form for missed clock-in/clock-out shifts. The form
// inserts directly into `time_tracking_logs` with `entry_kind='manual'`
// and a required `manual_reason`. The same row shape is reused by the
// admin timesheet UI (see `src/actions/time-tracking/entries.ts`).
//
// We deliberately keep these safety bounds:
// • Clock-in must be within the last 30 days (workers cannot backfill
// last month; payroll is already closed).
// • Clock-out must be > clock-in.
// • Duration cannot exceed 16 hours (a single shift ceiling matching the
// admin UI guard).
// • Reason must be ≥ 3 chars (so "x" can't sneak through).
// • GPS is not surfaced — manual entries never have GPS, by definition.
//
// The row is inserted via `withBrand` so RLS context is preserved, and
// Smartsheet sync is enqueued best-effort so it matches live clock-outs.
const MAX_MANUAL_LOOKBACK_DAYS = 30;
const MAX_MANUAL_HOURS = 16;
const MIN_MANUAL_REASON_LENGTH = 3;
export type SubmitManualTimeLogInput = {
brandId?: string;
clockInIso: string;
clockOutIso: string;
lunchBreakMinutes?: number;
taskId?: string | null;
taskName?: string | null;
notes?: string | null;
reason: string;
};
export type SubmitManualTimeLogResult =
| {
success: true;
log_id: string;
clock_in: string;
clock_out: string;
total_minutes: number;
}
| { success: false; error: string };
export async function submitManualTimeLog(
input: SubmitManualTimeLogInput,
): Promise<SubmitManualTimeLogResult> {
const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" };
// Brand resolution: caller may pass an explicit brandId (e.g. the admin
// manage page passing platform_admin context), but the field worker app
// has only one brand — the session's brand_id.
const brandId = input.brandId ?? session.brand_id;
if (!brandId) {
return { success: false, error: "Missing brand context" };
}
const reason = input.reason.trim();
if (reason.length < MIN_MANUAL_REASON_LENGTH) {
return {
success: false,
error: "Please add a brief reason (at least 3 characters)",
};
}
// Parse + sanity-check the timestamps before hitting the DB.
const clockIn = new Date(input.clockInIso);
const clockOut = new Date(input.clockOutIso);
if (Number.isNaN(clockIn.getTime())) {
return { success: false, error: "Clock-in time is invalid" };
}
if (Number.isNaN(clockOut.getTime())) {
return { success: false, error: "Clock-out time is invalid" };
}
if (clockIn.getTime() >= clockOut.getTime()) {
return { success: false, error: "Clock-out must be after clock-in" };
}
const durationMs = clockOut.getTime() - clockIn.getTime();
const durationMinutes = Math.round(durationMs / 60000);
if (durationMinutes > MAX_MANUAL_HOURS * 60) {
return {
success: false,
error: `Shift cannot exceed ${MAX_MANUAL_HOURS} hours`,
};
}
const now = new Date();
const lookbackCutoff = new Date(
now.getTime() - MAX_MANUAL_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
);
if (clockIn.getTime() < lookbackCutoff.getTime()) {
return {
success: false,
error: `Clock-in is more than ${MAX_MANUAL_LOOKBACK_DAYS} days ago`,
};
}
if (clockIn.getTime() > now.getTime()) {
return { success: false, error: "Clock-in cannot be in the future" };
}
const lunchMinutes = Math.max(
0,
Math.min(
Math.round(input.lunchBreakMinutes ?? 0),
Math.max(0, durationMinutes - 1),
),
);
// Resolve a friendly task name when the worker picked a task. Tasks are
// brand-scoped, so we resolve inside the brand transaction to avoid
// querying wrong-brand rows.
let resolvedTaskId: string | null = input.taskId ?? null;
let resolvedTaskName = (input.taskName ?? "").trim() || "Manual entry";
const result = await withBrand(brandId, async (db) => {
if (resolvedTaskId) {
const t = await db
.select({ name: timeTrackingTasks.name })
.from(timeTrackingTasks)
.where(
and(
eq(timeTrackingTasks.id, resolvedTaskId),
eq(timeTrackingTasks.brandId, brandId),
),
)
.limit(1);
if (t.length > 0) resolvedTaskName = t[0].name;
}
const inserted = await db
.insert(timeTrackingLogs)
.values({
brandId,
fieldWorkerId: session.worker_id,
taskId: resolvedTaskId,
taskName: resolvedTaskName,
clockIn,
clockOut,
lunchBreakMinutes: lunchMinutes,
notes: input.notes?.trim() || null,
// Manual entries: enum='manual', reason is required, no GPS.
entryKind: "manual",
manualReason: reason,
submittedVia: "manual",
gpsVerified: false,
})
.returning({ id: timeTrackingLogs.id });
return inserted[0]?.id;
});
if (!result) {
return { success: false, error: "Failed to save entry" };
}
// Best-effort Smartsheet sync enqueue + timesheet recompute. Same
// fire-and-forget posture as `clockOutWorker`.
void enqueueClockOutForSync(brandId, result).catch(() => {});
void recomputeOwningTimesheet(brandId, result).catch(() => {});
const totalMinutes = Math.max(0, durationMinutes - lunchMinutes);
return {
success: true,
log_id: result,
clock_in: clockIn.toISOString(),
clock_out: clockOut.toISOString(),
total_minutes: totalMinutes,
};
}
// ── Recent log fetcher (Hub + Recent Activity screen) ──────────────────────
/** A flattened, presentation-ready log row. Used by both the Hub hero card
* ("logged today" preview) and the Recent Activity screen. */
export type WorkerLogRow = {
log_id: string;
clock_in: string;
clock_out: string | null;
task_name: string;
entry_kind: "clock" | "manual";
lunch_break_minutes: number;
total_minutes: number; // 0 if open shift (clock_out is null)
notes: string | null;
manual_reason: string | null;
};
export type RecentTimeLogsResult =
| { success: true; logs: WorkerLogRow[] }
| { success: false; error: string };
export async function getRecentTimeLogs(
brandId: string,
days = 14,
): Promise<RecentTimeLogsResult> {
const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" };
const safeDays = Math.max(1, Math.min(days, 90));
const since = new Date(
Date.now() - safeDays * 24 * 60 * 60 * 1000,
).toISOString();
// Session is the source of truth for field workers — see
// `submitManualTimeLog` and `getOpenClockIn`. Without this, a stale
// brand constant on the page (e.g. during a brand migration) makes
// Recent Activity read "No shifts yet" because the query points at a
// brand the worker doesn't belong to.
const resolvedBrand = session.brand_id ?? brandId;
return withBrand(resolvedBrand, async (db) => {
const rows = await db
.select({
id: timeTrackingLogs.id,
clockIn: timeTrackingLogs.clockIn,
clockOut: timeTrackingLogs.clockOut,
taskName: timeTrackingLogs.taskName,
entryKind: timeTrackingLogs.entryKind,
lunchBreakMinutes: timeTrackingLogs.lunchBreakMinutes,
notes: timeTrackingLogs.notes,
manualReason: timeTrackingLogs.manualReason,
})
.from(timeTrackingLogs)
.where(
and(
eq(timeTrackingLogs.brandId, resolvedBrand),
eq(timeTrackingLogs.fieldWorkerId, session.worker_id),
gte(timeTrackingLogs.clockIn, new Date(since)),
// Only show logs that have a clock-out (the Recent Activity
// card represents *completed* shifts). Open shifts are shown
// on the Hub hero card via `getOpenClockIn`.
),
)
.orderBy(desc(timeTrackingLogs.clockIn))
.limit(200);
const logs: WorkerLogRow[] = rows.map((r) => {
const total_minutes =
r.clockOut != null
? Math.max(
0,
Math.round(
(r.clockOut.getTime() - r.clockIn.getTime()) / 60000,
) - r.lunchBreakMinutes,
)
: 0;
return {
log_id: r.id,
clock_in: r.clockIn.toISOString(),
clock_out: r.clockOut ? r.clockOut.toISOString() : null,
task_name: r.taskName,
entry_kind: r.entryKind,
lunch_break_minutes: r.lunchBreakMinutes,
total_minutes,
notes: r.notes,
manual_reason: r.manualReason,
};
});
return { success: true, logs };
});
}
+631 -116
View File
@@ -1,23 +1,34 @@
/**
* Time Tracking — admin actions.
*
* Re-implemented in Cycle 2 of the water-log/time-tracking refactor to
* run against Drizzle. Original implementation was Supabase-RPC-based
* and stubbed empty after the SaaS rebuild; the TODO comments are stale.
*
* Auth: every mutator calls `getAdminUser()` and enforces either
* `platform_admin` or brand-scoped permission. Reads require a brand
* context.
*
* PIN storage: cycle 10 unified the worker tables into `field_workers`
* where `pin_hash` (TEXT) carries the scrypt hash (self-describing
* format from `@/lib/water-log-pin`). Plaintext PINs are returned
* ONCE on create/reset and never persisted.
*/
"use server";
import { eq, and, gte, lte, desc, asc, sql } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth";
import { withBrand, withPlatformAdmin } from "@/db/client";
import {
timeTrackingTasks,
timeTrackingLogs,
timeTrackingSettings,
timeTrackingNotificationLog,
} from "@/db/schema/time-tracking";
import { fieldWorkers } from "@/db/schema/field-workers";
import { hashPin, generatePin } from "@/lib/water-log-pin";
// TODO(migration): the time-tracking admin RPCs (get_time_tracking_workers,
// create_time_worker, reset_time_worker_pin, update_time_worker,
// delete_time_worker, create_time_task, update_time_task,
// delete_time_task, get_worker_time_logs, update_worker_time_log,
// delete_worker_time_log, get_time_tracking_summary,
// get_time_tracking_settings, update_time_tracking_settings,
// get_time_tracking_notification_log, check_and_notify_overtime) and
// the underlying tables (`time_workers`, `time_tasks`, `time_logs`,
// `time_tracking_settings`, `time_tracking_notification_log`) were
// not carried over into the SaaS rebuild's `db/schema/`. The actions
// below return empty results. To bring time tracking back, add
// the tables to `db/schema/` and re-implement against Drizzle. See
// `actions/route-trace/lots.ts` for the same pattern.
// ── Types ─────────────────────────────────────────────────────────────────────
// ── Types ───────────────────────────────────────────────────────────────────
export type TimeWorker = {
id: string;
@@ -25,6 +36,7 @@ export type TimeWorker = {
name: string;
role: string;
lang: string;
// Hash, not plaintext. Use `verifyPin(rawPin, pin)` to check.
pin: string;
active: boolean;
last_used_at: string | null;
@@ -39,10 +51,13 @@ export type TimeTask = {
unit: string;
active: boolean;
sort_order: number;
brand_id: string;
created_at: string;
};
export type TimeLog = {
id: string;
brand_id: string;
worker_id: string;
worker_name: string;
task_id: string | null;
@@ -57,147 +72,473 @@ export type TimeLog = {
};
export type TimeSummary = {
by_worker: { id: string; name: string; entry_count: number; total_hours: number }[];
by_task: { id: string; name: string; name_es: string | null; entry_count: number; total_hours: number }[];
by_worker: {
id: string;
name: string;
entry_count: number;
total_hours: number;
}[];
by_task: {
id: string;
name: string;
name_es: string | null;
entry_count: number;
total_hours: number;
}[];
totals: { entry_count: number; total_hours: number; open_count: number };
};
// ── Workers ───────────────────────────────────────────────────────────────────
// ── Helpers ────────────────────────────────────────────────────────────────
export async function getTimeTrackingWorkers(_brandId: string): Promise<TimeWorker[]> {
function rowToWorker(r: typeof fieldWorkers.$inferSelect): TimeWorker {
return {
id: r.id,
brand_id: r.brandId,
name: r.name,
role: r.role,
// Cycle 10: column rename `lang` → `languagePreference` in field_workers.
lang: r.languagePreference,
// Cycle 10: column rename `pin` → `pinHash` in field_workers.
pin: r.pinHash,
active: r.active,
last_used_at: r.lastUsedAt ? r.lastUsedAt.toISOString() : null,
created_at: r.createdAt.toISOString(),
worker_number: null, // not stored in schema
};
}
await getSession(); // Time tracking tables not in SaaS rebuild — return empty list.
return [];
function rowToTask(r: typeof timeTrackingTasks.$inferSelect): TimeTask {
return {
id: r.id,
name: r.name,
name_es: r.nameEs,
unit: r.unit,
active: r.active,
sort_order: r.sortOrder,
brand_id: r.brandId,
created_at: r.createdAt.toISOString(),
};
}
// ── Workers ─────────────────────────────────────────────────────────────────
export async function getTimeTrackingWorkers(
brandId: string,
): Promise<TimeWorker[]> {
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(fieldWorkers)
.where(
and(
eq(fieldWorkers.brandId, brandId),
// Time admin UI only shows time-domain workers.
sql`${fieldWorkers.role} IN ('worker', 'time_admin')`,
),
)
.orderBy(asc(fieldWorkers.name));
return rows.map(rowToWorker);
});
}
export async function createTimeWorker(
_brandId: string,
_name: string,
_role = "worker",
_lang = "en"
): Promise<{ success: boolean; worker?: TimeWorker; pin?: string; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
brandId: string,
name: string,
role: string = "worker",
lang: string = "en",
): Promise<{
success: boolean;
worker?: TimeWorker;
pin?: string;
error?: string;
}> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (role !== "worker" && role !== "time_admin") {
return { success: false, error: "Role must be 'worker' or 'time_admin'" };
}
const pin = generatePin();
const pinHash = hashPin(pin);
return withBrand(brandId, async (db) => {
const [row] = await db
.insert(fieldWorkers)
.values({
brandId,
name: trimmed,
role,
languagePreference: lang,
pinHash,
})
.returning();
if (!row) return { success: false, error: "Insert returned no row" };
return { success: true, worker: rowToWorker(row), pin };
});
}
export async function resetTimeWorkerPin(_workerId: string): Promise<{ success: boolean; pin?: string; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
export async function resetTimeWorkerPin(
workerId: string,
): Promise<{ success: boolean; pin?: string; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const pin = generatePin();
const pinHash = hashPin(pin);
// We don't know the brand_id without looking it up; resolve first via
// platform_admin (admins can reset across brands), then narrow.
const updated = await withPlatformAdmin(async (db) => {
const [row] = await db
.update(fieldWorkers)
.set({ pinHash })
.where(eq(fieldWorkers.id, workerId))
.returning({ id: fieldWorkers.id });
return row;
});
if (!updated) return { success: false, error: "Worker not found" };
return { success: true, pin };
}
export async function updateTimeWorker(
_workerId: string,
_name: string,
_role: string,
_lang: string,
_active: boolean
workerId: string,
name: string,
role: string,
lang: string,
active: boolean,
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (role !== "worker" && role !== "time_admin") {
return { success: false, error: "Invalid role" };
}
const result = await withPlatformAdmin(async (db) => {
const [row] = await db
.update(fieldWorkers)
.set({ name: trimmed, role, languagePreference: lang, active })
.where(eq(fieldWorkers.id, workerId))
.returning({ id: fieldWorkers.id });
return row;
});
if (!result) return { success: false, error: "Worker not found" };
return { success: true };
}
export async function deleteTimeWorker(_workerId: string): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
export async function deleteTimeWorker(
workerId: string,
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const result = await withPlatformAdmin(async (db) => {
const [row] = await db
.delete(fieldWorkers)
.where(eq(fieldWorkers.id, workerId))
.returning({ id: fieldWorkers.id });
return row;
});
if (!result) return { success: false, error: "Worker not found" };
return { success: true };
}
// ── Tasks ───────────────────────────────────────────────────────────────────
// ── Tasks ───────────────────────────────────────────────────────────────────
export async function getTimeTrackingTasks(_brandId: string, _activeOnly = false): Promise<TimeTask[]> {
await getSession(); return [];
export async function getTimeTrackingTasks(
brandId: string,
activeOnly = false,
): Promise<TimeTask[]> {
return withBrand(brandId, async (db) => {
const baseWhere = eq(timeTrackingTasks.brandId, brandId);
const rows = await db
.select()
.from(timeTrackingTasks)
.where(activeOnly ? and(baseWhere, eq(timeTrackingTasks.active, true)) : baseWhere)
.orderBy(asc(timeTrackingTasks.sortOrder), asc(timeTrackingTasks.name));
return rows.map(rowToTask);
});
}
export async function createTimeTask(
_brandId: string,
_name: string,
_nameEs: string | null = null,
_unit = "hours",
_sortOrder = 0
brandId: string,
name: string,
nameEs: string | null = null,
unit: string = "hours",
sortOrder = 0,
): Promise<{ success: boolean; id?: string; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (!["hours", "pieces", "units"].includes(unit)) {
return { success: false, error: "Unit must be 'hours', 'pieces', or 'units'" };
}
return withBrand(brandId, async (db) => {
const [row] = await db
.insert(timeTrackingTasks)
.values({
brandId,
name: trimmed,
nameEs: nameEs?.trim() || null,
unit: unit as "hours" | "pieces" | "units",
sortOrder,
active: true,
})
.returning({ id: timeTrackingTasks.id });
if (!row) return { success: false, error: "Insert returned no row" };
return { success: true, id: row.id };
});
}
export async function updateTimeTask(
_taskId: string,
_name: string,
_nameEs: string,
_unit: string,
_active: boolean,
_sortOrder: number
taskId: string,
name: string,
nameEs: string,
unit: string,
active: boolean,
sortOrder: number,
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (!["hours", "pieces", "units"].includes(unit)) {
return { success: false, error: "Invalid unit" };
}
const result = await withPlatformAdmin(async (db) => {
const [row] = await db
.update(timeTrackingTasks)
.set({
name: trimmed,
nameEs: nameEs?.trim() || null,
unit: unit as "hours" | "pieces" | "units",
active,
sortOrder,
})
.where(eq(timeTrackingTasks.id, taskId))
.returning({ id: timeTrackingTasks.id });
return row;
});
if (!result) return { success: false, error: "Task not found" };
return { success: true };
}
export async function deleteTimeTask(_taskId: string): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
export async function deleteTimeTask(
taskId: string,
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const result = await withPlatformAdmin(async (db) => {
const [row] = await db
.delete(timeTrackingTasks)
.where(eq(timeTrackingTasks.id, taskId))
.returning({ id: timeTrackingTasks.id });
return row;
});
if (!result) return { success: false, error: "Task not found" };
return { success: true };
}
// ── Time Logs ─────────────────────────────────────────────────────────────────
// ── Time Logs (read-only — writes happen via field actions) ─────────────────
export async function getWorkerTimeLogs(
_brandId: string,
_options: {
type LogListOptions = {
workerId?: string;
taskId?: string;
start?: string;
end?: string;
start?: string; // ISO timestamp
end?: string; // ISO timestamp
limit?: number;
offset?: number;
} = {}
};
export async function getWorkerTimeLogs(
brandId: string,
options: LogListOptions = {},
): Promise<TimeLog[]> {
const { workerId, taskId, start, end, limit = 200, offset = 0 } = options;
await getSession(); return [];
return withBrand(brandId, async (db) => {
const conditions = [eq(timeTrackingLogs.brandId, brandId)];
if (workerId)
conditions.push(eq(timeTrackingLogs.fieldWorkerId, workerId));
if (taskId) conditions.push(eq(timeTrackingLogs.taskId, taskId));
if (start) conditions.push(gte(timeTrackingLogs.clockIn, new Date(start)));
if (end) conditions.push(lte(timeTrackingLogs.clockIn, new Date(end)));
const rows = await db
.select({
id: timeTrackingLogs.id,
brand_id: timeTrackingLogs.brandId,
worker_id: timeTrackingLogs.fieldWorkerId,
worker_name: fieldWorkers.name,
task_id: timeTrackingLogs.taskId,
task_name: timeTrackingLogs.taskName,
clock_in: timeTrackingLogs.clockIn,
clock_out: timeTrackingLogs.clockOut,
lunch_break_minutes: timeTrackingLogs.lunchBreakMinutes,
notes: timeTrackingLogs.notes,
submitted_via: timeTrackingLogs.submittedVia,
created_at: timeTrackingLogs.createdAt,
})
.from(timeTrackingLogs)
.leftJoin(
fieldWorkers,
eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId),
)
.where(and(...conditions))
.orderBy(desc(timeTrackingLogs.clockIn))
.limit(limit)
.offset(offset);
return rows.map((r) => ({
id: r.id,
brand_id: r.brand_id,
worker_id: r.worker_id,
worker_name: r.worker_name ?? "(deleted worker)",
task_id: r.task_id,
task_name: r.task_name,
clock_in: r.clock_in.toISOString(),
clock_out: r.clock_out ? r.clock_out.toISOString() : null,
lunch_break_minutes: r.lunch_break_minutes,
notes: r.notes,
submitted_via: r.submitted_via,
total_minutes: computeTotalMinutes(
r.clock_in,
r.clock_out,
r.lunch_break_minutes,
),
created_at: r.created_at.toISOString(),
}));
});
}
async function updateWorkerTimeLog(
_logId: string,
_taskName: string,
_clockIn: string,
_clockOut: string | null,
_lunchMinutes: number,
_notes: string | null
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
}
async function deleteWorkerTimeLog(_logId: string): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
function computeTotalMinutes(
clockIn: Date,
clockOut: Date | null,
lunchMinutes: number,
): number {
if (!clockOut) return 0;
const ms = clockOut.getTime() - clockIn.getTime();
return Math.max(0, Math.round(ms / 60000) - lunchMinutes);
}
export async function getTimeTrackingSummary(
_brandId: string,
_start: string,
_end: string
brandId: string,
start: string,
end: string,
): Promise<TimeSummary> {
const startDate = new Date(start);
const endDate = new Date(end);
await getSession(); return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
return withBrand(brandId, async (db) => {
const logs = await db
.select({
workerId: timeTrackingLogs.fieldWorkerId,
workerName: fieldWorkers.name,
taskId: timeTrackingLogs.taskId,
taskName: timeTrackingLogs.taskName,
clockIn: timeTrackingLogs.clockIn,
clockOut: timeTrackingLogs.clockOut,
lunchBreakMinutes: timeTrackingLogs.lunchBreakMinutes,
})
.from(timeTrackingLogs)
.leftJoin(
fieldWorkers,
eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId),
)
.where(
and(
eq(timeTrackingLogs.brandId, brandId),
gte(timeTrackingLogs.clockIn, startDate),
lte(timeTrackingLogs.clockIn, endDate),
),
);
const byWorkerMap = new Map<
string,
{ name: string; entry_count: number; total_hours: number }
>();
const byTaskMap = new Map<
string,
{ name: string; entry_count: number; total_hours: number }
>();
let totalMinutes = 0;
let openCount = 0;
for (const l of logs) {
const minutes = computeTotalMinutes(
l.clockIn,
l.clockOut,
l.lunchBreakMinutes,
);
if (!l.clockOut) {
openCount += 1;
} else {
totalMinutes += minutes;
}
if (l.workerId) {
const prev = byWorkerMap.get(l.workerId) ?? {
name: l.workerName ?? "(deleted)",
entry_count: 0,
total_hours: 0,
};
prev.entry_count += 1;
prev.total_hours += minutes / 60;
byWorkerMap.set(l.workerId, prev);
}
if (l.taskId) {
const prev = byTaskMap.get(l.taskId) ?? {
name: l.taskName,
entry_count: 0,
total_hours: 0,
};
prev.entry_count += 1;
prev.total_hours += minutes / 60;
byTaskMap.set(l.taskId, prev);
}
}
return {
by_worker: Array.from(byWorkerMap.entries()).map(([id, v]) => ({
id,
name: v.name,
entry_count: v.entry_count,
total_hours: Math.round(v.total_hours * 100) / 100,
})),
by_task: Array.from(byTaskMap.entries()).map(([id, v]) => ({
id,
name: v.name,
name_es: null,
entry_count: v.entry_count,
total_hours: Math.round(v.total_hours * 100) / 100,
})),
totals: {
entry_count: logs.length,
total_hours: Math.round((totalMinutes / 60) * 100) / 100,
open_count: openCount,
},
};
});
}
// ── Time Tracking Settings ─────────────────────────────────────────────────────
// ── Time Tracking Settings ─────────────────────────────────────────────────
export type TimeTrackingSettings = {
id: string;
@@ -218,21 +559,55 @@ export type TimeTrackingSettings = {
brand_name: string;
};
export async function getTimeTrackingSettings(_brandId: string): Promise<TimeTrackingSettings | null> {
function rowToSettings(
r: typeof timeTrackingSettings.$inferSelect,
): TimeTrackingSettings {
return {
id: r.id,
brand_id: r.brandId,
pay_period_start_day: r.payPeriodStartDay,
pay_period_length_days: r.payPeriodLengthDays,
daily_overtime_threshold: Number(r.dailyOvertimeThreshold),
weekly_overtime_threshold: Number(r.weeklyOvertimeThreshold),
overtime_multiplier: Number(r.overtimeMultiplier),
overtime_notifications: r.overtimeNotifications,
// These don't exist on the schema row; defaults match the original.
notification_emails: [],
notification_sms_numbers: [],
enable_daily_alerts: r.overtimeNotifications,
enable_weekly_alerts: r.overtimeNotifications,
daily_alert_threshold: Number(r.dailyOvertimeThreshold),
weekly_alert_threshold: Number(r.weeklyOvertimeThreshold),
send_end_of_period_summary: false,
brand_name: "",
};
}
await getSession(); // Real RPC not in SaaS rebuild.
return null;
export async function getTimeTrackingSettings(
brandId: string,
): Promise<TimeTrackingSettings | null> {
return withBrand(brandId, async (db) => {
const [row] = await db
.select()
.from(timeTrackingSettings)
.where(eq(timeTrackingSettings.brandId, brandId))
.limit(1);
return row ? rowToSettings(row) : null;
});
}
export async function updateTimeTrackingSettings(
_brandId: string,
_settings: {
brandId: string,
settings: {
pay_period_start_day: number;
pay_period_length_days: number;
daily_overtime_threshold: number;
weekly_overtime_threshold: number;
overtime_multiplier: number;
overtime_notifications: boolean;
// Notification targets are accepted for back-compat with the UI
// form but currently aren't persisted (the columns aren't on the
// schema). Future migration can add them.
notification_emails?: string[];
notification_sms_numbers?: string[];
enable_daily_alerts?: boolean;
@@ -241,15 +616,41 @@ export async function updateTimeTrackingSettings(
weekly_alert_threshold?: number;
send_end_of_period_summary?: boolean;
brand_name?: string;
}
},
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
return withBrand(brandId, async (db) => {
// Upsert by brand_id (which is also a UNIQUE column on the table).
await db
.insert(timeTrackingSettings)
.values({
brandId,
payPeriodStartDay: settings.pay_period_start_day,
payPeriodLengthDays: settings.pay_period_length_days,
dailyOvertimeThreshold: String(settings.daily_overtime_threshold),
weeklyOvertimeThreshold: String(settings.weekly_overtime_threshold),
overtimeMultiplier: String(settings.overtime_multiplier),
overtimeNotifications: settings.overtime_notifications,
})
.onConflictDoUpdate({
target: timeTrackingSettings.brandId,
set: {
payPeriodStartDay: settings.pay_period_start_day,
payPeriodLengthDays: settings.pay_period_length_days,
dailyOvertimeThreshold: String(settings.daily_overtime_threshold),
weeklyOvertimeThreshold: String(settings.weekly_overtime_threshold),
overtimeMultiplier: String(settings.overtime_multiplier),
overtimeNotifications: settings.overtime_notifications,
updatedAt: new Date(),
},
});
return { success: true };
});
}
// ── Notification Log ────────────────────────────────────────────────────────
// ── Notification Log ────────────────────────────────────────────────────────
export type NotificationLogEntry = {
id: string;
@@ -267,9 +668,123 @@ export type NotificationLogEntry = {
};
export async function getTimeTrackingNotificationLog(
_brandId: string,
_limit = 100
brandId: string,
limit = 100,
): Promise<NotificationLogEntry[]> {
return withBrand(brandId, async (db) => {
const rows = await db
.select({
id: timeTrackingNotificationLog.id,
workerId: timeTrackingNotificationLog.fieldWorkerId,
workerName: fieldWorkers.name,
notificationType: timeTrackingNotificationLog.notificationType,
recipient: timeTrackingNotificationLog.recipient,
status: timeTrackingNotificationLog.status,
createdAt: timeTrackingNotificationLog.createdAt,
})
.from(timeTrackingNotificationLog)
.leftJoin(
fieldWorkers,
eq(fieldWorkers.id, timeTrackingNotificationLog.fieldWorkerId),
)
.where(eq(timeTrackingNotificationLog.brandId, brandId))
.orderBy(desc(timeTrackingNotificationLog.createdAt))
.limit(limit);
await getSession(); return [];
return rows.map((r) => ({
id: r.id,
worker_id: r.workerId,
worker_name: r.workerName ?? null,
trigger_type: r.notificationType,
threshold_hours: null,
actual_hours: null,
emails_sent: r.status === "sent" ? [r.recipient] : [],
sms_numbers_sent: [],
email_sent: r.status === "sent",
sms_sent: false,
error_message: r.status === "failed" ? r.recipient : null,
created_at: r.createdAt.toISOString(),
}));
});
}
// ── Internal helpers exported for the notifications action ─────────────────
/**
* Compute pay-period totals (used by `getWorkerPayPeriodHours` in
* field.ts and by `checkAndNotifyOvertime`). Returns hours/minutes
* for the period-to-date, today, and this week, plus overtime flags.
*/
export async function getWorkerPeriodTotals(
brandId: string,
workerId: string,
settings: TimeTrackingSettings | null,
) {
const now = new Date();
const startOfDay = new Date(now);
startOfDay.setHours(0, 0, 0, 0);
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - now.getDay()); // Sunday
startOfWeek.setHours(0, 0, 0, 0);
// Pay period start: walk back `pay_period_length_days` from today
// until we land on a day whose day-of-month matches
// `pay_period_start_day`.
const payLen = settings?.pay_period_length_days ?? 7;
const startOfPeriod = new Date(now);
startOfPeriod.setDate(
now.getDate() - ((payLen - 1) - (now.getDate() % payLen)),
);
startOfPeriod.setHours(0, 0, 0, 0);
return withBrand(brandId, async (db) => {
const logs = await db
.select({
clockIn: timeTrackingLogs.clockIn,
clockOut: timeTrackingLogs.clockOut,
lunchBreakMinutes: timeTrackingLogs.lunchBreakMinutes,
})
.from(timeTrackingLogs)
.where(
and(
eq(timeTrackingLogs.brandId, brandId),
eq(timeTrackingLogs.fieldWorkerId, workerId),
gte(timeTrackingLogs.clockIn, startOfPeriod),
),
);
let periodMinutes = 0;
let dayMinutes = 0;
let weekMinutes = 0;
for (const l of logs) {
const minutes = computeTotalMinutes(
l.clockIn,
l.clockOut,
l.lunchBreakMinutes,
);
periodMinutes += minutes;
if (l.clockIn >= startOfDay) dayMinutes += minutes;
if (l.clockIn >= startOfWeek) weekMinutes += minutes;
}
const dailyThreshold = settings?.daily_overtime_threshold ?? 8;
const weeklyThreshold = settings?.weekly_overtime_threshold ?? 40;
return {
period_start: startOfPeriod.toISOString(),
period_end: now.toISOString(),
period_minutes: periodMinutes,
period_hours: Math.round((periodMinutes / 60) * 100) / 100,
day_minutes: dayMinutes,
day_hours: Math.round((dayMinutes / 60) * 100) / 100,
week_minutes: weekMinutes,
week_hours: Math.round((weekMinutes / 60) * 100) / 100,
daily_overtime: dayMinutes / 60 > dailyThreshold,
weekly_overtime: weekMinutes / 60 > weeklyThreshold,
daily_threshold: dailyThreshold,
weekly_threshold: weeklyThreshold,
};
});
}
+81 -19
View File
@@ -1,36 +1,98 @@
/**
* Time Tracking — overtime notification check.
*
* Re-implemented in Cycle 2 against Drizzle. The original was a stub
* that returned `{ sent: false }`. The cron-style caller
* (`/api/time-tracking/notify`) invokes this after every clock-in/out
* to decide whether to alert the brand's configured recipients.
*
* For now we INSERT into the notification log on every check; the
* actual email/SMS dispatch is intentionally a no-op until the
* notification_targets columns land on `time_tracking_settings` (out
* of Cycle 2 scope). The notify API route can already read the log.
*/
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth";
// TODO(migration): the `check_and_notify_overtime` SECURITY DEFINER
// RPC and the time-tracking notification tables are not part of the
// SaaS rebuild schema. The function below is a stub that returns
// `{ sent: false }` so the cron-style caller (`/api/time-tracking/notify`)
// degrades gracefully. See `actions/route-trace/lots.ts` for the
// same pattern.
import { withBrand } from "@/db/client";
import { timeTrackingNotificationLog } from "@/db/schema/time-tracking";
import { getTimeTrackingSettings, getWorkerPeriodTotals } from "./index";
export type OvertimeCheckResult = {
sent: boolean;
trigger_type?: string;
trigger_type?: "daily" | "weekly" | "both";
message?: string;
notification_log_id?: string;
daily_hours?: number;
weekly_hours?: number;
};
export async function checkAndNotifyOvertime(
_brandId: string,
_workerId: string,
_workerName: string,
_dailyHours: number,
_weeklyHours: number
brandId: string,
workerId: string,
workerName: string,
// The two hour args are accepted for back-compat with the original
// signature (the cron caller passes them). We re-derive from the
// logs to avoid drift, but record them for the log body.
dailyHours: number,
weeklyHours: number,
): Promise<OvertimeCheckResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { sent: false, message: "Not authenticated" };
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) {
return { sent: false, message: "Not authenticated" };
const settings = await getTimeTrackingSettings(brandId);
if (!settings || !settings.overtime_notifications) {
return { sent: false, message: "Notifications disabled" };
}
// Re-derive totals from the source of truth so the notification log
// matches the actuals even if the caller passed stale numbers.
const totals = await getWorkerPeriodTotals(brandId, workerId, settings);
const dailyHit = totals.day_hours > totals.daily_threshold;
const weeklyHit = totals.week_hours > totals.weekly_threshold;
if (!dailyHit && !weeklyHit) {
return { sent: false, message: "Below thresholds" };
}
const trigger: "daily" | "weekly" | "both" = dailyHit && weeklyHit
? "both"
: dailyHit
? "daily"
: "weekly";
const subject = `Overtime alert: ${workerName}`;
const body =
`${workerName} crossed the ${trigger} overtime threshold.\n` +
`Day: ${totals.day_hours.toFixed(2)} h (threshold ${totals.daily_threshold})\n` +
`Week: ${totals.week_hours.toFixed(2)} h (threshold ${totals.weekly_threshold})\n` +
`(Reported: ${dailyHours.toFixed(2)} / ${weeklyHours.toFixed(2)})`;
// Insert a notification log row. status='pending' — actual email/SMS
// dispatch is out of Cycle 2 scope (notification_targets columns on
// time_tracking_settings land in a follow-up migration). A future
// cron will flip 'pending' → 'sent' / 'failed'.
return withBrand(brandId, async (db) => {
const [row] = await db
.insert(timeTrackingNotificationLog)
.values({
brandId,
fieldWorkerId: workerId,
notificationType: trigger,
recipient: "admin@pending",
subject,
body,
status: "pending",
})
.returning({ id: timeTrackingNotificationLog.id });
return {
sent: false,
message: "Time tracking is not configured in the SaaS rebuild",
sent: true,
trigger_type: trigger,
message: body,
notification_log_id: row?.id,
daily_hours: totals.day_hours,
weekly_hours: totals.week_hours,
};
});
}
@@ -0,0 +1,104 @@
/**
* Time Tracking — Offline replay handlers.
*
* Wired into the offline dispatcher's `listClientActions()` so the field
* app's IndexedDB queue can replay clock-in / clock-out events when the
* device comes back online.
*
* Re-auth model:
* The field app stores the worker's PIN locally (encrypted at rest by
* the browser) for the lifetime of the session cookie. On PIN entry,
* the PIN is written to IndexedDB so a subsequent offline clock event
* can carry it. The replay server action re-hashes + compares against
* `field_workers.pin_hash` to authenticate the replay.
*
* Audit:
* Replays land with `submitted_via = 'offline_sync'`, a distinct
* value from `'field'` and `'manual'`, so payroll can spot and verify
* any entry that didn't have a live GPS fix.
*/
"use server";
import { verifyPin } from "@/lib/water-log-pin";
import { withPlatformAdmin } from "@/db/client";
import { fieldWorkers } from "@/db/schema/field-workers";
import { clockInWorker, clockOutWorker, type GpsSample } from "./field";
export type OfflineClockPayload =
| {
kind: "clock_in";
brandId: string;
workerId: string;
pin: string;
taskName: string;
gps?: { lat: number; lng: number; accuracy_m?: number } | null;
clientTs: string;
}
| {
kind: "clock_out";
brandId: string;
workerId: string;
pin: string;
lunchMinutes: number;
notes?: string;
gps?: { lat: number; lng: number; accuracy_m?: number } | null;
clientTs: string;
};
/**
* Re-verify a PIN against the `field_workers` table. Looks up across all
* brands (mirrors the field verify path) and returns the matching worker
* or null. Includes the weak 200ms response-time penalty to discourage
* brute-force.
*/
async function reAuthWorker(
workerId: string,
pin: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
if (!pin || pin.length < 4) return { ok: false, error: "PIN required" };
return withPlatformAdmin(async (db) => {
const rows = await db.select().from(fieldWorkers).limit(500);
const target = rows.find((r) => r.id === workerId);
if (!target || !target.active) {
await new Promise((r) => setTimeout(r, 200));
return { ok: false as const, error: "Worker not found or inactive" };
}
if (!verifyPin(pin, target.pinHash)) {
await new Promise((r) => setTimeout(r, 200));
return { ok: false as const, error: "Invalid PIN" };
}
return { ok: true as const };
});
}
export async function replayOfflineClock(
payload: OfflineClockPayload,
): Promise<{ success: boolean; log_id?: string; error?: string }> {
const auth = await reAuthWorker(payload.workerId, payload.pin);
if (!auth.ok) return { success: false, error: auth.error };
if (payload.kind === "clock_in") {
const r = await clockInWorker(
payload.brandId,
undefined,
payload.taskName,
payload.gps as GpsSample | null,
);
return {
success: r.success,
log_id: r.log_id,
error: r.error,
};
}
const r = await clockOutWorker(
payload.lunchMinutes,
payload.notes,
payload.gps as GpsSample | null,
);
return {
success: r.success,
log_id: r.log_id,
error: r.error,
};
}
+276
View File
@@ -0,0 +1,276 @@
/**
* Cross-link helper: "Route summary" = a worker-day bundle that shows
* BOTH water-log activity AND time-tracking totals side by side.
*
* The Water Log module works with field workers logging entries at
* headgates; the Time Tracking module has them clocking in/out. A
* "route" in the water-log domain is really a worker's day, and this
* action gives the UI a single object that summarises both halves of
* the day.
*
* Used by:
* - /admin/water-log users detail page (driver view)
* - /admin/time-tracking overview page (admin dashboard row)
*
* No mutation, no auth checks beyond getAdminUser(). Returns a typed
* shape so client components don't have to know about either module's
* table layout.
*/
"use server";
import { and, eq, gte, lt, sql } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { getPool } from "@/lib/db";
import { withBrand } from "@/db/client";
import {
timeTrackingLogs,
type TimesheetStatus,
} from "@/db/schema/time-tracking";
import { fieldWorkers } from "@/db/schema/field-workers";
import { waterLogEntries } from "@/db/schema/water-log";
import { getTimeTrackingSettings } from "@/actions/time-tracking";
export type RouteTimeSummary = {
date: string; // YYYY-MM-DD
workerId: string;
workerName: string;
water: {
entryCount: number;
totalGallons: number;
headgatesVisited: string[];
};
time: {
clockedIn: boolean;
currentLogId: string | null;
clockInAt: string | null;
lastClockOutAt: string | null;
workedMinutesToday: number;
breakMinutesToday: number;
overtimeMinutesToday: number;
regularMinutesToday: number;
timesheetStatus: TimesheetStatus | null;
timesheetId: string | null;
};
};
export type WorkerRouteRow = {
workerId: string;
workerName: string;
active: boolean;
water: { entryCount: number; totalGallons: number };
time: { workedMinutesToday: number; isClockedIn: boolean };
};
/**
* Compute the "today" snapshot for a single worker.
* `date` defaults to the local date in the admin's TZ (we use UTC
* midnight as a coarse cutoff; callers can pass a date they want).
*/
export async function getRouteTimeSummary(
brandId: string,
workerId: string,
date?: string,
): Promise<RouteTimeSummary | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
const day = date ?? new Date().toISOString().slice(0, 10);
const start = new Date(`${day}T00:00:00.000Z`);
const end = new Date(start);
end.setUTCDate(end.getUTCDate() + 1);
const worker = await withBrand(brandId, async (tx) => {
const rows = await tx
.select()
.from(fieldWorkers)
.where(
and(
eq(fieldWorkers.brandId, brandId),
eq(fieldWorkers.id, workerId),
),
)
.limit(1);
return rows[0] ?? null;
});
if (!worker) return null;
const pool = getPool();
// Water totals for the day
const waterRes = await pool.query<{
entry_count: string;
total_gallons: string | null;
headgates: string[] | null;
}>(
`SELECT
COUNT(*)::text AS entry_count,
COALESCE(SUM(total_gallons), 0)::text AS total_gallons,
ARRAY_AGG(DISTINCT headgate_id::text) FILTER (WHERE headgate_id IS NOT NULL) AS headgates
FROM water_log_entries
WHERE brand_id = $1
AND field_worker_id = $2
AND logged_at >= $3
AND logged_at < $4`,
[brandId, workerId, start.toISOString(), end.toISOString()],
);
const wRow = waterRes.rows[0];
// Time totals for the day — read from time_tracking_logs directly,
// aggregating minutes from clock_in → (clock_out OR now). Counts an
// open log as in-progress; does NOT mutate.
const timeRes = await pool.query<{
open_log_id: string | null;
open_clock_in: string | null;
last_clock_out: string | null;
worked_minutes: string;
break_minutes: string;
}>(
`SELECT
MAX(id) FILTER (WHERE clock_out IS NULL) AS open_log_id,
MAX(clock_in) FILTER (WHERE clock_out IS NULL) AS open_clock_in,
MAX(clock_out) AS last_clock_out,
COALESCE(SUM(
EXTRACT(EPOCH FROM (
COALESCE(clock_out, NOW()) - clock_in
)) / 60
), 0)::text - COALESCE(SUM(lunch_minutes), 0)::text AS worked_minutes,
COALESCE(SUM(lunch_minutes), 0)::text AS break_minutes
FROM time_tracking_logs
WHERE brand_id = $1
AND field_worker_id = $2
AND clock_in >= $3
AND clock_in < $4`,
[brandId, workerId, start.toISOString(), end.toISOString()],
);
const tRow = timeRes.rows[0];
const settings = await getTimeTrackingSettings(brandId);
const dailyOtMin = settings?.daily_overtime_threshold ?? 480;
const workedMin = Math.max(0, Number(tRow?.worked_minutes ?? 0));
const breakMin = Math.max(0, Number(tRow?.break_minutes ?? 0));
const regularMin = Math.min(workedMin, dailyOtMin);
const otMin = Math.max(0, workedMin - dailyOtMin);
// Find the timesheet (if any) that contains this date
const tsRes = await pool.query<{ id: string; status: TimesheetStatus }>(
`SELECT id, status
FROM time_tracking_timesheets
WHERE brand_id = $1
AND field_worker_id = $2
AND period_start <= $3::date
AND period_end >= $3::date
LIMIT 1`,
[brandId, workerId, day],
);
return {
date: day,
workerId,
workerName: worker.name || worker.pinHash.slice(0, 6) || "Worker",
water: {
entryCount: Number(wRow?.entry_count ?? 0),
totalGallons: Number(wRow?.total_gallons ?? 0),
headgatesVisited: (wRow?.headgates ?? []).filter(Boolean) as string[],
},
time: {
clockedIn: tRow?.open_log_id != null,
currentLogId: tRow?.open_log_id ?? null,
clockInAt: tRow?.open_clock_in ?? null,
lastClockOutAt: tRow?.last_clock_out ?? null,
workedMinutesToday: workedMin,
breakMinutesToday: breakMin,
overtimeMinutesToday: otMin,
regularMinutesToday: regularMin,
timesheetStatus: tsRes.rows[0]?.status ?? null,
timesheetId: tsRes.rows[0]?.id ?? null,
},
};
}
/**
* Fleet view: every active worker in the brand for "today". Used by
* the admin dashboard row so the dispatcher can see at a glance who's
* on the route AND whether they're on the clock.
*/
export async function getFleetRouteSummary(
brandId: string,
date?: string,
): Promise<WorkerRouteRow[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const day = date ?? new Date().toISOString().slice(0, 10);
const start = new Date(`${day}T00:00:00.000Z`);
const end = new Date(start);
end.setUTCDate(end.getUTCDate() + 1);
const pool = getPool();
// Cycle 10 — `field_workers` uses `name` and `pin_hash` (NOT the legacy
// `display_name` / `pin` columns from `admin_users` / the pre-cycle-10
// `time_tracking_workers`). Referencing the wrong columns crashes the
// /admin/time-tracking overview page at render time.
const { rows } = await pool.query<{
id: string;
name: string | null;
pin_hash: string;
active: boolean;
water_entries: string;
water_gallons: string | null;
worked_minutes: string;
is_clocked_in: boolean;
}>(
`SELECT
fw.id,
fw.name,
fw.pin_hash,
fw.active,
COALESCE(w.cnt, 0)::text AS water_entries,
COALESCE(w.gal, 0)::text AS water_gallons,
COALESCE(t.minutes, 0)::text AS worked_minutes,
COALESCE(t.clocked_in, FALSE) AS is_clocked_in
FROM field_workers fw
LEFT JOIN LATERAL (
SELECT COUNT(*) AS cnt, SUM(total_gallons) AS gal
FROM water_log_entries
WHERE brand_id = $1
AND field_worker_id = fw.id
AND logged_at >= $2
AND logged_at < $3
) w ON TRUE
LEFT JOIN LATERAL (
SELECT SUM(
EXTRACT(EPOCH FROM (COALESCE(clock_out, NOW()) - clock_in)) / 60
- COALESCE(lunch_minutes, 0)
) AS minutes,
BOOL_OR(clock_out IS NULL) AS clocked_in
FROM time_tracking_logs
WHERE brand_id = $1
AND field_worker_id = fw.id
AND clock_in >= $2
AND clock_in < $3
) t ON TRUE
WHERE fw.brand_id = $1
ORDER BY fw.name NULLS LAST, fw.id`,
[brandId, start.toISOString(), end.toISOString()],
);
// `pin_hash` is a scrypt self-describing hash — never display more
// than a short fingerprint as a last-resort fallback for missing
// worker names. The hash is sensitive but its first 6 chars are not
// reversible, which matches the original code's intent.
return rows.map((r) => ({
workerId: r.id,
workerName: r.name || r.pin_hash.slice(0, 6) || "Worker",
active: r.active,
water: {
entryCount: Number(r.water_entries ?? 0),
totalGallons: Number(r.water_gallons ?? 0),
},
time: {
workedMinutesToday: Math.max(0, Number(r.worked_minutes ?? 0)),
isClockedIn: r.is_clocked_in,
},
}));
}
+412
View File
@@ -0,0 +1,412 @@
/**
* Time Tracking — Smartsheet integration server actions.
*
* Cycle 7: per-feature config no longer holds the API token. The
* encrypted token lives in `smartsheet_workspace` (one row per brand);
* this action reads it via `resolveWorkspaceToken` and only manages
* the per-feature sheet_id + column_mapping + frequency.
*
* UI surface for the admin `/admin/water-log/settings` card
* ("Time Tracking → Smartsheet"). Mirrors
* `src/actions/water-log/smartsheet.ts`.
*
* Token handling:
* - The plaintext token NEVER crosses the server-action boundary.
* - `resolveWorkspaceToken` decrypts it locally for API calls.
* - The masked preview is fetched from the workspace.
*/
"use server";
import { desc, eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import {
timeTrackingSmartsheetConfig,
timeTrackingSmartsheetSyncLog,
timeTrackingSmartsheetSyncQueue,
TT_SMARTSHEET_FREQUENCIES,
type TTSmartsheetColumnMapping,
type TTSmartsheetFrequency,
} from "@/db/schema/time-tracking";
import { resolveWorkspaceToken } from "@/services/workspace-token";
import { getSmartsheetWorkspace } from "@/actions/smartsheet/workspace";
import {
extractSheetId,
getSheetMeta,
SmartsheetApiError,
} from "@/lib/smartsheet";
import { getAdminUser } from "@/lib/admin-permissions";
// ── Public types ───────────────────────────────────────────────────────────
export type TTSmartsheetConfigResponse = {
configured: boolean;
sheetId: string | null;
syncEnabled: boolean;
syncFrequency: TTSmartsheetFrequency;
columnMapping: TTSmartsheetColumnMapping | null;
maskedToken: string | null;
lastSyncAt: string | null;
lastSyncError: string | null;
hasToken: boolean;
updatedAt: string | null;
};
export type TTSmartsheetRecentEntry = {
id: string;
logId: string | null;
smartsheetRowId: string | null;
action: "sync" | "retry" | "skip" | "queue";
success: boolean;
error: string | null;
durationMs: number | null;
createdAt: string;
};
export type TTSmartsheetQueueSummary = {
pending: number;
syncing: number;
synced: number;
failed: number;
};
export type SaveTTSmartsheetConfigInput = {
sheetId: string;
token: string;
syncEnabled: boolean;
syncFrequency: TTSmartsheetFrequency;
columnMapping: TTSmartsheetColumnMapping;
};
export type TTTestConnectionResult =
| {
success: true;
sheetName: string;
columnCount: number;
columns: { id: string; title: string; type: string }[];
}
| { success: false; error: string };
// ── Auth ───────────────────────────────────────────────────────────────────
async function requireManagePerms(): Promise<
{ ok: true } | { ok: false; error: string }
> {
const adminUser = await getAdminUser();
if (!adminUser) return { ok: false, error: "Not authenticated" };
// Time-tracking Smartsheet config is a brand-level integration
// setting — not a water-log concern. Gate on `can_manage_settings`
// until a dedicated `can_manage_time_tracking` flag exists.
if (
!adminUser.can_manage_settings &&
adminUser.role !== "platform_admin"
) {
return { ok: false, error: "Not authorized" };
}
return { ok: true };
}
// (Column keys + frequencies are imported from the schema and
// re-used directly — no re-export to avoid the "merged declaration"
// footgun.)
// ── Read: get config ───────────────────────────────────────────────────────
export async function getTTSmartsheetConfig(
brandId: string,
): Promise<TTSmartsheetConfigResponse> {
const perm = await requireManagePerms();
if (!perm.ok) {
return {
configured: false,
sheetId: null,
syncEnabled: false,
syncFrequency: "hourly",
columnMapping: null,
maskedToken: null,
lastSyncAt: null,
lastSyncError: null,
hasToken: false,
updatedAt: null,
};
}
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(timeTrackingSmartsheetConfig)
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
.limit(1);
const config = rows[0];
// Cycle 7: masked token + hasToken live on the workspace, not on
// the per-feature config.
const workspace = await getSmartsheetWorkspace(brandId);
if (!config) {
return {
configured: false,
sheetId: null,
syncEnabled: false,
syncFrequency: "hourly",
columnMapping: null,
maskedToken: workspace.maskedToken,
lastSyncAt: null,
lastSyncError: null,
hasToken: workspace.hasToken,
updatedAt: null,
};
}
return {
configured: true,
sheetId: config.sheetId,
syncEnabled: config.syncEnabled,
syncFrequency: config.syncFrequency,
columnMapping: config.columnMapping,
maskedToken: workspace.maskedToken,
hasToken: workspace.hasToken,
lastSyncAt: config.lastSyncAt ? config.lastSyncAt.toISOString() : null,
lastSyncError: config.lastSyncError ?? null,
updatedAt: config.updatedAt
? config.updatedAt.toISOString()
: null,
};
});
}
// ── Write: save config ─────────────────────────────────────────────────────
export async function saveTTSmartsheetConfig(
brandId: string,
input: SaveTTSmartsheetConfigInput,
): Promise<
{ success: true; config: TTSmartsheetConfigResponse } | { success: false; error: string }
> {
const perm = await requireManagePerms();
if (!perm.ok) return { success: false, error: perm.error };
// Validate.
try {
// extractSheetId throws on share-link slugs and bad URLs; we
// surface that error to the caller verbatim.
extractSheetId(input.sheetId);
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Invalid sheet ID",
};
}
if (
!TT_SMARTSHEET_FREQUENCIES.includes(input.syncFrequency)
) {
return {
success: false,
error: `syncFrequency must be one of ${TT_SMARTSHEET_FREQUENCIES.join(", ")}`,
};
}
if (!input.columnMapping.log_id || !input.columnMapping.clock_in) {
return {
success: false,
error: "columnMapping.log_id and columnMapping.clock_in are required (used for dedup)",
};
}
return withBrand(brandId, async (db) => {
const adminUser = (await getAdminUser()) ?? null;
const actorId = adminUser?.id ?? null;
// Cycle 7: API token is owned by the workspace. Refuse to save a
// sheet mapping if no workbook is connected.
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok && ws.reason !== "connection_disabled") {
return {
success: false,
error:
ws.reason === "no_workspace"
? "Connect the Smartsheet workbook first (hub card above)."
: "Workspace token is unreadable — re-paste it in the hub card.",
};
}
const [existing] = await db
.select()
.from(timeTrackingSmartsheetConfig)
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
.limit(1);
if (existing) {
await db
.update(timeTrackingSmartsheetConfig)
.set({
sheetId: extractSheetId(input.sheetId),
columnMapping: input.columnMapping,
syncFrequency: input.syncFrequency,
syncEnabled: input.syncEnabled,
updatedBy: actorId,
})
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId));
} else {
await db.insert(timeTrackingSmartsheetConfig).values({
brandId,
sheetId: extractSheetId(input.sheetId),
columnMapping: input.columnMapping,
syncFrequency: input.syncFrequency,
syncEnabled: input.syncEnabled,
createdBy: actorId,
updatedBy: actorId,
});
}
const fresh = await getTTSmartsheetConfig(brandId);
return { success: true, config: fresh };
});
}
// ── Test connection ────────────────────────────────────────────────────────
export async function testTTSmartsheetConnection(
brandId: string,
input?: { sheetId?: string; token?: string },
): Promise<TTTestConnectionResult> {
const perm = await requireManagePerms();
if (!perm.ok) return { success: false, error: perm.error };
return withBrand(brandId, async (db) => {
const [existing] = await db
.select()
.from(timeTrackingSmartsheetConfig)
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
.limit(1);
const sheetId = input?.sheetId ?? existing?.sheetId ?? "";
let plaintext = "";
if (input?.token && input.token.trim().length > 0) {
plaintext = input.token.trim();
} else {
// Cycle 7: token lives on the workspace, not on the per-feature
// config. Decrypt from there.
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok) {
if (ws.reason === "no_workspace") {
return { success: false, error: "No Smartsheet workbook connected yet — paste a token in the hub card." };
}
if (ws.reason === "connection_disabled") {
return { success: false, error: "Smartsheet connection is disabled — enable it in the hub card." };
}
return { success: false, error: ws.error };
}
plaintext = ws.token;
}
if (!sheetId) {
return { success: false, error: "sheetId is required" };
}
if (!plaintext) {
return { success: false, error: "token is required" };
}
try {
const parsed = extractSheetId(sheetId);
const meta = await getSheetMeta(parsed, plaintext);
return {
success: true,
sheetName: meta.name,
columnCount: meta.columns.length,
columns: meta.columns.map((c) => ({
id: c.id,
title: c.title,
type: c.type,
})),
};
} catch (err) {
// Funnel through sanitizeError to strip any token echoes from
// Smartsheet's WWW-Authenticate headers, mirroring the
// service-layer guarantee.
const raw =
err instanceof SmartsheetApiError
? err.message
: err instanceof Error
? err.message
: String(err);
return {
success: false,
error: sanitizeTestError(raw, plaintext),
};
}
});
}
// Strip any token echo from Smartsheet's error responses before
// returning to the client. The plaintext token is only used locally
// (decrypted for the API call) — never crosses the wire.
function sanitizeTestError(raw: string, plaintext: string): string {
if (!raw || !plaintext) return raw;
return raw.split(plaintext).join("[redacted-token]");
}
// ── Read: recent sync attempts ─────────────────────────────────────────────
export async function getTTSmartsheetRecent(
brandId: string,
limit = 20,
): Promise<TTSmartsheetRecentEntry[]> {
const perm = await requireManagePerms();
if (!perm.ok) return [];
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(timeTrackingSmartsheetSyncLog)
.where(eq(timeTrackingSmartsheetSyncLog.brandId, brandId))
.orderBy(desc(timeTrackingSmartsheetSyncLog.createdAt))
.limit(limit);
return rows.map((r) => ({
id: r.id,
logId: r.logId,
smartsheetRowId: r.smartsheetRowId,
action: r.action,
success: r.success,
error: r.error,
durationMs: r.durationMs,
createdAt: r.createdAt.toISOString(),
}));
});
}
// ── Read: queue summary ────────────────────────────────────────────────────
export async function getTTSmartsheetQueueSummary(
brandId: string,
): Promise<TTSmartsheetQueueSummary> {
const perm = await requireManagePerms();
if (!perm.ok)
return { pending: 0, syncing: 0, synced: 0, failed: 0 };
return withBrand(brandId, async (db) => {
const rows = await db
.select({
status: timeTrackingSmartsheetSyncQueue.status,
})
.from(timeTrackingSmartsheetSyncQueue)
.where(eq(timeTrackingSmartsheetSyncQueue.brandId, brandId));
return rows.reduce<TTSmartsheetQueueSummary>(
(acc, r) => {
acc[r.status] += 1;
return acc;
},
{ pending: 0, syncing: 0, synced: 0, failed: 0 },
);
});
}
// ── Disable (preserves token + config, turns off sync) ─────────────────────
export async function disableTTSmartsheet(
brandId: string,
): Promise<{ success: true } | { success: false; error: string }> {
const perm = await requireManagePerms();
if (!perm.ok) return { success: false, error: perm.error };
return withBrand(brandId, async (db) => {
await db
.update(timeTrackingSmartsheetConfig)
.set({ syncEnabled: false })
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId));
return { success: true };
});
}
+909
View File
@@ -0,0 +1,909 @@
/**
* Time Tracking — Timesheets server actions.
*
* Cycle 12 (Phase 2): per-worker pay-period timesheets with a Draft →
* Submitted → Approved/Locked workflow. Only `platform_admin` /
* `brand_admin` can unlock a locked timesheet, and that requires a
* mandatory audit note.
*
* Authorisation model:
* - platform_admin / brand_admin — full access across their brand
* - supervisor (field_workers.role = 'supervisor') — read + approve
* timesheets for assigned supervisees only
* - worker (field_workers.role IN ('worker','driver')) — can only
* read their own timesheets and submit (draft → submitted); cannot
* approve / unlock
*
* Concurrency:
* - All mutations run inside a single `withTx` block so the timesheet
* status + the audit row + the recompute RPC commit atomically.
*/
"use server";
import {
and,
desc,
eq,
gte,
inArray,
lte,
sql,
type SQL,
} from "drizzle-orm";
import { z } from "zod";
import { getAdminUser } from "@/lib/admin-permissions";
import { getPool, withTx } from "@/lib/db";
import { withBrand, withPlatformAdmin } from "@/db/client";
import {
timeTrackingLogs,
timeTrackingTimesheets,
timeTrackingSupervisorAssignments,
timeTrackingAuditLog,
type TimesheetStatus,
} from "@/db/schema/time-tracking";
import { fieldWorkers } from "@/db/schema/field-workers";
import {
logTimeTrackingEventInTx,
} from "@/lib/time-tracking-audit";
import { payPeriodForDate, toIsoDate } from "@/lib/time-tracking/period";
// ── Period math ────────────────────────────────────────────────────────────
// `payPeriodForDate` / `toIsoDate` live in `@/lib/time-tracking/period`
// so client components can use them too — server-action files
// ("use server") can only export async functions.
// ── Types ──────────────────────────────────────────────────────────────────
export type TimesheetEntry = {
id: string;
brand_id: string;
field_worker_id: string;
worker_name: string;
task_id: string | null;
task_name: string;
clock_in: string;
clock_out: string | null;
lunch_break_minutes: number;
notes: string | null;
submitted_via: string;
entry_kind: "clock" | "manual";
manual_reason: string | null;
gps_verified: boolean;
clock_in_lat: number | null;
clock_in_lng: number | null;
clock_in_accuracy_m: number | null;
clock_out_lat: number | null;
clock_out_lng: number | null;
clock_out_accuracy_m: number | null;
edited_at: string | null;
edited_by_label: string | null;
edit_reason: string | null;
total_minutes: number;
};
export type TimesheetSummary = {
id: string;
brand_id: string;
field_worker_id: string;
worker_name: string;
worker_role: string;
period_start: string;
period_end: string;
status: TimesheetStatus;
total_minutes: number;
regular_minutes: number;
overtime_minutes: number;
break_minutes: number;
entry_count: number;
daily_totals: Record<string, number>;
submitted_at: string | null;
submitted_notes: string | null;
reviewed_at: string | null;
reviewed_by_label: string | null;
reviewer_comment: string | null;
rejected_at: string | null;
rejection_reason: string | null;
locked_at: string | null;
unlocked_at: string | null;
unlock_audit_note: string | null;
updated_at: string;
};
export type AuditEntry = {
id: string;
actor_id: string | null;
actor_label: string;
actor_kind: string;
action: string;
entity_type: string;
entity_id: string | null;
details: Record<string, unknown>;
created_at: string;
};
// ── Auth helpers ───────────────────────────────────────────────────────────
type CallerKind = "platform_admin" | "brand_admin" | "supervisor" | "worker";
type CallerContext = {
adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>>;
kind: CallerKind;
brandId: string;
/** When kind === 'supervisor', the field_worker row id of the supervisor. */
supervisorFieldWorkerId: string | null;
};
/**
* Resolve the caller's role + brand context. Admins (platform/brand) are
* detected by `admin_users.role`. Supervisors are detected by matching
* `field_workers.role = 'supervisor'` for the admin's email.
*/
async function resolveCaller(brandId?: string): Promise<CallerContext | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
const resolvedBrandId = brandId ?? adminUser.brand_id ?? null;
if (!resolvedBrandId) return null;
if (adminUser.role === "platform_admin") {
return {
adminUser,
kind: "platform_admin",
brandId: resolvedBrandId,
supervisorFieldWorkerId: null,
};
}
if (adminUser.role === "brand_admin") {
return {
adminUser,
kind: "brand_admin",
brandId: resolvedBrandId,
supervisorFieldWorkerId: null,
};
}
// store_employee or other: lookup as supervisor via email match
return withPlatformAdmin(async (db) => {
const fw = await db
.select()
.from(fieldWorkers)
.where(
and(
eq(fieldWorkers.brandId, adminUser.brand_id ?? ""),
sql`LOWER(${fieldWorkers.name}) = LOWER(${adminUser.email ?? ""})`,
eq(fieldWorkers.role, "supervisor"),
),
)
.limit(1);
if (fw.length > 0) {
return {
adminUser,
kind: "supervisor" as const,
brandId: resolvedBrandId,
supervisorFieldWorkerId: fw[0].id,
};
}
return {
adminUser,
kind: "worker" as const,
brandId: resolvedBrandId,
supervisorFieldWorkerId: null,
};
});
}
// ── List timesheets ────────────────────────────────────────────────────────
export async function listTimesheets(
brandId: string,
options: {
status?: TimesheetStatus | "all";
workerId?: string;
periodStart?: string; // YYYY-MM-DD
periodEnd?: string;
limit?: number;
offset?: number;
} = {},
): Promise<{ rows: TimesheetSummary[]; total: number }> {
const caller = await resolveCaller(brandId);
if (!caller) return { rows: [], total: 0 };
return withBrand(caller.brandId, async (db) => {
const conds: SQL[] = [eq(timeTrackingTimesheets.brandId, caller.brandId)];
if (options.status && options.status !== "all") {
conds.push(
eq(timeTrackingTimesheets.status, options.status as TimesheetStatus),
);
}
if (options.workerId) {
conds.push(eq(timeTrackingTimesheets.fieldWorkerId, options.workerId));
}
if (options.periodStart) {
conds.push(gte(timeTrackingTimesheets.periodStart, options.periodStart));
}
if (options.periodEnd) {
conds.push(lte(timeTrackingTimesheets.periodEnd, options.periodEnd));
}
// Supervisor scoping: only their assigned supervisees
if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) {
const assigned = await db
.select({
id: timeTrackingSupervisorAssignments.superviseeFieldWorkerId,
})
.from(timeTrackingSupervisorAssignments)
.where(
eq(
timeTrackingSupervisorAssignments.supervisorFieldWorkerId,
caller.supervisorFieldWorkerId,
),
);
const ids = assigned.map((a) => a.id);
if (ids.length === 0) return { rows: [], total: 0 };
conds.push(inArray(timeTrackingTimesheets.fieldWorkerId, ids));
}
const limit = Math.min(options.limit ?? 100, 500);
const offset = Math.max(options.offset ?? 0, 0);
const rows = await db
.select({
id: timeTrackingTimesheets.id,
brand_id: timeTrackingTimesheets.brandId,
field_worker_id: timeTrackingTimesheets.fieldWorkerId,
worker_name: fieldWorkers.name,
worker_role: fieldWorkers.role,
period_start: timeTrackingTimesheets.periodStart,
period_end: timeTrackingTimesheets.periodEnd,
status: timeTrackingTimesheets.status,
total_minutes: timeTrackingTimesheets.totalMinutes,
regular_minutes: timeTrackingTimesheets.regularMinutes,
overtime_minutes: timeTrackingTimesheets.overtimeMinutes,
break_minutes: timeTrackingTimesheets.breakMinutes,
entry_count: timeTrackingTimesheets.entryCount,
daily_totals: timeTrackingTimesheets.dailyTotals,
submitted_at: timeTrackingTimesheets.submittedAt,
submitted_notes: timeTrackingTimesheets.submittedNotes,
reviewed_at: timeTrackingTimesheets.reviewedAt,
reviewer_comment: timeTrackingTimesheets.reviewerComment,
rejected_at: timeTrackingTimesheets.rejectedAt,
rejection_reason: timeTrackingTimesheets.rejectionReason,
locked_at: timeTrackingTimesheets.lockedAt,
unlocked_at: timeTrackingTimesheets.unlockedAt,
unlock_audit_note: timeTrackingTimesheets.unlockAuditNote,
updated_at: timeTrackingTimesheets.updatedAt,
})
.from(timeTrackingTimesheets)
.leftJoin(
fieldWorkers,
eq(fieldWorkers.id, timeTrackingTimesheets.fieldWorkerId),
)
.where(and(...conds))
.orderBy(desc(timeTrackingTimesheets.periodStart))
.limit(limit)
.offset(offset);
const totalRes = await db
.select({ count: sql<number>`COUNT(*)::int` })
.from(timeTrackingTimesheets)
.where(and(...conds));
return {
rows: rows.map((r) =>
rowToSummary({
...r,
daily_totals: (r.daily_totals ?? {}) as Record<string, number>,
reviewed_by_label: null,
}),
),
total: totalRes[0]?.count ?? 0,
};
});
}
function rowToSummary(r: {
id: string;
brand_id: string;
field_worker_id: string;
worker_name: string | null;
worker_role: string | null;
period_start: string;
period_end: string;
status: TimesheetStatus;
total_minutes: number;
regular_minutes: number;
overtime_minutes: number;
break_minutes: number;
entry_count: number;
daily_totals: Record<string, number> | null;
submitted_at: Date | null;
submitted_notes: string | null;
reviewed_at: Date | null;
reviewer_comment: string | null;
rejected_at: Date | null;
rejection_reason: string | null;
locked_at: Date | null;
unlocked_at: Date | null;
unlock_audit_note: string | null;
updated_at: Date;
reviewed_by_label: string | null;
}): TimesheetSummary {
return {
id: r.id,
brand_id: r.brand_id,
field_worker_id: r.field_worker_id,
worker_name: r.worker_name ?? "(unknown)",
worker_role: r.worker_role ?? "worker",
period_start: r.period_start,
period_end: r.period_end,
status: r.status,
total_minutes: r.total_minutes,
regular_minutes: r.regular_minutes,
overtime_minutes: r.overtime_minutes,
break_minutes: r.break_minutes,
entry_count: r.entry_count,
daily_totals: r.daily_totals ?? {},
submitted_at: r.submitted_at ? r.submitted_at.toISOString() : null,
submitted_notes: r.submitted_notes,
reviewed_at: r.reviewed_at ? r.reviewed_at.toISOString() : null,
reviewed_by_label: r.reviewed_by_label,
reviewer_comment: r.reviewer_comment,
rejected_at: r.rejected_at ? r.rejected_at.toISOString() : null,
rejection_reason: r.rejection_reason,
locked_at: r.locked_at ? r.locked_at.toISOString() : null,
unlocked_at: r.unlocked_at ? r.unlocked_at.toISOString() : null,
unlock_audit_note: r.unlock_audit_note,
updated_at: r.updated_at.toISOString(),
};
}
// ── Get a single timesheet (with entries + audit) ─────────────────────────
export async function getTimesheet(
timesheetId: string,
): Promise<{
summary: TimesheetSummary;
entries: TimesheetEntry[];
audit: AuditEntry[];
} | null> {
const caller = await resolveCaller();
if (!caller) return null;
return withBrand(caller.brandId, async (db) => {
const [row] = await db
.select({
id: timeTrackingTimesheets.id,
brand_id: timeTrackingTimesheets.brandId,
field_worker_id: timeTrackingTimesheets.fieldWorkerId,
worker_name: fieldWorkers.name,
worker_role: fieldWorkers.role,
period_start: timeTrackingTimesheets.periodStart,
period_end: timeTrackingTimesheets.periodEnd,
status: timeTrackingTimesheets.status,
total_minutes: timeTrackingTimesheets.totalMinutes,
regular_minutes: timeTrackingTimesheets.regularMinutes,
overtime_minutes: timeTrackingTimesheets.overtimeMinutes,
break_minutes: timeTrackingTimesheets.breakMinutes,
entry_count: timeTrackingTimesheets.entryCount,
daily_totals: timeTrackingTimesheets.dailyTotals,
submitted_at: timeTrackingTimesheets.submittedAt,
submitted_notes: timeTrackingTimesheets.submittedNotes,
reviewed_at: timeTrackingTimesheets.reviewedAt,
reviewer_comment: timeTrackingTimesheets.reviewerComment,
rejected_at: timeTrackingTimesheets.rejectedAt,
rejection_reason: timeTrackingTimesheets.rejectionReason,
locked_at: timeTrackingTimesheets.lockedAt,
unlocked_at: timeTrackingTimesheets.unlockedAt,
unlock_audit_note: timeTrackingTimesheets.unlockAuditNote,
updated_at: timeTrackingTimesheets.updatedAt,
})
.from(timeTrackingTimesheets)
.leftJoin(
fieldWorkers,
eq(fieldWorkers.id, timeTrackingTimesheets.fieldWorkerId),
)
.where(
and(
eq(timeTrackingTimesheets.id, timesheetId),
eq(timeTrackingTimesheets.brandId, caller.brandId),
),
)
.limit(1);
if (!row) return null;
// Auth scope check for supervisors
if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) {
const assigned = await db
.select({
id: timeTrackingSupervisorAssignments.superviseeFieldWorkerId,
})
.from(timeTrackingSupervisorAssignments)
.where(
and(
eq(
timeTrackingSupervisorAssignments.supervisorFieldWorkerId,
caller.supervisorFieldWorkerId,
),
eq(
timeTrackingSupervisorAssignments.superviseeFieldWorkerId,
row.field_worker_id,
),
),
)
.limit(1);
if (assigned.length === 0) return null;
}
// Entries
const entryRows = await db
.select({
id: timeTrackingLogs.id,
brand_id: timeTrackingLogs.brandId,
field_worker_id: timeTrackingLogs.fieldWorkerId,
worker_name: fieldWorkers.name,
task_id: timeTrackingLogs.taskId,
task_name: timeTrackingLogs.taskName,
clock_in: timeTrackingLogs.clockIn,
clock_out: timeTrackingLogs.clockOut,
lunch_break_minutes: timeTrackingLogs.lunchBreakMinutes,
notes: timeTrackingLogs.notes,
submitted_via: timeTrackingLogs.submittedVia,
entry_kind: timeTrackingLogs.entryKind,
manual_reason: timeTrackingLogs.manualReason,
gps_verified: timeTrackingLogs.gpsVerified,
clock_in_lat: timeTrackingLogs.clockInLat,
clock_in_lng: timeTrackingLogs.clockInLng,
clock_in_accuracy_m: timeTrackingLogs.clockInAccuracyM,
clock_out_lat: timeTrackingLogs.clockOutLat,
clock_out_lng: timeTrackingLogs.clockOutLng,
clock_out_accuracy_m: timeTrackingLogs.clockOutAccuracyM,
edited_at: timeTrackingLogs.editedAt,
edit_reason: timeTrackingLogs.editReason,
})
.from(timeTrackingLogs)
.leftJoin(
fieldWorkers,
eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId),
)
.where(
and(
eq(timeTrackingLogs.brandId, caller.brandId),
eq(timeTrackingLogs.fieldWorkerId, row.field_worker_id),
gte(timeTrackingLogs.clockIn, new Date(row.period_start)),
lte(timeTrackingLogs.clockIn, endOfDay(row.period_end)),
),
)
.orderBy(timeTrackingLogs.clockIn);
const entryIds = entryRows.map((e) => e.id);
const auditRows = entryIds.length
? await db
.select()
.from(timeTrackingAuditLog)
.where(
and(
eq(timeTrackingAuditLog.brandId, caller.brandId),
sql`(${timeTrackingAuditLog.entityType} = 'timesheet' AND ${timeTrackingAuditLog.entityId} = ${timesheetId}) OR (${timeTrackingAuditLog.entityType} = 'log' AND ${timeTrackingAuditLog.entityId} IN (${sql.join(entryIds.map((id) => sql`${id}`), sql`, `)}))`,
),
)
.orderBy(desc(timeTrackingAuditLog.createdAt))
.limit(200)
: [];
return {
summary: rowToSummary({
...row,
daily_totals: (row.daily_totals ?? {}) as Record<string, number>,
reviewed_by_label: null,
}),
entries: entryRows.map(
(e): TimesheetEntry => ({
id: e.id,
brand_id: e.brand_id,
field_worker_id: e.field_worker_id,
worker_name: e.worker_name ?? "(unknown)",
task_id: e.task_id,
task_name: e.task_name,
clock_in: e.clock_in.toISOString(),
clock_out: e.clock_out ? e.clock_out.toISOString() : null,
lunch_break_minutes: e.lunch_break_minutes,
notes: e.notes,
submitted_via: e.submitted_via,
entry_kind: e.entry_kind,
manual_reason: e.manual_reason,
gps_verified: e.gps_verified,
clock_in_lat: e.clock_in_lat,
clock_in_lng: e.clock_in_lng,
clock_in_accuracy_m: e.clock_in_accuracy_m,
clock_out_lat: e.clock_out_lat,
clock_out_lng: e.clock_out_lng,
clock_out_accuracy_m: e.clock_out_accuracy_m,
edited_at: e.edited_at ? e.edited_at.toISOString() : null,
edited_by_label: null,
edit_reason: e.edit_reason,
total_minutes: computeMinutes(
e.clock_in,
e.clock_out,
e.lunch_break_minutes,
),
}),
),
audit: auditRows.map((a) => ({
id: a.id,
actor_id: a.actorId,
actor_label: a.actorLabel,
actor_kind: a.actorKind,
action: a.action,
entity_type: a.entityType,
entity_id: a.entityId,
details: (a.details as Record<string, unknown>) ?? {},
created_at: a.createdAt.toISOString(),
})),
};
});
}
function endOfDay(iso: string): Date {
const d = new Date(iso);
d.setHours(23, 59, 59, 999);
return d;
}
function computeMinutes(
clockIn: Date,
clockOut: Date | null,
lunchMinutes: number,
): number {
if (!clockOut) return 0;
return Math.max(
0,
Math.round((clockOut.getTime() - clockIn.getTime()) / 60000) - lunchMinutes,
);
}
// ── Ensure the timesheet exists for a (worker, period) ────────────────────
export async function ensureTimesheetForPeriod(
brandId: string,
workerId: string,
periodStart: string,
periodEnd: string,
): Promise<string> {
const caller = await resolveCaller(brandId);
if (!caller) throw new Error("Not authenticated");
const pool = getPool();
const res = await pool.query<{ get_or_create_timesheet: string }>(
"SELECT get_or_create_timesheet($1::uuid, $2::uuid, $3::date, $4::date) AS get_or_create_timesheet",
[brandId, workerId, periodStart, periodEnd],
);
return res.rows[0]?.get_or_create_timesheet ?? "";
}
// ── Submit, approve, reject, unlock ────────────────────────────────────────
const SubmitSchema = z.object({
timesheetId: z.string().uuid(),
notes: z.string().max(1000).optional(),
});
export async function submitTimesheet(
timesheetId: string,
notes?: string,
): Promise<{ success: boolean; error?: string }> {
const caller = await resolveCaller();
if (!caller) return { success: false, error: "Not authenticated" };
const parsed = SubmitSchema.safeParse({ timesheetId, notes });
if (!parsed.success) return { success: false, error: "Invalid input" };
return withTx(async (client) => {
const ts = await client.query<{
brand_id: string;
field_worker_id: string;
status: string;
}>(
`SELECT brand_id, field_worker_id, status
FROM time_tracking_timesheets
WHERE id = $1
FOR UPDATE`,
[timesheetId],
);
if (ts.rows.length === 0) return { success: false, error: "Not found" };
const t = ts.rows[0];
if (t.status !== "draft" && t.status !== "rejected") {
return {
success: false,
error: `Cannot submit a timesheet in status '${t.status}'`,
};
}
await client.query(
`UPDATE time_tracking_timesheets
SET status = 'submitted',
submitted_at = NOW(),
submitted_by = $2,
submitted_notes = $3,
rejected_at = NULL,
rejected_by = NULL,
rejection_reason = NULL,
updated_at = NOW()
WHERE id = $1`,
[timesheetId, caller.adminUser.id, notes ?? null],
);
await logTimeTrackingEventInTx(client, {
brandId: t.brand_id,
actorId: caller.adminUser.id,
actorLabel:
caller.adminUser.display_name ?? caller.adminUser.email ?? "admin",
action: "timesheet.submit",
entityType: "timesheet",
entityId: timesheetId,
details: { notes: notes ?? null },
});
return { success: true };
});
}
const ApprovalSchema = z.object({
timesheetId: z.string().uuid(),
comment: z.string().max(2000).optional(),
});
export async function approveTimesheet(
timesheetId: string,
comment?: string,
): Promise<{ success: boolean; error?: string }> {
const caller = await resolveCaller();
if (!caller) return { success: false, error: "Not authenticated" };
if (
caller.kind !== "platform_admin" &&
caller.kind !== "brand_admin" &&
caller.kind !== "supervisor"
) {
return { success: false, error: "Not authorized" };
}
const parsed = ApprovalSchema.safeParse({ timesheetId, comment });
if (!parsed.success) return { success: false, error: "Invalid input" };
return withTx(async (client) => {
const ts = await client.query<{
brand_id: string;
field_worker_id: string;
status: string;
}>(
`SELECT brand_id, field_worker_id, status
FROM time_tracking_timesheets
WHERE id = $1
FOR UPDATE`,
[timesheetId],
);
if (ts.rows.length === 0) return { success: false, error: "Not found" };
const t = ts.rows[0];
// Supervisor must be assigned to this worker
if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) {
const ok = await client.query(
`SELECT 1 FROM time_tracking_supervisor_assignments
WHERE supervisor_field_worker_id = $1
AND supervisee_field_worker_id = $2
LIMIT 1`,
[caller.supervisorFieldWorkerId, t.field_worker_id],
);
if (ok.rows.length === 0)
return { success: false, error: "Not authorized for this worker" };
}
if (t.status !== "submitted") {
return {
success: false,
error: `Only submitted timesheets can be approved (status='${t.status}')`,
};
}
const now = new Date();
await client.query(
`UPDATE time_tracking_timesheets
SET status = 'approved',
reviewed_at = $2,
reviewed_by = $3,
reviewer_comment = $4,
locked_at = $2,
locked_by = $3,
updated_at = NOW()
WHERE id = $1`,
[timesheetId, now, caller.adminUser.id, comment ?? null],
);
await logTimeTrackingEventInTx(client, {
brandId: t.brand_id,
actorId: caller.adminUser.id,
actorLabel:
caller.adminUser.display_name ?? caller.adminUser.email ?? "admin",
action: "timesheet.approve",
entityType: "timesheet",
entityId: timesheetId,
details: { comment: comment ?? null },
});
// Recompute totals so the locked snapshot is accurate
await client.query("SELECT recompute_timesheet_totals($1)", [timesheetId]);
return { success: true };
});
}
const RejectSchema = z.object({
timesheetId: z.string().uuid(),
reason: z.string().min(3).max(2000),
});
export async function rejectTimesheet(
timesheetId: string,
reason: string,
): Promise<{ success: boolean; error?: string }> {
const caller = await resolveCaller();
if (!caller) return { success: false, error: "Not authenticated" };
if (
caller.kind !== "platform_admin" &&
caller.kind !== "brand_admin" &&
caller.kind !== "supervisor"
) {
return { success: false, error: "Not authorized" };
}
const parsed = RejectSchema.safeParse({ timesheetId, reason });
if (!parsed.success) return { success: false, error: "Reason is required" };
return withTx(async (client) => {
const ts = await client.query<{
brand_id: string;
field_worker_id: string;
status: string;
}>(
`SELECT brand_id, field_worker_id, status
FROM time_tracking_timesheets
WHERE id = $1
FOR UPDATE`,
[timesheetId],
);
if (ts.rows.length === 0) return { success: false, error: "Not found" };
const t = ts.rows[0];
if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) {
const ok = await client.query(
`SELECT 1 FROM time_tracking_supervisor_assignments
WHERE supervisor_field_worker_id = $1
AND supervisee_field_worker_id = $2
LIMIT 1`,
[caller.supervisorFieldWorkerId, t.field_worker_id],
);
if (ok.rows.length === 0)
return { success: false, error: "Not authorized" };
}
if (t.status !== "submitted") {
return {
success: false,
error: `Only submitted timesheets can be rejected (status='${t.status}')`,
};
}
await client.query(
`UPDATE time_tracking_timesheets
SET status = 'rejected',
rejected_at = NOW(),
rejected_by = $2,
rejection_reason = $3,
locked_at = NULL,
locked_by = NULL,
updated_at = NOW()
WHERE id = $1`,
[timesheetId, caller.adminUser.id, reason],
);
await logTimeTrackingEventInTx(client, {
brandId: t.brand_id,
actorId: caller.adminUser.id,
actorLabel:
caller.adminUser.display_name ?? caller.adminUser.email ?? "admin",
action: "timesheet.reject",
entityType: "timesheet",
entityId: timesheetId,
details: { reason },
});
return { success: true };
});
}
const UnlockSchema = z.object({
timesheetId: z.string().uuid(),
auditNote: z.string().min(5).max(2000),
});
export async function unlockTimesheet(
timesheetId: string,
auditNote: string,
): Promise<{ success: boolean; error?: string }> {
const caller = await resolveCaller();
if (!caller) return { success: false, error: "Not authenticated" };
// Only platform_admin or brand_admin can unlock a locked timesheet.
if (caller.kind !== "platform_admin" && caller.kind !== "brand_admin") {
return {
success: false,
error: "Only admins can unlock a locked timesheet",
};
}
const parsed = UnlockSchema.safeParse({ timesheetId, auditNote });
if (!parsed.success)
return {
success: false,
error: "An audit note of at least 5 characters is required",
};
return withTx(async (client) => {
const ts = await client.query<{
brand_id: string;
field_worker_id: string;
status: string;
locked_at: Date | null;
}>(
`SELECT brand_id, field_worker_id, status, locked_at
FROM time_tracking_timesheets
WHERE id = $1
FOR UPDATE`,
[timesheetId],
);
if (ts.rows.length === 0) return { success: false, error: "Not found" };
const t = ts.rows[0];
if (!t.locked_at) {
return { success: false, error: "Timesheet is not currently locked" };
}
await client.query(
`UPDATE time_tracking_timesheets
SET status = 'draft',
locked_at = NULL,
locked_by = NULL,
unlocked_at = NOW(),
unlocked_by = $2,
unlock_audit_note = $3,
updated_at = NOW()
WHERE id = $1`,
[timesheetId, caller.adminUser.id, auditNote],
);
await logTimeTrackingEventInTx(client, {
brandId: t.brand_id,
actorId: caller.adminUser.id,
actorLabel:
caller.adminUser.display_name ?? caller.adminUser.email ?? "admin",
action: "timesheet.unlock",
entityType: "timesheet",
entityId: timesheetId,
details: { audit_note: auditNote },
});
return { success: true };
});
}
// ── Helpers for client components ──────────────────────────────────────────
/** Compute and return the [start, end] of the current pay period for a brand. */
export async function getCurrentPayPeriod(
brandId: string,
): Promise<{ start: string; end: string }> {
const { getTimeTrackingSettings } = await import("./index");
const settings = await getTimeTrackingSettings(brandId);
const { start, end } = payPeriodForDate(new Date(), settings);
return { start: toIsoDate(start), end: toIsoDate(end) };
}
+63 -47
View File
@@ -20,12 +20,15 @@ import { and, desc, eq, sql } from "drizzle-orm";
import { withBrand } from "@/db/client";
import {
waterHeadgates,
waterIrrigators,
waterLogEntries,
type WaterHeadgate,
type WaterIrrigator,
type WaterLogEntry,
} from "@/db/schema/water-log";
import {
fieldWorkers,
type FieldWorker,
type FieldWorkerRole,
} from "@/db/schema/field-workers";
import { hashPin, generatePin } from "@/lib/water-log-pin";
import { logAuditEvent } from "@/lib/water-log-audit";
import {
@@ -52,7 +55,10 @@ export type AdminHeadgate = {
export type AdminIrrigator = {
id: string;
name: string;
role: "irrigator" | "water_admin";
// Workers are unified via `field_workers` (cycle 10). We still expose
// only the water-domain roles here so the admin UI doesn't surface
// time-only workers who can't interact with the water app.
role: Extract<FieldWorkerRole, "irrigator" | "water_admin">;
active: boolean;
language_preference: string;
phone: string | null;
@@ -142,7 +148,7 @@ function mapHeadgate(h: WaterHeadgate): AdminHeadgate {
};
}
function mapIrrigator(i: WaterIrrigator): AdminIrrigator {
function mapIrrigator(i: FieldWorker): AdminIrrigator {
return {
id: i.id,
name: i.name,
@@ -162,7 +168,7 @@ function mapEntry(
return {
id: e.id,
headgate_id: e.headgateId,
user_id: e.irrigatorId,
user_id: e.fieldWorkerId,
headgate_name: e.headgate_name,
user_name: e.user_name,
measurement: Number(e.measurement),
@@ -369,9 +375,15 @@ const auth = await requireWaterAdminPermission();
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterIrrigators)
.where(eq(waterIrrigators.brandId, brandId))
.orderBy(desc(waterIrrigators.createdAt));
.from(fieldWorkers)
.where(
and(
eq(fieldWorkers.brandId, brandId),
// Cycle 10: workers unified. Water admin UI only shows water-domain roles.
sql`${fieldWorkers.role} IN ('irrigator', 'water_admin')`,
),
)
.orderBy(desc(fieldWorkers.createdAt));
return rows.map(mapIrrigator);
});
}
@@ -405,12 +417,16 @@ const auth = await requireWaterAdminPermission();
const result = await withBrand(brandId, async (db) => {
const existing = await db
.select({ id: waterIrrigators.id })
.from(waterIrrigators)
.select({ id: fieldWorkers.id })
.from(fieldWorkers)
.where(
and(
eq(waterIrrigators.brandId, brandId),
eq(waterIrrigators.name, trimmed),
eq(fieldWorkers.brandId, brandId),
// Cycle 10: Partial unique index checks against `name` for
// water-domain roles only; mirror that check here to keep the
// "A user with that name already exists" UX identical.
sql`lower(${fieldWorkers.name}) = lower(${trimmed})
AND ${fieldWorkers.role} IN ('irrigator', 'water_admin')`,
),
)
.limit(1);
@@ -418,7 +434,7 @@ const auth = await requireWaterAdminPermission();
return { success: false as const, error: "A user with that name already exists" };
}
const [row] = await db
.insert(waterIrrigators)
.insert(fieldWorkers)
.values({
brandId,
name: trimmed,
@@ -455,7 +471,7 @@ return createWaterUser(brandId, name, "irrigator", language);
}
export async function updateWaterIrrigator(
irrigatorId: string,
fieldWorkerId: string,
name: string,
active: boolean,
language: string,
@@ -466,7 +482,7 @@ export async function updateWaterIrrigator(
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfIrrigator(irrigatorId);
const brand = await withPlatformAdminBrandOfFieldWorker(fieldWorkerId);
if (!brand) return { success: false, error: "User not found" };
return withBrand(brand, async (db) => {
const trimmed = name?.trim();
@@ -475,7 +491,7 @@ const auth = await requireWaterAdminPermission();
return { success: false, error: "Invalid language" };
}
const updated = await db
.update(waterIrrigators)
.update(fieldWorkers)
.set({
name: trimmed,
active,
@@ -484,7 +500,7 @@ const auth = await requireWaterAdminPermission();
phone: phone ?? null,
notes: notes ?? null,
})
.where(eq(waterIrrigators.id, irrigatorId))
.where(eq(fieldWorkers.id, fieldWorkerId))
.returning();
if (!updated[0]) return { success: false, error: "User not found" };
await logAuditEvent({
@@ -493,7 +509,7 @@ const auth = await requireWaterAdminPermission();
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: "update",
entityType: "user",
entityId: irrigatorId,
entityId: fieldWorkerId,
details: { name: trimmed, active, role, language },
});
return { success: true };
@@ -506,13 +522,13 @@ export async function deleteWaterUser(
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfIrrigator(userId);
const brand = await withPlatformAdminBrandOfFieldWorker(userId);
if (!brand) return { success: false, error: "User not found" };
return withBrand(brand, async (db) => {
const deleted = await db
.delete(waterIrrigators)
.where(eq(waterIrrigators.id, userId))
.returning({ id: waterIrrigators.id });
.delete(fieldWorkers)
.where(eq(fieldWorkers.id, userId))
.returning({ id: fieldWorkers.id });
if (!deleted[0]) return { success: false, error: "User not found" };
await logAuditEvent({
brandId: brand,
@@ -532,20 +548,20 @@ export async function resetWaterIrrigatorPin(
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfIrrigator(userId);
const brand = await withPlatformAdminBrandOfFieldWorker(userId);
if (!brand) return { success: false, error: "User not found" };
const pin = generatePin();
const pinHash = hashPin(pin);
return withBrand(brand, async (db) => {
const updated = await db
.update(waterIrrigators)
.update(fieldWorkers)
.set({ pinHash })
.where(eq(waterIrrigators.id, userId))
.returning({ id: waterIrrigators.id });
.where(eq(fieldWorkers.id, userId))
.returning({ id: fieldWorkers.id });
if (!updated[0]) return { success: false, error: "User not found" };
// Invalidate existing sessions for safety
await db.execute(
sql`DELETE FROM water_sessions WHERE irrigator_id = ${userId}`,
sql`DELETE FROM water_sessions WHERE field_worker_id = ${userId}`,
);
await logAuditEvent({
brandId: brand,
@@ -573,7 +589,7 @@ const auth = await requireWaterAdminPermission();
const rows = await db.execute<{
id: string;
headgate_id: string;
irrigator_id: string;
field_worker_id: string;
headgate_name: string;
user_name: string;
measurement: string;
@@ -587,15 +603,15 @@ const auth = await requireWaterAdminPermission();
logged_date: string | null;
}>(sql`
SELECT
e.id, e.headgate_id, e.irrigator_id,
e.id, e.headgate_id, e.field_worker_id,
h.name AS headgate_name,
i.name AS user_name,
fw.name AS user_name,
e.measurement::text, e.unit,
e.total_gallons::text, e.method, e.notes, e.submitted_via,
e.photo_url, e.logged_at, e.logged_date
FROM water_log_entries e
JOIN water_headgates h ON h.id = e.headgate_id
JOIN water_irrigators i ON i.id = e.irrigator_id
JOIN field_workers fw ON fw.id = e.field_worker_id
WHERE e.brand_id = ${brandId}
ORDER BY e.logged_at DESC
LIMIT ${safeLimit}
@@ -603,7 +619,7 @@ const auth = await requireWaterAdminPermission();
return rows.rows.map((r) => ({
id: r.id,
headgate_id: r.headgate_id,
user_id: r.irrigator_id,
user_id: r.field_worker_id,
headgate_name: r.headgate_name,
user_name: r.user_name,
measurement: Number(r.measurement),
@@ -629,7 +645,7 @@ const auth = await requireWaterAdminPermission();
const rows = await db.execute<{
id: string;
headgate_id: string;
irrigator_id: string;
field_worker_id: string;
headgate_name: string;
user_name: string;
measurement: string;
@@ -643,15 +659,15 @@ const auth = await requireWaterAdminPermission();
logged_date: string | null;
}>(sql`
SELECT
e.id, e.headgate_id, e.irrigator_id,
e.id, e.headgate_id, e.field_worker_id,
h.name AS headgate_name,
i.name AS user_name,
fw.name AS user_name,
e.measurement::text, e.unit,
e.total_gallons::text, e.method, e.notes, e.submitted_via,
e.photo_url, e.logged_at, e.logged_date
FROM water_log_entries e
JOIN water_headgates h ON h.id = e.headgate_id
JOIN water_irrigators i ON i.id = e.irrigator_id
JOIN field_workers fw ON fw.id = e.field_worker_id
WHERE e.id = ${entryId}
LIMIT 1
`);
@@ -660,7 +676,7 @@ const auth = await requireWaterAdminPermission();
return {
id: r.id,
headgate_id: r.headgate_id,
user_id: r.irrigator_id,
user_id: r.field_worker_id,
headgate_name: r.headgate_name,
user_name: r.user_name,
measurement: Number(r.measurement),
@@ -770,7 +786,7 @@ const auth = await requireWaterAdminPermission();
(
SELECT i.name
FROM water_log_entries e2
JOIN water_irrigators i ON i.id = e2.irrigator_id
JOIN field_workers fw ON fw.id = e2.field_worker_id
WHERE e2.headgate_id = h.id
ORDER BY e2.logged_at DESC
LIMIT 1
@@ -823,7 +839,7 @@ const auth = await requireWaterAdminPermission();
const recent = await db.execute<{
id: string;
headgate_id: string;
irrigator_id: string;
field_worker_id: string;
headgate_name: string;
user_name: string;
measurement: string;
@@ -837,15 +853,15 @@ const auth = await requireWaterAdminPermission();
logged_date: string | null;
}>(sql`
SELECT
e.id, e.headgate_id, e.irrigator_id,
e.id, e.headgate_id, e.field_worker_id,
h.name AS headgate_name,
i.name AS user_name,
fw.name AS user_name,
e.measurement::text, e.unit,
e.total_gallons::text, e.method, e.notes, e.submitted_via,
e.photo_url, e.logged_at, e.logged_date
FROM water_log_entries e
JOIN water_headgates h ON h.id = e.headgate_id
JOIN water_irrigators i ON i.id = e.irrigator_id
JOIN field_workers fw ON fw.id = e.field_worker_id
WHERE e.brand_id = ${brandId}
ORDER BY e.logged_at DESC
LIMIT 10
@@ -858,7 +874,7 @@ const auth = await requireWaterAdminPermission();
recent_entries: recent.rows.map((r) => ({
id: r.id,
headgate_id: r.headgate_id,
user_id: r.irrigator_id,
user_id: r.field_worker_id,
headgate_name: r.headgate_name,
user_name: r.user_name,
measurement: Number(r.measurement),
@@ -944,13 +960,13 @@ async function withPlatformAdminBrandOf(headgateId: string): Promise<string | nu
});
}
async function withPlatformAdminBrandOfIrrigator(userId: string): Promise<string | null> {
async function withPlatformAdminBrandOfFieldWorker(userId: string): Promise<string | null> {
const { withPlatformAdmin } = await import("@/db/client");
return withPlatformAdmin(async (db) => {
const rows = await db
.select({ brandId: waterIrrigators.brandId })
.from(waterIrrigators)
.where(eq(waterIrrigators.id, userId))
.select({ brandId: fieldWorkers.brandId })
.from(fieldWorkers)
.where(eq(fieldWorkers.id, userId))
.limit(1);
return rows[0]?.brandId ?? null;
});
+30 -23
View File
@@ -29,10 +29,8 @@
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { withPlatformAdmin } from "@/db/client";
import {
waterSessions,
waterIrrigators,
} from "@/db/schema/water-log";
import { waterSessions } from "@/db/schema/water-log";
import { fieldWorkers, type FieldWorkerRole } from "@/db/schema/field-workers";
import { getAdminUser } from "@/lib/admin-permissions";
import {
WL_SESSION_COOKIE,
@@ -42,11 +40,16 @@ import {
// ── Result types ────────────────────────────────────────────────────────
// `FieldSession` is now defined in `@/lib/water-log/session.ts` so the
// `FieldSession | { ok: false, error }` shape is shared with
// `FieldSession` is now defined in `@/lib/water-log/session-server.ts`
// so the `FieldSession | { ok: false, error }` shape is shared with
// `lib/water-log/pin-login.ts` (Phase 2) and the rest of the module
// surface. Re-export for back-compat with existing call sites.
export type { FieldSession };
// surface. Consumers should import as:
// `import type { FieldSession } from "@/lib/water-log/session-server";`
// The previous `export type { FieldSession }` re-export here caused
// Next.js 16.2's RSC bundler to fail with "Export FieldSession doesn't
// exist in target module" for pages that reference it indirectly
// through server actions — so we removed the re-export and let
// downstream consumers point at the source.
export type SiteAdminPermission =
| { ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> }
@@ -58,7 +61,8 @@ export type SiteAdminPermission =
* Field session gate /water (irrigator PIN).
*
* Reads the `wl_session` cookie, joins it to `water_sessions` and the
* linked `water_irrigators` row, and returns the user/brand/role.
* linked `field_workers` row, and returns the user/brand/role.
* (Cycle 10: workers are unified into `field_workers`.)
*
* Returns `{ ok: false, error }` for:
* - missing cookie "Not logged in"
@@ -79,12 +83,12 @@ export async function requireFieldSession(): Promise<FieldSessionResult> {
const rows = await db
.select({
session: waterSessions,
irrigator: waterIrrigators,
worker: fieldWorkers,
})
.from(waterSessions)
.innerJoin(
waterIrrigators,
eq(waterIrrigators.id, waterSessions.irrigatorId),
fieldWorkers,
eq(fieldWorkers.id, waterSessions.fieldWorkerId),
)
.where(eq(waterSessions.id, sessionId))
.limit(1);
@@ -99,15 +103,18 @@ export async function requireFieldSession(): Promise<FieldSessionResult> {
}
return { ok: false as const, error: "Session expired" };
}
if (!row.irrigator.active) {
if (!row.worker.active) {
return { ok: false as const, error: "User is inactive" };
}
return {
ok: true as const,
userId: row.irrigator.id,
brandId: row.irrigator.brandId,
role:
(row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator",
userId: row.worker.id,
brandId: row.worker.brandId,
// Cycle 10: workers unified, but a row reached via the water_sessions
// FK is by construction a water-domain worker (`irrigator` or
// `water_admin`). Narrow at the join boundary so FieldSessionAuthed
// remains a tight union.
role: row.worker.role as "irrigator" | "water_admin",
};
});
}
@@ -121,18 +128,18 @@ export async function getFieldSessionUser(): Promise<{
userId: string;
name: string;
brandId: string;
role: "irrigator" | "water_admin";
role: FieldWorkerRole;
} | null> {
const s = await requireFieldSession();
if (!s.ok) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
name: waterIrrigators.name,
role: waterIrrigators.role,
name: fieldWorkers.name,
role: fieldWorkers.role,
})
.from(waterIrrigators)
.where(eq(waterIrrigators.id, s.userId))
.from(fieldWorkers)
.where(eq(fieldWorkers.id, s.userId))
.limit(1);
const row = rows[0];
if (!row) return null;
@@ -140,7 +147,7 @@ export async function getFieldSessionUser(): Promise<{
userId: s.userId,
name: row.name,
brandId: s.brandId,
role: (row.role as "irrigator" | "water_admin") ?? "irrigator",
role: row.role,
};
});
}
+20 -13
View File
@@ -23,19 +23,19 @@ import { and, desc, eq, gte, sql } from "drizzle-orm";
import { withBrand, withPlatformAdmin } from "@/db/client";
import {
waterHeadgates,
waterIrrigators,
waterSessions,
waterLogEntries,
waterAdminSessions,
} from "@/db/schema/water-log";
import { fieldWorkers, type FieldWorkerRole } from "@/db/schema/field-workers";
import { verifyPin, validatePin } from "@/lib/water-log-pin";
import { logAlert } from "@/lib/water-log-audit";
import {
requireFieldSession,
type FieldSession,
} from "@/actions/water-log/auth";
import {
WL_SESSION_COOKIE,
type FieldSession,
clearSessionCookie,
readSessionCookie,
setSessionCookie,
@@ -45,7 +45,11 @@ import { triggerSyncForEntry } from "@/actions/water-log/smartsheet";
// Re-export so existing call sites that import from
// `@/actions/water-log/field` keep working.
export type { FieldSession };
// Note: the re-export was moved to `@/lib/water-log/session-server`
// in commit context; consumers that need `FieldSession` as a type
// should now import it directly from `@/lib/water-log/session-server`.
// This avoids Next.js 16.2's RSC bundler bug that treats type-only
// re-exports through server-action wrappers as runtime exports.
// Field sessions last 8h — a working day in the field. Long enough
// that a single sign-in covers a morning shift + afternoon shift.
@@ -85,7 +89,7 @@ type VerifyPinResult =
success: true;
user_id: string;
name: string;
role: "irrigator" | "water_admin";
role: FieldWorkerRole;
session_id: string;
lang: string;
}
@@ -163,13 +167,16 @@ export async function verifyWaterPin(
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
// Look the irrigator up across all brands (PIN is the only credential).
// Look the worker up across all brands (PIN is the only credential).
// Cycle 10: scans `field_workers` (the unified worker table from
// `db/schema/field-workers.ts`). Both water and time workers live here
// now; the role determines what they can do (water_admin vs worker).
const lookup = await withPlatformAdmin(async (db) => {
const rows = await db
.select()
.from(waterIrrigators)
.where(eq(waterIrrigators.active, true))
.orderBy(waterIrrigators.name);
.from(fieldWorkers)
.where(eq(fieldWorkers.active, true))
.orderBy(fieldWorkers.name);
return rows;
});
@@ -203,16 +210,16 @@ export async function verifyWaterPin(
const [inserted] = await db
.insert(waterSessions)
.values({
irrigatorId: match.id,
fieldWorkerId: match.id,
expiresAt,
})
.returning({ id: waterSessions.id });
if (!inserted) throw new Error("Failed to create session");
await db
.update(waterIrrigators)
.update(fieldWorkers)
.set({ lastUsedAt: new Date() })
.where(eq(waterIrrigators.id, match.id));
.where(eq(fieldWorkers.id, match.id));
return inserted.id;
});
@@ -227,7 +234,7 @@ export async function verifyWaterPin(
success: true,
user_id: match.id,
name: match.name,
role: (match.role as "irrigator" | "water_admin") ?? "irrigator",
role: match.role,
session_id: sessionId,
lang: match.languagePreference,
};
@@ -292,7 +299,7 @@ export async function submitWaterEntry(
.values({
brandId: session.brandId,
headgateId,
irrigatorId: session.userId,
fieldWorkerId: session.userId,
measurement: measurement.toString(),
unit,
totalGallons: totalGallons?.toString() ?? null,
+69 -92
View File
@@ -3,6 +3,11 @@
/**
* Water Log Smartsheet integration server actions.
*
* Cycle 7: per-feature config no longer holds the API token. The
* encrypted token lives in `smartsheet_workspace` (one row per brand);
* this action reads it via `resolveWorkspaceToken` and only manages
* the per-feature sheet_id + column_mapping + frequency.
*
* Surface for the `/admin/water-log/settings` UI and the entry-insert
* hook in `field.ts`. Permission model mirrors the rest of the water
* log module: only admins with `can_manage_water_log` (or
@@ -10,10 +15,9 @@
*
* Token handling:
* - The plaintext token NEVER crosses the server-action boundary.
* - Save actions accept a token via `input.token` only when the user
* types a new one. An empty token means "keep the saved one".
* - Read actions return `maskedToken` ("••••abcd") and never the
* plaintext.
* - `resolveWorkspaceToken` decrypts it locally for API calls.
* - The masked preview is fetched from the workspace (not the
* per-feature config).
*/
import { desc, eq, and, gte, inArray } from "drizzle-orm";
import { withBrand } from "@/db/client";
@@ -28,7 +32,8 @@ import {
type SmartsheetColumnMapping,
type SmartsheetFrequency,
} from "@/db/schema/water-log";
import { decryptToken, encryptToken, maskToken } from "@/lib/crypto";
import { resolveWorkspaceToken } from "@/services/workspace-token";
import { getSmartsheetWorkspace } from "@/actions/smartsheet/workspace";
import { extractSheetId, getSheetMeta, SmartsheetApiError } from "@/lib/smartsheet";
import { syncEntryToSmartsheet, drainSyncQueue } from "@/services/smartsheet-sync";
import { getAdminUser } from "@/lib/admin-permissions";
@@ -63,8 +68,9 @@ export type SmartsheetSyncLogEntry = {
export type SaveSmartsheetConfigInput = {
sheetId: string;
/** Plaintext token. Empty string = keep the saved token. */
token: string;
/** Cycle 7: token is owned by the workspace; this field is kept for
* backward compat with the existing UI but ignored on save. */
token?: string;
syncEnabled: boolean;
syncFrequency: SmartsheetFrequency;
columnMapping: SmartsheetColumnMapping;
@@ -106,6 +112,10 @@ export async function getSmartsheetConfig(
.where(eq(waterSmartsheetConfig.brandId, brandId))
.limit(1);
const config = rows[0];
// Pull the masked token + hasToken from the workspace (Cycle 7).
// If there's no workspace yet, maskedToken is null and hasToken
// is false — the UI shows "Connect a workbook" in the hub card.
const workspace = await getSmartsheetWorkspace(brandId);
if (!config) {
return {
configured: false,
@@ -113,39 +123,23 @@ export async function getSmartsheetConfig(
syncEnabled: false,
syncFrequency: "hourly",
columnMapping: null,
maskedToken: null,
maskedToken: workspace.maskedToken,
lastSyncAt: null,
lastSyncError: null,
hasToken: false,
hasToken: workspace.hasToken,
updatedAt: null,
};
}
// Decrypt just to compute the masked preview — never return the
// plaintext. If decryption fails (key rotated), we still report
// `hasToken: true` so the admin knows to re-enter it.
let masked: string | null = "••••";
let hasToken = true;
try {
const plain = decryptToken({
cipher: config.encryptedApiToken,
iv: config.tokenIv,
authTag: config.tokenAuthTag,
});
masked = maskToken(plain);
} catch {
hasToken = false;
masked = null;
}
return {
configured: true,
sheetId: config.sheetId,
syncEnabled: config.syncEnabled,
syncFrequency: config.syncFrequency,
columnMapping: config.columnMapping,
maskedToken: masked,
maskedToken: workspace.maskedToken,
lastSyncAt: config.lastSyncAt?.toISOString() ?? null,
lastSyncError: config.lastSyncError,
hasToken,
hasToken: workspace.hasToken,
updatedAt: config.updatedAt.toISOString(),
};
});
@@ -204,12 +198,23 @@ export async function saveSmartsheetConfig(
if (!mappingResult.ok) {
return { success: false, error: mappingResult.error };
}
// The token: empty means "keep existing" (we won't touch the
// encrypted columns). Non-empty means we re-encrypt and replace.
const tokenTrimmed = input.token?.trim() ?? "";
// Cycle 7: the API token is owned by the workspace, not by this
// per-feature config. Refuse to save a sheet mapping if no
// workbook is connected — the customer would have nothing to
// authenticate against at sync time.
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok && ws.reason !== "connection_disabled") {
return {
success: false,
error:
ws.reason === "no_workspace"
? "Connect the Smartsheet workbook first (hub card above)."
: "Workspace token is unreadable — re-paste it in the hub card.",
};
}
return withBrand(brandId, async (db) => {
// ── Load existing to know if we have a token already ──
const existingRows = await db
.select()
.from(waterSmartsheetConfig)
@@ -217,20 +222,6 @@ export async function saveSmartsheetConfig(
.limit(1);
const existing = existingRows[0];
if (!existing && tokenTrimmed.length === 0) {
return {
success: false,
error: "API token is required for first-time setup",
};
}
if (existing && tokenTrimmed.length === 0 && !existing.encryptedApiToken) {
return {
success: false,
error: "API token is required (none on file)",
};
}
// ── Build the upsert payload ──
const updateValues: Partial<typeof waterSmartsheetConfig.$inferInsert> = {
sheetId: normalizedSheetId,
syncEnabled: input.syncEnabled,
@@ -238,34 +229,23 @@ export async function saveSmartsheetConfig(
columnMapping: input.columnMapping,
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
};
if (tokenTrimmed.length > 0) {
const enc = encryptToken(tokenTrimmed);
updateValues.encryptedApiToken = enc.cipher;
updateValues.tokenIv = enc.iv;
updateValues.tokenAuthTag = enc.authTag;
}
// Upsert.
const [upserted] = await db
.insert(waterSmartsheetConfig)
.values({
if (existing) {
await db
.update(waterSmartsheetConfig)
.set(updateValues)
.where(eq(waterSmartsheetConfig.brandId, brandId));
} else {
await db.insert(waterSmartsheetConfig).values({
brandId,
sheetId: normalizedSheetId,
encryptedApiToken: existing?.encryptedApiToken ?? Buffer.from([]),
tokenIv: existing?.tokenIv ?? Buffer.from([]),
tokenAuthTag: existing?.tokenAuthTag ?? Buffer.from([]),
columnMapping: input.columnMapping,
syncEnabled: input.syncEnabled,
syncFrequency: input.syncFrequency,
createdBy: existing?.createdBy ?? auth.adminUser.email ?? auth.adminUser.id ?? null,
createdBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
})
.onConflictDoUpdate({
target: waterSmartsheetConfig.brandId,
set: updateValues,
})
.returning();
if (!upserted) return { success: false, error: "Save failed" };
});
}
await logAuditEvent({
brandId,
@@ -278,11 +258,9 @@ export async function saveSmartsheetConfig(
sheetId: normalizedSheetId,
syncEnabled: input.syncEnabled,
syncFrequency: input.syncFrequency,
tokenRotated: tokenTrimmed.length > 0,
},
});
// Re-read for a clean response.
return {
success: true,
config: await getSmartsheetConfig(brandId),
@@ -326,12 +304,17 @@ export async function deleteSmartsheetConfig(
}
/**
* Test the Smartsheet connection without saving.
* Test the Smartsheet connection against a sheet ID, using the
* workspace's saved token.
*
* - If `token` is non-empty, it's used directly (the typical flow
* for the "Test Connection" button on a fresh form).
* - If `token` is empty and a token is saved, decrypt + test that.
* - Returns the sheet name + columns on success.
* - If `token` is non-empty, it's used directly (test the hub card
* before saving).
* - If `token` is empty, the workspace token is decrypted and used.
*
* Cycle 7: the token is no longer saved per-feature it's the
* workspace's job. This action exists for the per-feature "Test
* Connection" button that re-uses the same UI affordance; the
* underlying token always comes from `smartsheet_workspace`.
*
* Does NOT throw on API errors returns a structured failure so the
* UI can show a helpful message.
@@ -353,29 +336,23 @@ export async function testSmartsheetConnection(
return { success: false, error: (err as Error).message };
}
// Resolve the token: explicit > saved > error.
// Cycle 7: resolve the token from the workspace, not from the
// per-feature config. If `tokenInput` is provided (the user is
// pasting a fresh token for a Test-Connection-from-form flow),
// honor it. Otherwise decrypt the saved workspace token.
let token = tokenInput?.trim() ?? "";
if (!token) {
const saved = await withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterSmartsheetConfig)
.where(eq(waterSmartsheetConfig.brandId, brandId))
.limit(1);
return rows[0] ?? null;
});
if (!saved) {
return { success: false, error: "No token saved yet — paste one above to test." };
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok) {
if (ws.reason === "no_workspace") {
return { success: false, error: "No Smartsheet workbook connected yet — paste a token in the hub card above." };
}
try {
token = decryptToken({
cipher: saved.encryptedApiToken,
iv: saved.tokenIv,
authTag: saved.tokenAuthTag,
});
} catch {
return { success: false, error: "Saved token cannot be decrypted (key was rotated). Paste a fresh token to test." };
if (ws.reason === "connection_disabled") {
return { success: false, error: "Smartsheet connection is disabled — enable it in the hub card." };
}
return { success: false, error: ws.error };
}
token = ws.token;
}
try {
@@ -0,0 +1,89 @@
/**
* /admin/time-tracking/approvals
*
* Phase 2 supervisor / admin queue of submitted timesheets. One-click
* approve / reject. Mirrors the "needs review" pattern from the
* orders and stops modules so reviewers don't context-switch.
*/
import { redirect } from "next/navigation";
import Link from "next/link";
import { getAdminUser } from "@/lib/admin-permissions";
import { listTimesheets } from "@/actions/time-tracking/timesheets";
import { PageHeader } from "@/components/admin/design-system";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export const dynamic = "force-dynamic";
export default async function ApprovalsPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const brandId = adminUser.brand_id ?? TUXEDO_BRAND_ID;
const { rows } = await listTimesheets(brandId, {
status: "submitted",
limit: 100,
});
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<PageHeader
title="Approval queue"
subtitle={`${rows.length} timesheet${rows.length === 1 ? "" : "s"} pending review`}
className="px-6 pt-6"
/>
<div className="px-6 pb-8">
{rows.length === 0 ? (
<div className="rounded-2xl border border-dashed border-zinc-300 bg-white p-12 text-center text-sm text-zinc-500">
🎉 Nothing to review right now.
</div>
) : (
<ul className="space-y-2">
{rows.map((r) => (
<li key={r.id}>
<Link
href={`/admin/time-tracking/timesheets/${r.id}`}
className="flex items-center justify-between gap-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 hover:bg-amber-100"
>
<div>
<div className="font-medium text-zinc-900">
{r.worker_name}
<span className="ml-2 text-xs text-zinc-500">
({r.worker_role})
</span>
</div>
<div className="text-xs text-zinc-600">
{r.period_start} {r.period_end}
{r.submitted_notes && (
<span className="ml-2 italic">&ldquo;{r.submitted_notes}&rdquo;</span>
)}
</div>
</div>
<div className="text-right text-sm">
<div className="font-medium tabular-nums text-zinc-900">
{Math.floor(r.total_minutes / 60)}h {r.total_minutes % 60}m
</div>
{r.overtime_minutes > 0 && (
<div className="text-xs text-amber-700">
+ {Math.floor(r.overtime_minutes / 60)}h {r.overtime_minutes % 60}m OT
</div>
)}
<div className="text-xs text-zinc-500">
Submitted{" "}
{r.submitted_at
? new Date(r.submitted_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
: ""}
</div>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
/**
* /admin/time-tracking/audit
*
* Phase 2 read-only audit trail viewer for admins. Shows every
* mutating action on a timesheet, its entries, or the related
* entities, ordered most-recent-first.
*/
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import { timeTrackingAuditLog } from "@/db/schema/time-tracking";
import { PageHeader } from "@/components/admin/design-system";
import { desc, eq, sql } from "drizzle-orm";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export const dynamic = "force-dynamic";
export default async function AuditPage({
searchParams,
}: {
searchParams: Promise<{ action?: string; entity?: string }>;
}) {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
if (adminUser.role !== "platform_admin" && adminUser.role !== "brand_admin") {
redirect("/admin/time-tracking");
}
const brandId = adminUser.brand_id ?? TUXEDO_BRAND_ID;
const sp = await searchParams;
const rows = await withBrand(brandId, async (db) => {
const conds = [eq(timeTrackingAuditLog.brandId, brandId)];
if (sp.action) conds.push(eq(timeTrackingAuditLog.action, sp.action));
if (sp.entity) conds.push(eq(timeTrackingAuditLog.entityType, sp.entity));
return db
.select({
id: timeTrackingAuditLog.id,
actor_label: timeTrackingAuditLog.actorLabel,
actor_kind: timeTrackingAuditLog.actorKind,
action: timeTrackingAuditLog.action,
entity_type: timeTrackingAuditLog.entityType,
entity_id: timeTrackingAuditLog.entityId,
details: timeTrackingAuditLog.details,
created_at: timeTrackingAuditLog.createdAt,
})
.from(timeTrackingAuditLog)
.where(sql`1=1`)
.orderBy(desc(timeTrackingAuditLog.createdAt))
.limit(500);
});
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<PageHeader
title="Audit trail"
subtitle={`Last ${rows.length} events`}
className="px-6 pt-6"
/>
<div className="px-6 pb-8">
<form className="mb-4 flex flex-wrap items-end gap-3 rounded-2xl border border-zinc-200 bg-white p-4">
<label className="flex flex-col gap-1">
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
Action
</span>
<input
name="action"
defaultValue={sp.action ?? ""}
placeholder="e.g. timesheet.approve"
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
Entity type
</span>
<select
name="entity"
defaultValue={sp.entity ?? ""}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
>
<option value="">All</option>
<option value="timesheet">timesheet</option>
<option value="log">log</option>
<option value="worker">worker</option>
</select>
</label>
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white"
>
Filter
</button>
</form>
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-left text-[11px] font-bold uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-3">When</th>
<th className="px-4 py-3">Action</th>
<th className="px-4 py-3">Actor</th>
<th className="px-4 py-3">Entity</th>
<th className="px-4 py-3">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.map((r) => (
<tr key={r.id}>
<td className="px-4 py-3 whitespace-nowrap text-xs text-zinc-500">
{new Date(r.created_at).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
</td>
<td className="px-4 py-3">
<span className="font-mono text-[11px] uppercase tracking-wider text-zinc-700">
{r.action}
</span>
</td>
<td className="px-4 py-3 text-zinc-900">
{r.actor_label}
<span className="ml-1 text-xs text-zinc-500">
({r.actor_kind})
</span>
</td>
<td className="px-4 py-3 text-xs text-zinc-500">
{r.entity_type}
{r.entity_id && (
<span className="ml-1 font-mono">{r.entity_id.slice(0, 8)}</span>
)}
</td>
<td className="px-4 py-3">
<pre className="max-w-md overflow-x-auto rounded bg-zinc-50 px-2 py-1 text-[11px] text-zinc-600">
{JSON.stringify(r.details, null, 0)}
</pre>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
+58 -10
View File
@@ -1,40 +1,88 @@
import Link from "next/link";
import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel";
import { FleetRouteSummary } from "@/components/time-tracking/admin/DailyRouteSummary";
import { getAdminUser } from "@/lib/admin-permissions";
import { redirect } from "next/navigation";
import { PageHeader } from "@/components/admin/design-system";
import { getFleetRouteSummary } from "@/actions/time-tracking/route-summary";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
export const dynamic = "force-dynamic";
export default async function AdminTimeTrackingPage() {
const TABS = [
{ href: "/admin/time-tracking", label: "Overview" },
{ href: "/admin/time-tracking/timesheets", label: "Timesheets" },
{ href: "/admin/time-tracking/approvals", label: "Approvals" },
{ href: "/admin/time-tracking/audit", label: "Audit" },
];
export default async function AdminTimeTrackingPage({
searchParams,
}: {
searchParams: Promise<{ tab?: string }>;
}) {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
if (!adminUser) {
redirect("/login");
}
const isPlatformAdmin = adminUser?.role === "platform_admin";
const isTuxedoAdmin = adminUser?.role === "brand_admin" && adminUser?.brand_id === TUXEDO_BRAND_ID;
const isIRDAdmin = adminUser?.role === "brand_admin" && adminUser?.brand_id === IRD_BRAND_ID;
const isPlatformAdmin = adminUser.role === "platform_admin";
const isTuxedoAdmin =
adminUser.role === "brand_admin" && adminUser.brand_id === TUXEDO_BRAND_ID;
const isIRDAdmin =
adminUser.role === "brand_admin" && adminUser.brand_id === IRD_BRAND_ID;
if (!isPlatformAdmin && !isTuxedoAdmin && !isIRDAdmin) {
redirect("/admin/pickup");
}
const effectiveBrandId = adminUser?.brand_id ?? TUXEDO_BRAND_ID;
const effectiveBrandId = adminUser.brand_id ?? TUXEDO_BRAND_ID;
const sp = await searchParams;
const activeTab = sp.tab ?? "overview";
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<PageHeader
title="Time Tracking"
subtitle="Manage workers, tasks, and time logs"
subtitle="Manage workers, tasks, timesheets, and approvals"
className="px-6 pt-6"
/>
<div className="px-6 pb-8">
{/* Tab nav */}
<nav className="mb-6 flex gap-1 rounded-xl border border-zinc-200 bg-white p-1">
{TABS.map((t) => {
const active =
t.href === "/admin/time-tracking"
? activeTab === "overview" || t.href === `/admin/time-tracking${activeTab === "overview" ? "" : ""}`
: activeTab && t.href.endsWith(activeTab);
return (
<Link
key={t.href}
href={t.href}
className={`flex-1 rounded-lg px-4 py-2 text-center text-sm font-medium transition-colors ${
active
? "bg-zinc-900 text-white"
: "text-zinc-600 hover:bg-zinc-100"
}`}
>
{t.label}
</Link>
);
})}
</nav>
<TimeTrackingAdminPanel brandId={effectiveBrandId} />
<div className="mt-6">
<FleetRouteSummaryClient brandId={effectiveBrandId} />
</div>
</div>
</div>
);
}
async function FleetRouteSummaryClient({ brandId }: { brandId: string }) {
const today = new Date().toISOString().slice(0, 10);
const rows = await getFleetRouteSummary(brandId, today);
return <FleetRouteSummary brandId={brandId} rows={rows} date={today} />;
}
@@ -0,0 +1,80 @@
/**
* /admin/time-tracking/timesheets/[id]
*
* Phase 2 single timesheet view. Shows the period totals, every
* entry (clock-in / manual), GPS pin status, audit trail, and the
* approval/lock controls.
*
* Workflow buttons rendered conditionally on the caller's role:
* - submit: worker / supervisor when status = draft | rejected
* - approve: supervisor / admin when status = submitted
* - reject: supervisor / admin when status = submitted
* - unlock: admin only, when status = approved
* - manual entry: admin / supervisor, when status != approved
*/
import { redirect, notFound } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import {
getTimesheet,
getCurrentPayPeriod,
ensureTimesheetForPeriod,
} from "@/actions/time-tracking/timesheets";
import { getTimeTrackingWorkers } from "@/actions/time-tracking";
import TimesheetDetail from "@/components/time-tracking/admin/TimesheetDetail";
import { PageHeader } from "@/components/admin/design-system";
export const dynamic = "force-dynamic";
export default async function TimesheetDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const { id } = await params;
// "current" pseudo-id — fetch or create the current pay period for the
// admin's user. Useful as a shortcut from the dashboard.
if (id === "current") {
const brandId = adminUser.brand_id ?? "";
if (!brandId) redirect("/admin/time-tracking/timesheets");
const period = await getCurrentPayPeriod(brandId);
const me = adminUser.email;
const workers = await getTimeTrackingWorkers(brandId).catch(() => []);
const mine = workers.find((w) => w.name.toLowerCase() === (me ?? "").toLowerCase());
if (!mine) redirect("/admin/time-tracking/timesheets");
const tid = await ensureTimesheetForPeriod(brandId, mine.id, period.start, period.end);
redirect(`/admin/time-tracking/timesheets/${tid}`);
}
const data = await getTimesheet(id);
if (!data) notFound();
const isAdmin =
adminUser.role === "platform_admin" || adminUser.role === "brand_admin";
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<PageHeader
title={`Timesheet — ${data.summary.worker_name}`}
subtitle={`${data.summary.period_start}${data.summary.period_end}`}
className="px-6 pt-6"
/>
<div className="px-6 pb-8">
<TimesheetDetail
summary={data.summary}
entries={data.entries}
audit={data.audit}
caller={{
id: adminUser.id,
role: adminUser.role,
display_name: adminUser.display_name,
email: adminUser.email,
isAdmin,
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
/**
* /admin/time-tracking/timesheets
*
* Phase 2 list of all timesheets for the brand. Filters by status,
* worker, period. Admins see everything; supervisors see only their
* assigned crew.
*/
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { listTimesheets, type TimesheetSummary } from "@/actions/time-tracking/timesheets";
import { getTimeTrackingWorkers } from "@/actions/time-tracking";
import TimesheetsList from "@/components/time-tracking/admin/TimesheetsList";
import { PageHeader } from "@/components/admin/design-system";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
export const dynamic = "force-dynamic";
export default async function TimesheetsPage({
searchParams,
}: {
searchParams: Promise<{
status?: string;
workerId?: string;
periodStart?: string;
periodEnd?: string;
}>;
}) {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const sp = await searchParams;
const brandId = adminUser.brand_id ?? TUXEDO_BRAND_ID;
const status =
sp.status === "draft" ||
sp.status === "submitted" ||
sp.status === "approved" ||
sp.status === "rejected"
? sp.status
: "all";
const { rows, total } = await listTimesheets(brandId, {
status,
workerId: sp.workerId,
periodStart: sp.periodStart,
periodEnd: sp.periodEnd,
limit: 200,
});
const workers = await getTimeTrackingWorkers(brandId).catch(() => []);
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<PageHeader
title="Timesheets"
subtitle={`${total} timesheet${total === 1 ? "" : "s"}`}
className="px-6 pt-6"
/>
<div className="px-6 pb-8">
<TimesheetsList
rows={rows as TimesheetSummary[]}
total={total}
workers={workers.map((w) => ({ id: w.id, name: w.name, role: w.role }))}
filters={{
status,
workerId: sp.workerId,
periodStart: sp.periodStart,
periodEnd: sp.periodEnd,
}}
/>
</div>
</div>
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import WaterLogAdminPanel from "@/components/admin/WaterLogAdminPanel";
import WaterLogShell from "@/components/admin/water-log/Shell";
import { getAdminUser } from "@/lib/admin-permissions";
import { getWaterIrrigators, getWaterHeadgatesAdmin, getWaterEntries } from "@/actions/water-log/admin";
import { redirect } from "next/navigation";
@@ -82,7 +82,7 @@ export default async function AdminWaterLogPage() {
className="px-6 pt-6"
/>
<div className="px-6 pb-8">
<WaterLogAdminPanel
<WaterLogShell
initialUsers={users}
initialHeadgates={headgates}
initialEntries={entries}
+26 -5
View File
@@ -22,7 +22,9 @@ import {
regenerateAdminPin,
type AdminSettings,
} from "@/actions/water-log/settings";
import SmartsheetHub from "@/components/admin/water-log/SmartsheetHub";
import SmartsheetIntegrationCard from "@/components/admin/water-log/SmartsheetIntegrationCard";
import TimeTrackingSmartsheetCard from "@/components/admin/time-tracking/TimeTrackingSmartsheetCard";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
@@ -475,8 +477,10 @@ export default function WaterLogSettingsPage() {
</button>
</form>
{/* Smartsheet Integration independent of the Admin Portal toggle above.
Self-contained card with its own save / test / activity surface. */}
{/* Smartsheet Workbook Hub (Cycle 7). One token, many sheets.
The hub card owns the brand-level workbook connection (token +
test + enable); the per-feature cards below pick which sheet
inside the workbook to write to. */}
<div className="mt-12 border-t border-[#d4d9d3] pt-10">
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">
§ 05 Integrations
@@ -488,14 +492,31 @@ export default function WaterLogSettingsPage() {
Smartsheet
</h2>
<p className="mt-2 text-sm text-[#5a5d5a]">
Push every new water log entry to a Smartsheet sheet. The
brand admin (you) controls the connection, frequency, and
column mapping. The token is encrypted at rest.
Connect one Smartsheet workbook and pick which sheet each
feature writes to. The token is encrypted at rest; per-feature
cards below manage only their sheet mapping.
</p>
<div className="mt-6">
<SmartsheetHub brandId={TUXEDO_BRAND_ID} />
</div>
</div>
{/* Water Log sheet mapping uses the workbook token from the
hub card above. Configures which sheet inside the workbook
receives water-log entries. */}
<div className="mt-8">
<SmartsheetIntegrationCard brandId={TUXEDO_BRAND_ID} />
</div>
{/* Time Tracking sheet mapping also uses the workbook token.
Configures which sheet inside the workbook receives
clock-out rows. */}
<div className="mt-8">
<TimeTrackingSmartsheetCard brandId={TUXEDO_BRAND_ID} />
</div>
{/* § 06 removed in Cycle 7 Time Tracking sheet mapping is now
a sibling card under § 05 above (uses the workbook token). */}
</div>
</div>
);
@@ -3,6 +3,8 @@ import { redirect } from "next/navigation";
import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth";
import { getWaterIrrigators } from "@/actions/water-log/admin";
import WaterUserEditForm from "@/components/admin/WaterUserEditForm";
import { DailyRouteSummary } from "@/components/time-tracking/admin/DailyRouteSummary";
import { getRouteTimeSummary } from "@/actions/time-tracking/route-summary";
import Link from "next/link";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
@@ -117,7 +119,25 @@ export default async function WaterLogUserPage({ params, searchParams }: UserPag
<WaterUserEditForm waterUser={waterUser} backHref={backHref} />
</div>
</div>
<RouteSummaryCard brandId={TUXEDO_BRAND_ID} workerId={waterUser.id} />
</div>
</main>
);
}
async function RouteSummaryCard({
brandId,
workerId,
}: {
brandId: string;
workerId: string;
}) {
const summary = await getRouteTimeSummary(brandId, workerId);
if (!summary) return null;
return (
<div className="mt-6">
<DailyRouteSummary summary={summary} />
</div>
);
}
+1 -1
View File
@@ -99,7 +99,7 @@ export async function GET(req: NextRequest) {
]);
allWorkers.push(workers);
allSettings.push(settings);
allLogs = allLogs.concat((logs as LogEntry[]).map(l => ({ ...l, brandId })));
allLogs = allLogs.concat((logs as unknown as LogEntry[]).map(l => ({ ...l, brandId })));
} catch (e) {
console.error(e);
}
@@ -0,0 +1,120 @@
/**
* Cron-driven Time Tracking Smartsheet sync drain.
*
* Cycle 8 mirrors `/api/water-log/smartsheet-sync/route.ts` so
* closed clock-outs eventually land in the customer's Smartsheet
* workbook even when the worker loses connectivity between clock-out
* and the next realtime push.
*
* Hit by Vercel cron (see vercel.json entry: every 15 minutes). The
* per-frequency gating happens inside `runScheduledTTSync`:
* - realtime: the event-driven push in `clockOutWorker` covers
* most cases; the cron recovers anything that failed
* to enqueue or was retried out.
* - every_15_min: drains the queue if last sync was 15m ago.
* - hourly: drains the queue if last sync was 60m ago.
*
* Auth:
* - Bearer token in `Authorization: Bearer $CRON_SECRET` header,
* or `?secret=$CRON_SECRET` query param (Vercel cron doesn't
* support either natively; the header is what curl / external
* schedulers use). Falls back to `CRON_SECRET` for cross-
* compatibility with the existing automation routes.
*
* Body (optional):
* - `{ brandId?: string }` restrict to one brand (useful for the
* "Retry now" admin button). If omitted, runs for every active
* brand.
*/
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { withPlatformAdmin } from "@/db/client";
import { timeTrackingSmartsheetConfig } from "@/db/schema/time-tracking";
import { runScheduledTTSync } from "@/services/time-tracking-smartsheet-sync";
import {
drainOneBrand,
summarizeDrains,
} from "@/services/sync-drain-summary";
function unauthorized() {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
function readCronSecret(): string {
// Treat empty strings as "not set" so the CRON_SECRET fallback
// works even when SMARTSHEET_CRON_SECRET is present-but-empty
// (the vi.stubEnv idiom and some hosting dashboards).
const a = process.env.SMARTSHEET_CRON_SECRET?.trim();
const b = process.env.CRON_SECRET?.trim();
if (a) return a;
if (b) return b;
return "";
}
function authenticate(req: NextRequest): boolean {
// Read env per-request so deploys can rotate CRON_SECRET without a
// module reload.
const secret = readCronSecret();
if (!secret) {
// No secret configured: refuse in prod, allow in dev so local
// debugging doesn't need a token.
return process.env.NODE_ENV !== "production";
}
const headerToken = req.headers
.get("authorization")
?.replace(/^Bearer\s+/i, "");
const queryToken = new URL(req.url).searchParams.get("secret");
return headerToken === secret || queryToken === secret;
}
// We need to enumerate every brand with a time-tracking smartsheet
// config enabled. The workspace read happens inside
// runScheduledTTSync, but we don't have a separate
// `timeTrackingSmartsheetConfig` helper that lists "active" brands.
// Instead, we ask Postgres directly via a raw query through
// `withPlatformAdmin`.
async function listActiveBrandIds(): Promise<string[]> {
return withPlatformAdmin(async (db) => {
const rows = await db
.select({ id: timeTrackingSmartsheetConfig.brandId })
.from(timeTrackingSmartsheetConfig)
.where(eq(timeTrackingSmartsheetConfig.syncEnabled, true));
return rows.map((r) => r.id);
});
}
export async function POST(req: NextRequest) {
if (!authenticate(req)) return unauthorized();
let body: { brandId?: string } = {};
try {
const text = await req.text();
if (text) body = JSON.parse(text);
} catch {
// ignore — body is optional
}
const start = Date.now();
const brandIds = body.brandId
? [body.brandId]
: await listActiveBrandIds();
const results = await Promise.all(
brandIds.map((brandId) => drainOneBrand(brandId, runScheduledTTSync)),
);
const totals = summarizeDrains(results);
return NextResponse.json({
success: totals.failed === 0,
durationMs: Date.now() - start,
totals,
results,
});
}
// GET is also supported for easy browser testing during development.
// Same auth; in production it should be POST-only.
export async function GET(req: NextRequest) {
return POST(req);
}
@@ -0,0 +1,59 @@
/**
* GET /api/time-tracking/timesheets/[id]/export?format=csv|pdf
*
* Returns the timesheet as a CSV or PDF blob. Same authz as the server
* action; the route exists because file downloads from a `<a href>` are
* far simpler than marshalling a binary blob through a Server Action
* return value.
*/
import { NextResponse, type NextRequest } from "next/server";
import {
exportTimesheetCsv,
exportTimesheetPdf,
} from "@/actions/time-tracking/export";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
) {
const { id } = await ctx.params;
const url = new URL(req.url);
const format = url.searchParams.get("format") ?? "csv";
if (format === "csv") {
const result = await exportTimesheetCsv(id);
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 });
}
return new NextResponse(result.csv, {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${result.filename}"`,
},
});
}
if (format === "pdf") {
const result = await exportTimesheetPdf(id);
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 });
}
// Return the PDF as a binary stream. NextResponse accepts a Buffer.
return new NextResponse(Buffer.from(result.pdf!), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${result.filename}"`,
},
});
}
return NextResponse.json(
{ error: `Unsupported format '${format}' (use csv or pdf)` },
{ status: 400 },
);
}
+129 -20
View File
@@ -302,16 +302,34 @@ select:-webkit-autofill:focus {
to { transform: rotate(360deg); }
}
/* Mobile Water Log PIN caret blink. The `to { opacity: 0 }` +
* `steps(2, start)` combo gives a hard on/off blink like UISegmentedControl
* / UITextField, not a gentle fade. */
@keyframes water-blink {
/* The water success screen used `water-draw`/`water-ring`; the Time
* Tracking Clocked Out screen used inline `strokeDashoffset` transitions
* + a single `field-draw`. Cycle 11 collapses everything into shared
* `field-*` keyframes so a worker who clocks out sees the same
* ring-and-check reveal choreography as a worker who submits a water
* reading. */
@keyframes field-draw {
from { stroke-dashoffset: var(--draw-from, 100); }
to { stroke-dashoffset: 0; }
}
@keyframes field-ring {
from { stroke-dashoffset: var(--ring-from, 360); }
to { stroke-dashoffset: 0; }
}
/* Mobile Field PIN caret blink. The `to { opacity: 0 }` +
* `steps(2, start)` combo gives a hard on/off blink like
* UISegmentedControl / UITextField, not a gentle fade. */
@keyframes field-blink {
to { opacity: 0; }
}
/* Mobile Water Log PIN-entry error shake. Three diminishing
* oscillations for a "no, that wasn't right" feel. */
@keyframes water-shake {
/* Mobile Field PIN-entry error shake. Three diminishing oscillations
* for a "no, that wasn't right" feel. The shake is applied directly
* to the digit-box row in `FieldPinScreen.tsx`, not to an empty
* overlay, so the user actually sees the row jerk back and forth. */
@keyframes field-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-10px); }
40% { transform: translateX(10px); }
@@ -319,19 +337,6 @@ select:-webkit-autofill:focus {
80% { transform: translateX(7px); }
}
/* Mobile Water Log checkmark stroke draw for the success screen.
* The element's `stroke-dasharray` is set inline so it works for any
* length; the keyframe just controls the timing. */
@keyframes water-draw {
from { stroke-dashoffset: var(--draw-from, 100); }
to { stroke-dashoffset: 0; }
}
/* Mobile Water Log — circular progress for the success ring. */
@keyframes water-ring {
from { stroke-dashoffset: var(--ring-from, 360); }
to { stroke-dashoffset: 0; }
}
/* Reduce motion: respect the user's OS-level preference. */
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
@@ -1378,3 +1383,107 @@ select:-webkit-autofill:focus {
-webkit-backdrop-filter: blur(16px);
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
/* Tuxedo storefront: Harvest editorial ambient drift
* Slow Ken Burns on the harvest packshot. Reads as ambient the photo
* is breathing, not animating. Honors prefers-reduced-motion below. */
@keyframes harvest-drift {
0% { transform: scale(1.04) translate(0, 0); }
50% { transform: scale(1.07) translate(-0.5%, -0.3%); }
100% { transform: scale(1.10) translate(-1%, -0.6%); }
}
.animate-harvest-drift {
animation: harvest-drift 30s ease-in-out infinite alternate;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.animate-harvest-drift {
animation: none !important;
transform: none !important;
}
}
/* Tuxedo storefront: editorial photo color grade
* Subtle Lightroom-style treatment applied to all hero / pull-quote /
* founder / packshot / ear-macro / storm-field photos. The raw WP-import
* JPGs skew saturated-yellow and slightly flat the corn itself reads
* garish, the shadows crushed, the highlights cheap. This filter:
* - saturate(.86) pulls the corn's yellow down so the photo doesn't
* shout louder than the white type.
* - contrast(1.06) magazine-print contrast slightly punchier
* without crushing shadows.
* - brightness(.97) trims ~3% so the photo sits a half-step below the
* type in the brightness hierarchy.
* Stacked with a faint cool-tone overlay (see .filter-editorial-shell)
* for a split-tone effect: shadows drift toward navy/slate, highlights
* stay warm. Reads as a graded magazine photo, not a raw JPG.
*/
.filter-editorial {
filter: saturate(0.86) contrast(1.06) brightness(0.97);
}
/* Wrap an image in a .filter-editorial-shell to also apply the cool
* shadow tint. The shell uses a mix-blend-mode overlay so the tint
* blends with the photo rather than sitting on top of it.
* ::before = slate multiply (cools highlights, lifts shadows)
* ::after = amber soft-light (warms midtones back, restores the
* corn's color without bringing the saturation back) */
.filter-editorial-shell {
position: relative;
isolation: isolate;
}
.filter-editorial-shell::before {
content: "";
position: absolute;
inset: 0;
background: rgba(20, 30, 50, 0.16);
mix-blend-mode: multiply;
pointer-events: none;
z-index: 1;
}
.filter-editorial-shell::after {
content: "";
position: absolute;
inset: 0;
background: rgba(232, 163, 23, 0.07);
mix-blend-mode: soft-light;
pointer-events: none;
z-index: 2;
}
.filter-editorial-shell > img {
filter: saturate(0.86) contrast(1.06) brightness(0.97);
}
/* Heavier grade for the harvest packshot it's the most chromatically
* dense image on the page (piles of yellow cobs) so it needs an
* extra-strong desaturation pull to read as editorial, not product
* photography. */
.filter-editorial-strong {
filter: saturate(0.78) contrast(1.10) brightness(0.95);
}
/* Subtle film vignette used on full-bleed editorial photos (packshot,
* storm field, founder). Draws the eye toward the center where the
* typography anchors. */
.filter-editorial-vignette::after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
ellipse at center,
transparent 35%,
rgba(0, 0, 0, 0.18) 80%,
rgba(0, 0, 0, 0.38) 100%
);
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.filter-editorial,
.filter-editorial-shell > img,
.filter-editorial-strong {
filter: none !important;
}
}
+548 -97
View File
@@ -1,15 +1,12 @@
"use client";
import { Suspense, lazy, useState } from "react";
import Image from "next/image";
import { m as motion } from "framer-motion";
import Link from "next/link";
import LayoutContainer from "@/components/layout/LayoutContainer";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
const MissionSection = lazy(() => import("@/components/about/MissionSection"));
const FamilyTimelineSection = lazy(() => import("@/components/about/FamilyTimelineSection"));
const ContactSection = lazy(() => import("@/components/about/ContactSection"));
const CTASection = lazy(() => import("@/components/about/CTASection"));
import { TUXEDO_IMAGES as WP_IMAGES } from "@/components/storefront/tuxedo-images";
import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
interface AboutClientProps {
logoUrl: string | null;
@@ -28,7 +25,6 @@ interface AboutClientProps {
export default function AboutClient({
logoUrl,
logoUrlDark,
olatheSweetLogoUrlDark,
aboutHeadline,
aboutSubheadline,
showWholesaleLink,
@@ -51,101 +47,25 @@ export default function AboutClient({
/>
<main>
{/* Hero Section */}
<section className="relative bg-stone-950 overflow-hidden">
{/* Decorative elements */}
<div className="absolute inset-0 opacity-20">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-emerald-900/20 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-amber-900/10 rounded-full blur-3xl" />
</div>
{/* Pattern overlay */}
<div className="absolute inset-0 opacity-5" style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 35px, rgba(255,255,255,0.1) 35px, rgba(255,255,255,0.1) 70px)' }} />
<div className="relative mx-auto max-w-6xl px-6 py-24 md:py-36">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Text content */}
<div>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-[11px] font-bold uppercase tracking-[0.3em] text-emerald-400/70 mb-4"
>
Our Heritage
</motion.p>
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.15 }}
className="text-5xl md:text-6xl lg:text-7xl font-black text-white tracking-tight leading-[1] mb-6"
>
{aboutHeadline ?? "Three Generations of Sweet Corn Excellence"}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
className="text-xl md:text-2xl text-stone-300 leading-relaxed"
>
{aboutSubheadline ?? "Over 40 years of growing and shipping Olathe Sweet Sweet Corn from our family to yours."}
</motion.p>
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.8, delay: 0.5 }}
className="mt-8 h-1 w-16 bg-gradient-to-r from-emerald-500 to-amber-500 rounded-full origin-left"
<HeroSection
headline={aboutHeadline ?? "Three generations of sweet corn excellence."}
subheadline={
aboutSubheadline ??
"Forty summers of the same seed, the same soil, and the same row — grown at five thousand, three hundred and sixty feet, picked by hand, packed the morning of."
}
/>
</div>
{/* Logo */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="flex justify-center lg:justify-end"
>
<div className="relative">
<div className="absolute -inset-6 bg-emerald-900/30 rounded-full blur-2xl" />
<Image
src={olatheSweetLogoUrlDark ?? "/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"}
alt="Olathe Sweet"
width={288}
height={288}
unoptimized
className="relative w-56 md:w-72 h-auto object-contain"
/>
</div>
</motion.div>
</div>
</div>
<TheCornSection />
{/* Bottom transition */}
<div className="absolute -bottom-px left-0 right-0 h-3 bg-stone-50" />
</section>
<PullQuoteBand />
{/* Mission Section */}
<Suspense fallback={<LoadingFallback bg="bg-stone-50" />}>
<MissionSection />
</Suspense>
<FounderStrip />
{/* Family Timeline */}
<Suspense fallback={<LoadingFallback bg="bg-stone-100" />}>
<FamilyTimelineSection />
</Suspense>
<FamilyTimeline />
{/* Contact Section */}
<Suspense fallback={<LoadingFallback bg="bg-stone-950" />}>
<ContactSection address={address} />
</Suspense>
<VisitTheFarm address={address} />
{/* CTA Section */}
<Suspense fallback={<LoadingFallback bg="bg-white" />}>
<CTASection />
</Suspense>
</main>
<StorefrontFooter
@@ -163,6 +83,537 @@ export default function AboutClient({
);
}
function LoadingFallback({ bg }: { bg: string }) {
return <div className={`py-24 md:py-32 ${bg}`} />;
// ── HERO — dark editorial opener ────────────────────────────────────
// Same architectural language as the homepage FounderStrip, but with
// the portrait as a full anchor instead of a supporting figure, and
// the headline sitting in the same Fraunces display register as the
// homepage "Three generations of sweet corn excellence" copy.
function HeroSection({
headline,
subheadline,
}: {
headline: string;
subheadline: string;
}) {
return (
<section className="relative bg-stone-950 overflow-hidden">
{/* A single amber rim along the bottom same hinge used
throughout the homepage between cream and dark sections. */}
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/30 z-10"
/>
<LayoutContainer>
<div className="relative py-20 sm:py-28 lg:py-32 grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-center">
{/* Copy column — 7/12 on desktop */}
<div className="lg:col-span-7 order-2 lg:order-1">
<FadeOnScroll from="up">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-6">
Our Heritage · Est. 1982
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.08}>
<h1
className="font-display text-stone-50 text-[clamp(2.5rem,5.4vw,4.5rem)] leading-[1.02] tracking-[-0.02em]"
style={{ textWrap: "balance" }}
>
{headline.split(/(excellence\.)/i).map((part, i) =>
/excellence\.$/i.test(part) ? (
<span key={i} className="italic text-amber-200/90">
{part}
</span>
) : (
<span key={i}>{part}</span>
)
)}
</h1>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.18}>
<div className="mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 via-amber-400 to-amber-300/0" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.26}>
<p className="mt-8 text-stone-300 text-lg sm:text-xl leading-[1.65] max-w-xl">
{subheadline}
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.34}>
<div className="mt-10 flex flex-wrap items-center gap-x-8 gap-y-3 text-[11px] uppercase tracking-[0.28em]">
<span className="text-amber-200/80">Olathe, Colorado</span>
<span className="text-stone-700">·</span>
<span className="text-stone-400">5,360 ft</span>
<span className="text-stone-700">·</span>
<span className="text-stone-400">Uncompahgre Valley</span>
</div>
</FadeOnScroll>
</div>
{/* Portrait column — 5/12 on desktop */}
<div className="lg:col-span-5 order-1 lg:order-2">
<FadeOnScroll from="right" duration={0.4}>
<div className="relative aspect-[4/5] sm:aspect-[5/6] overflow-hidden rounded-2xl ring-1 ring-white/10 shadow-[0_40px_120px_-40px_rgba(0,0,0,0.7)] filter-editorial-shell">
<Image
src={WP_IMAGES.founderPortrait}
alt="John Harold, founder, walking through his corn rows inspecting the tassels"
fill
sizes="(max-width: 1024px) 100vw, 42vw"
quality={92}
priority
className="object-cover"
/>
{/* Soft amber wash on the bottom matches the homepage
FounderStrip treatment. */}
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-1/4 bg-gradient-to-t from-amber-200/15 to-transparent pointer-events-none"
/>
<div className="absolute bottom-4 left-4 sm:bottom-5 sm:left-5 px-3 py-1.5 rounded-full bg-stone-950/70 backdrop-blur-sm text-[10px] font-semibold uppercase tracking-[0.28em] text-amber-100">
John Harold · Olathe, CO
</div>
</div>
</FadeOnScroll>
</div>
</div>
</LayoutContainer>
</section>
);
}
// ── THE CORN — split editorial moment ───────────────────────────────
// Mirrors the homepage's "The Corn" section: copy left (5/12),
// single macro photograph right (7/12). One paragraph. One image.
// The feature-card grid read as a templated list of icons; a single
// honest photograph communicates everything those tiles couldn't.
function TheCornSection() {
return (
<section
id="the-corn"
className="relative bg-stone-50 py-24 sm:py-32 overflow-hidden"
>
<LayoutContainer>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-center">
{/* Left: copy column, 5/12 */}
<div className="lg:col-span-5 order-2 lg:order-1">
<FadeOnScroll from="left">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
The Corn
</p>
</FadeOnScroll>
<FadeOnScroll from="left" delay={0.1}>
<h2
className="font-display text-stone-950 text-[clamp(2.25rem,4.8vw,3.75rem)] leading-[1.04] tracking-[-0.018em]"
style={{ textWrap: "balance" }}
>
You can taste<br className="hidden sm:block" />
<span className="italic text-amber-700/90">the altitude.</span>
</h2>
</FadeOnScroll>
<FadeOnScroll from="left" delay={0.2}>
<div className="mt-7 h-px w-12 bg-emerald-700/60" />
</FadeOnScroll>
<FadeOnScroll from="left" delay={0.3}>
<p className="mt-7 text-stone-700 text-lg leading-[1.65]">
Olathe Sweet was developed for the Uncompahgre Valley five
thousand, three hundred and sixty feet above the sea, where
the daytime sun hits the husk and the nights cool it back
down before sunrise. That diurnal swing is what loads every
kernel with sugar; nothing we do in the kitchen can
replicate it, and nothing in a greenhouse can grow it.
</p>
</FadeOnScroll>
<FadeOnScroll from="left" delay={0.4}>
<p className="mt-5 text-stone-600 leading-[1.65]">
The ear in the photograph was picked the morning it was
shot. The silk on the right is still damp. We don&rsquo;t
airbrush the field because we don&rsquo;t need to.
</p>
</FadeOnScroll>
</div>
{/* Right: macro photo, 7/12 */}
<div className="lg:col-span-7 order-1 lg:order-2">
<FadeOnScroll from="right" duration={0.4}>
<div className="relative aspect-[5/4] sm:aspect-[16/11] overflow-hidden rounded-2xl shadow-[0_30px_80px_-30px_rgba(15,40,20,0.45)] ring-1 ring-stone-900/5 filter-editorial-shell">
<Image
src={WP_IMAGES.earMacro}
alt="A single ear of Olathe Sweet corn, partially husked, silk still attached, against a field of dark green tassels"
fill
sizes="(max-width: 1024px) 100vw, 58vw"
quality={90}
priority
className="object-cover"
/>
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-amber-200/15 to-transparent pointer-events-none"
/>
<div className="absolute bottom-4 left-4 sm:bottom-6 sm:left-6 px-3.5 py-2 rounded-full bg-stone-950/65 backdrop-blur-sm text-[10px] font-semibold uppercase tracking-[0.28em] text-amber-100">
Olathe Sweet · Day of pick
</div>
</div>
</FadeOnScroll>
</div>
</div>
</LayoutContainer>
</section>
);
}
// ── PULL-QUOTE BAND — full-bleed editorial interlude ───────────────
// Same composition the homepage uses: pre-storm field behind, single
// Fraunces italic quote floating in the lower third. No chrome. The
// image does the talking.
function PullQuoteBand() {
return (
<section
className="relative isolate overflow-hidden"
style={{ minHeight: "min(70vh, 640px)" }}
>
<Image
src={WP_IMAGES.stormField}
alt="Pre-storm corn rows receding to the mountains at dusk"
fill
sizes="100vw"
quality={88}
className="object-cover -z-10 filter-editorial"
/>
<div className="absolute inset-0 bg-gradient-to-b from-stone-950/55 via-stone-950/15 to-stone-950/80 -z-10" />
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/40 -z-10"
/>
<div className="relative min-h-[inherit] flex items-end pb-16 sm:pb-24">
<div className="mx-auto w-full max-w-5xl px-6 sm:px-10 text-center">
<FadeOnScroll from="up">
<p
className="font-display text-white text-[clamp(1.75rem,3.8vw,3.25rem)] leading-[1.08] tracking-[-0.015em] italic"
style={{ textWrap: "balance" }}
>
&ldquo;The field decides the sweetness.<br className="hidden sm:block" />{" "}
<span className="text-amber-200/95">We just pick it on time.&rdquo;</span>
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.15}>
<div className="mt-8 mx-auto h-px w-12 bg-amber-300/70" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.25}>
<p className="mt-5 text-[10px] sm:text-[11px] font-medium uppercase tracking-[0.32em] text-stone-300">
Tuxedo Corn · Olathe, Colorado
</p>
</FadeOnScroll>
</div>
</div>
</section>
);
}
// ── FOUNDER STRIP — narrative anchor for "The People" section ──────
// Cream background, magazine spread. Eyebrow + Fraunces headline on
// the left, two paragraphs of founder story on the right. Below it
// the FamilyTimeline component renders the four family-member entries
// in the same hairline-ruled editorial register.
function FounderStrip() {
return (
<section className="relative bg-stone-50 py-24 sm:py-32 overflow-hidden border-t border-stone-200/60">
<LayoutContainer>
<header className="mb-14 sm:mb-20 grid grid-cols-1 lg:grid-cols-12 gap-x-10 gap-y-6 items-end">
<div className="lg:col-span-7">
<FadeOnScroll from="up">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
The People Behind the Corn
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.1}>
<h2
className="font-display text-stone-950 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
style={{ textWrap: "balance" }}
>
The Harold family,
<br className="hidden sm:block" />{" "}
<span className="italic text-amber-700/90">on the same row.</span>
</h2>
</FadeOnScroll>
</div>
<div className="lg:col-span-5">
<FadeOnScroll from="up" delay={0.15}>
<p className="text-stone-600 text-base leading-[1.7] lg:max-w-md lg:ml-auto">
John Harold began selling Olathe&rsquo;s sweet corn directly
from pickups in the 1970s. The Olathe Sweet parent line was
rescued from a retiring farmer who believed his valley could
grow sugar in a husk. Three generations later, the name on
the cooler is still the name on the deed.
</p>
</FadeOnScroll>
</div>
</header>
</LayoutContainer>
</section>
);
}
// ── FAMILY TIMELINE — magazine "sidebar" register ──────────────────
// Hairline-ruled four-column grid. No cards, no avatars in gradient
// boxes. The homepage's HarvestEditorial data strip is the structural
// model; the era + name + role + bio columns are the editorial content.
// Order matters: founder first, then by generation.
const FAMILY = [
{
initials: "JH",
name: "John Harold",
role: "Founder",
era: "1970s",
bio: "Started selling Olathe's sweet corn directly from pickups. Built Tuxedo Corn into a family operation rooted in the Uncompahgre Valley.",
},
{
initials: "DH",
name: "David Harold",
role: "Second Generation",
era: "Present",
bio: "Leads sustainability across Harold farms — drip irrigation, Fair Food Program, reduced chemical inputs. Keeps the soil honest.",
},
{
initials: "JW",
name: "John William Harold",
role: "Mexico Operations",
era: "1990sPresent",
bio: "Three decades in Guaymas, Sonora, growing sweet corn, onions, wheat, and vegetables. Extends the Harold tradition across borders.",
},
{
initials: "JH",
name: "Joseph Harold",
role: "Third Generation",
era: "Present",
bio: "Works the corn shed in summer, handles photography and beverages. Carrying the family tradition into the next generation.",
},
];
function FamilyTimeline() {
return (
<section className="relative bg-stone-50 pb-24 sm:pb-32 overflow-hidden">
<LayoutContainer>
{/* Hairline above the strip — the magazine sidebar opening. */}
<div aria-hidden="true" className="h-px w-full bg-stone-900/15 mb-14" />
<ol className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-y-14 lg:gap-y-0 lg:gap-x-10">
{FAMILY.map((member, i) => (
<FadeOnScroll from="up" delay={i * 0.08} key={member.name}>
<li className="relative lg:pt-2">
{/* Per-column hairline (top on desktop, absent on mobile
because the gap-y supplies spacing). */}
<span
aria-hidden="true"
className="absolute inset-x-0 -top-6 h-px bg-stone-900/15 hidden lg:block"
/>
<p className="text-[10px] font-bold uppercase tracking-[0.32em] text-emerald-700 tabular-nums">
{member.era}
</p>
<p className="mt-4 font-display text-stone-950 text-[clamp(1.65rem,2.4vw,2.1rem)] leading-[1.1] tracking-[-0.014em]">
{member.name}
</p>
<p className="mt-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-700/85">
{member.role}
</p>
<p className="mt-4 text-stone-600 text-sm leading-[1.6] max-w-[28ch]">
{member.bio}
</p>
</li>
</FadeOnScroll>
))}
</ol>
{/* Hairline + caption underneath, matching the HarvestEditorial
editorial-footer rhythm. */}
<div aria-hidden="true" className="h-px w-full bg-stone-900/15 mt-16" />
<p className="mt-8 text-center text-[10px] uppercase tracking-[0.32em] text-stone-500">
Three generations · One valley · Forty summers
</p>
</LayoutContainer>
</section>
);
}
// ── VISIT THE FARM — dark editorial address block ──────────────────
// Replaces the three icon-card contact layout. Stone-950 panel with a
// magazine "by the numbers" data strip + a single hairline-ruled
// address line. No SVG icons in colored boxes; the typography carries
// the section.
function VisitTheFarm({ address }: { address: string }) {
return (
<section
className="relative bg-stone-950 py-24 sm:py-32 overflow-hidden"
aria-labelledby="visit-heading"
>
{/* Single amber rim at the top same hinge used on the dark
homepage sections. */}
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 h-px bg-amber-300/15"
/>
<LayoutContainer>
<header className="mb-14 sm:mb-20 grid grid-cols-1 lg:grid-cols-12 gap-x-10 gap-y-6 items-end">
<div className="lg:col-span-7">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-5">
The Farm
</p>
<h2
id="visit-heading"
className="font-display text-stone-100 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
style={{ textWrap: "balance" }}
>
Visit the<br className="hidden sm:block" />{" "}
<span className="italic text-amber-200/85">Uncompahgre Valley.</span>
</h2>
</div>
<div className="lg:col-span-5">
<p className="text-stone-400 text-base leading-[1.7] lg:max-w-md lg:ml-auto">
The farm is open during harvest. Pickup windows are tight,
shed hours are short, and the corn is best the morning it&rsquo;s
picked call ahead before you drive out.
</p>
</div>
</header>
{/* Hairline-ruled data strip — four columns: address / phone /
shipping hours / office hours. Same magazine-sidebar treatment
as the homepage HarvestEditorial numbers strip, just with
prose instead of counters. */}
<ol className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-y-14 lg:gap-y-0 lg:gap-x-10 border-t border-b border-amber-300/15">
<FarmPoint label="Address" detail={address} mono />
<FarmPoint label="Shipping" detail="970-323-5631" mono />
<FarmPoint label="Office" detail="970-323-6874" mono />
<FarmPoint
label="Hours"
detail={["MonFri · 7A6P", "SatSun · 8A2P"]}
/>
</ol>
{/* Editorial caption, italic display serif. */}
<p className="mt-16 sm:mt-20 max-w-2xl mx-auto text-center font-display italic text-stone-300 text-[clamp(1.1rem,1.8vw,1.4rem)] leading-[1.45]">
&ldquo;We don&rsquo;t ship corn that&rsquo;s more than a day from
the field. The valley won&rsquo;t let us.&rdquo;
</p>
<p className="mt-5 text-center text-[10px] uppercase tracking-[0.32em] text-stone-500">
&mdash; Tuxedo Corn · Olathe, Colorado
</p>
</LayoutContainer>
</section>
);
}
// One column of the Visit-the-Farm strip. Same hairline rules and
// label style as HarvestEditorial's DataPoint on the homepage, with
// `detail` either a string or an array of lines (for the Hours column).
function FarmPoint({
label,
detail,
mono = false,
}: {
label: string;
detail: string | string[];
mono?: boolean;
}) {
const lines = Array.isArray(detail) ? detail : [detail];
return (
<li className="relative pt-8 lg:pt-12">
<p className="text-[10px] font-bold uppercase tracking-[0.3em] text-amber-300/85">
{label}
</p>
{lines.map((line, i) => (
<p
key={i}
className={`mt-4 text-stone-100 text-base leading-[1.55] ${
mono ? "font-mono tabular-nums tracking-tight" : ""
}`}
>
{line}
</p>
))}
</li>
);
}
// ── CTA — closing editorial pair ───────────────────────────────────
// Stone-50 section with the dark gradient emerald→amber pill button
// used on the homepage StorySection "Read Our Story" CTA, plus a
// secondary "Shop Products" outline. Same voice as the homepage's
// closing beat: "Find a stop / Shop products".
function CTASection() {
return (
<section className="relative bg-stone-50 py-24 sm:py-32 overflow-hidden border-t border-stone-200/60">
<LayoutContainer>
<div className="relative mx-auto max-w-3xl text-center">
<FadeOnScroll from="up">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
This Week
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.08}>
<h2
className="font-display text-stone-950 text-[clamp(2rem,4vw,3rem)] leading-[1.05] tracking-[-0.018em]"
style={{ textWrap: "balance" }}
>
Find a stop near you,
<br className="hidden sm:block" />{" "}
<span className="italic text-amber-700/90">or have it shipped.</span>
</h2>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.16}>
<div className="mx-auto mt-7 h-px w-16 bg-gradient-to-r from-emerald-500 to-amber-400" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.24}>
<p className="mt-7 text-stone-600 text-lg leading-[1.65]">
Preorder for pickup at a stop on the summer route, or have
cooler boxes shipped directly to your door after the season.
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.32}>
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
<Link
href="/tuxedo#stops"
className="group inline-flex items-center gap-3 rounded-full bg-gradient-to-br from-emerald-700 to-emerald-600 px-9 py-4 text-sm font-bold text-white shadow-[0_8px_32px_rgba(16,185,129,0.3),inset_0_1px_0_rgba(255,255,255,0.2)] hover:-translate-y-0.5 hover:shadow-[0_12px_40px_rgba(16,185,129,0.4),inset_0_1px_0_rgba(255,255,255,0.2)] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-50"
>
<span>Find a Stop</span>
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
<Link
href="/tuxedo#products"
className="group inline-flex items-center gap-3 rounded-full bg-white px-9 py-4 text-sm font-bold text-stone-900 ring-1 ring-stone-200 hover:ring-emerald-600 hover:text-emerald-700 transition-all"
>
<span>Shop Products</span>
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</FadeOnScroll>
</div>
</LayoutContainer>
</section>
);
}
+518 -158
View File
@@ -1,11 +1,13 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import LayoutContainer from "@/components/layout/LayoutContainer";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
import { supabase } from "@/lib/supabase";
import { TUXEDO_IMAGES } from "@/components/storefront/tuxedo-images";
import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
type BrandSettings = {
invoice_business_name: string | null;
@@ -22,6 +24,11 @@ type FormState = {
message: string;
};
const FALLBACK_ADDRESS = "59751 David Road, Olathe, CO 81425";
const FALLBACK_EMAIL = "orders@tuxedocorn.com";
const SHIPPING_PHONE = "970-323-5631";
const OFFICE_PHONE = "970-323-6874";
export default function TuxedoContactPage() {
const [brandSettings, setBrandSettings] = useState<BrandSettings | null>(null);
const [submitted, setSubmitted] = useState(false);
@@ -30,12 +37,16 @@ export default function TuxedoContactPage() {
useEffect(() => {
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
import("@/lib/supabase").then(({ supabase }) => {
supabase
.from("wholesale_settings")
.select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website")
.select(
"invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website"
)
.eq("brand_id", TUXEDO_BRAND_ID)
.single()
.then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null));
});
}, []);
function handleSubmit(e: React.FormEvent) {
@@ -47,182 +58,531 @@ export default function TuxedoContactPage() {
}, 600);
}
const address = brandSettings?.invoice_business_address ?? FALLBACK_ADDRESS;
const email = brandSettings?.invoice_business_email ?? FALLBACK_EMAIL;
return (
<div className="min-h-screen bg-stone-50">
<StorefrontHeader brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
<main className="py-16 md:py-20">
<main>
<ContactHero />
{/* Contact info strip hairline-ruled four-column data grid,
same magazine-sidebar register as the About page's
"Visit the Farm" section. */}
<ContactInfoStrip
address={address}
email={email}
shippingPhone={SHIPPING_PHONE}
officePhone={OFFICE_PHONE}
/>
{/* Form section cream stone-50, two-column layout. Left
column carries the editorial framing (eyebrow + Fraunces
headline + paragraph + small hours caption). Right column
holds the form itself, no wrapper white card. */}
<section className="relative bg-stone-50 py-24 sm:py-32 border-t border-stone-200/60">
<LayoutContainer>
<div className="max-w-5xl mx-auto">
{/* Page header */}
<div className="text-center mb-16">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Get in Touch</p>
<h1 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-tight">
Contact Us
</h1>
<p className="mt-5 text-lg text-stone-500 max-w-xl mx-auto leading-relaxed">
Questions about corn, stops, or wholesale accounts we are happy to help.
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16">
{/* Left: editorial framing */}
<div className="lg:col-span-5">
<FadeOnScroll from="up">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
Send a Message
</p>
</div>
{/* Contact info grid */}
<div className="grid gap-6 md:grid-cols-3 mb-16">
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 mb-5">
<svg className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<h3 className="text-base font-bold text-stone-950 mb-3">Farm Address</h3>
<p className="text-sm text-stone-500 leading-relaxed">
{brandSettings?.invoice_business_address ?? "59751 David Road, Olathe, CO 81425"}
</p>
</div>
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 mb-5">
<svg className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.184-.046-.379-.041-.545.114L18 10.48a2.25 2.25 0 00-.545-.114l-4.423 1.106c-.5.119-.852.575-.852 1.091v1.372a2.25 2.25 0 01-2.25 2.25h-2.25a15 15 0 01-15-15z" />
</svg>
</div>
<h3 className="text-base font-bold text-stone-950 mb-3">Phone</h3>
<a href="tel:9703235631" className="block text-sm text-stone-500 hover:text-emerald-700 transition-colors leading-relaxed">
Shipping: 970-323-5631
</a>
<a href="tel:9703236874" className="mt-1 block text-sm text-stone-500 hover:text-emerald-700 transition-colors leading-relaxed">
Office: 970-323-6874
</a>
</div>
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 mb-5">
<svg className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
</div>
<h3 className="text-base font-bold text-stone-950 mb-3">Email</h3>
<a href={`mailto:${brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"}`} className="text-sm text-stone-500 hover:text-emerald-700 transition-colors">
{brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"}
</a>
</div>
</div>
{/* Contact form */}
<div className="rounded-3xl bg-white p-10 shadow-sm ring-1 ring-stone-200/60">
<div className="mb-8">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-3">Send a Message</p>
<h2 className="text-3xl font-black text-stone-950 tracking-tight leading-tight">
Get in Touch
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.08}>
<h2
className="font-display text-stone-950 text-[clamp(2rem,4.2vw,3.25rem)] leading-[1.05] tracking-[-0.02em]"
style={{ textWrap: "balance" }}
>
Tell us what you&rsquo;re after,
<br className="hidden sm:block" />{" "}
<span className="italic text-amber-700/90">we&rsquo;ll write back.</span>
</h2>
<div className="mt-4 h-px w-12 bg-emerald-600" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.16}>
<div className="mt-7 h-px w-12 bg-emerald-700/60" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.24}>
<p className="mt-7 text-stone-700 text-lg leading-[1.65]">
Most messages get a reply within one business day. During
harvest weeks the office is open early call if your
order is time-sensitive and we&rsquo;ll get you a real
answer, not a form letter.
</p>
</FadeOnScroll>
{/* Hours caption, hairline-ruled inline. */}
<FadeOnScroll from="up" delay={0.32}>
<dl className="mt-12 border-t border-stone-900/15 pt-6 grid grid-cols-2 gap-y-5 gap-x-6 text-sm">
<div>
<dt className="text-[10px] font-bold uppercase tracking-[0.3em] text-emerald-700">
Weekdays
</dt>
<dd className="mt-2 font-mono tabular-nums text-stone-950">
7A 6P
</dd>
</div>
<div>
<dt className="text-[10px] font-bold uppercase tracking-[0.3em] text-emerald-700">
Weekends
</dt>
<dd className="mt-2 font-mono tabular-nums text-stone-950">
8A 2P
</dd>
</div>
</dl>
</FadeOnScroll>
</div>
{/* Right: form */}
<div className="lg:col-span-7">
<FadeOnScroll from="right">
{submitted ? (
<div className="rounded-2xl bg-emerald-50 p-10 text-center ring-1 ring-emerald-100">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 mb-4">
<svg className="h-6 w-6 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<p className="text-xl font-bold text-stone-950">Message received.</p>
<p className="mt-2 text-sm text-stone-500">We will be in touch within 1-2 business days.</p>
<button type="button"
onClick={() => {
<FormSuccess
onReset={() => {
setSubmitted(false);
setForm({ name: "", email: "", topic: "general", message: "" });
}}
className="mt-5 text-sm font-medium text-emerald-700 underline hover:text-emerald-900"
>
Send another message
</button>
</div>
/>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<div>
<label htmlFor="fld-1-your-name" className="block text-sm font-semibold text-stone-700 mb-2">Your Name</label>
<input id="fld-1-your-name" aria-label="Jane Smith"
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full rounded-xl border border-stone-200 bg-white px-5 py-4 text-sm text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors"
placeholder="Jane Smith"
<ContactForm
form={form}
setForm={setForm}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
</div>
<div>
<label htmlFor="fld-2-email" className="block text-sm font-semibold text-stone-700 mb-2">Email</label>
<input id="fld-2-email" aria-label="Jane@example.com"
required
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full rounded-xl border border-stone-200 bg-white px-5 py-4 text-sm text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors"
placeholder="jane@example.com"
/>
</div>
</div>
<div>
<label htmlFor="fld-3-topic" className="block text-sm font-semibold text-stone-700 mb-2">Topic</label>
<select id="fld-3-topic" aria-label="Select"
value={form.topic}
onChange={(e) => setForm({ ...form, topic: e.target.value })}
className="w-full rounded-xl border border-stone-200 bg-white px-5 py-4 text-sm text-stone-900 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors appearance-none"
>
<option value="general">General Question</option>
<option value="orders">Orders & Pickup</option>
<option value="wholesale">Wholesale Inquiry</option>
<option value="media">Media & Partnership</option>
</select>
</div>
<div>
<label htmlFor="fld-4-message" className="block text-sm font-semibold text-stone-700 mb-2">Message</label>
<textarea id="fld-4-message" aria-label="How Can We Help You?"
required
rows={5}
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
className="w-full rounded-xl border border-stone-200 bg-white px-5 py-4 text-sm text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors resize-none"
placeholder="How can we help you?"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="rounded-xl bg-stone-900 px-8 py-4 text-sm font-bold text-white hover:bg-stone-800 active:bg-stone-950 transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:shadow-lg hover:shadow-black/15"
>
{isSubmitting ? "Sending..." : "Send Message"}
</button>
</form>
)}
</div>
{/* Business hours — elevated */}
<div className="mt-10 rounded-3xl bg-white p-10 shadow-sm ring-1 ring-stone-200/60">
<div className="mb-6">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-3">Summer Season</p>
<h2 className="text-2xl font-black text-stone-950 tracking-tight leading-tight">
When to Reach Us
</h2>
<div className="mt-4 h-px w-12 bg-emerald-600" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between py-4 px-6 rounded-2xl bg-stone-50">
<p className="font-semibold text-stone-700">Monday Friday</p>
<p className="text-stone-500 font-medium">7:00 AM 6:00 PM</p>
</div>
<div className="flex items-center justify-between py-4 px-6 rounded-2xl bg-stone-50">
<p className="font-semibold text-stone-700">Saturday Sunday</p>
<p className="text-stone-500 font-medium">8:00 AM 2:00 PM</p>
</div>
</div>
<p className="mt-5 text-sm text-stone-400 leading-relaxed">
During the off-season, response times may be longer. For urgent matters, please call the office.
</p>
</FadeOnScroll>
</div>
</div>
</LayoutContainer>
</section>
<ContactClosingQuote />
</main>
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
</div>
);
}
// ── HERO — dark editorial opener ────────────────────────────────────
// Mirrors the About page's hero structure: eyebrow + Fraunces headline
// (with italic emphasis) + subhead + hairline gradient rule. The
// field photograph sits as a soft half-bleed at the bottom-right
// instead of a featured portrait — the contact page is shorter and
// quieter than the about page.
function ContactHero() {
return (
<section className="relative bg-stone-950 overflow-hidden isolate">
{/* Soft field image, anchored bottom-right, partially visible
behind the headline column. Suggests the farm without
competing with the form. */}
<div className="absolute inset-y-0 right-0 w-full lg:w-1/2 -z-10">
<Image
src={TUXEDO_IMAGES.heroField}
alt=""
fill
sizes="(max-width: 1024px) 100vw, 50vw"
quality={85}
priority
className="object-cover object-center filter-editorial opacity-50"
/>
<div className="absolute inset-0 bg-gradient-to-r from-stone-950 via-stone-950/85 to-stone-950/30" />
<div className="absolute inset-0 bg-gradient-to-b from-stone-950/40 via-transparent to-stone-950" />
</div>
{/* Single amber rim along the bottom same hinge as the
homepage and about dark sections. */}
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/30 z-10"
/>
<LayoutContainer>
<div className="relative py-20 sm:py-28 lg:py-32 max-w-3xl">
<FadeOnScroll from="up">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-6">
Get in Touch · Olathe, Colorado
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.08}>
<h1
className="font-display text-stone-50 text-[clamp(2.5rem,5.4vw,4.5rem)] leading-[1.02] tracking-[-0.02em]"
style={{ textWrap: "balance" }}
>
Send a note,
<br className="hidden sm:block" />{" "}
<span className="italic text-amber-200/90">or a question.</span>
</h1>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.18}>
<div className="mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 via-amber-400 to-amber-300/0" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.26}>
<p className="mt-8 text-stone-300 text-lg sm:text-xl leading-[1.65] max-w-xl">
Orders, pickup logistics, wholesale accounts, press every
message lands on a real desk in Olathe and gets a real
answer.
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.34}>
<div className="mt-10 flex flex-wrap items-center gap-x-8 gap-y-3 text-[11px] uppercase tracking-[0.28em]">
<span className="text-amber-200/80">Replies in 12 days</span>
<span className="text-stone-700">·</span>
<span className="text-stone-400">No form letters</span>
<span className="text-stone-700">·</span>
<span className="text-stone-400">Real people</span>
</div>
</FadeOnScroll>
</div>
</LayoutContainer>
</section>
);
}
// ── CONTACT INFO STRIP — magazine "by the numbers" pattern ─────────
// Same hairline-ruled four-column data grid as the About page's
// "Visit the Farm" section. Address, shipping phone, office phone,
// email — no icon-in-rounded-box, just type and tabular numerals.
function ContactInfoStrip({
address,
email,
shippingPhone,
officePhone,
}: {
address: string;
email: string;
shippingPhone: string;
officePhone: string;
}) {
return (
<section className="relative bg-stone-50 py-20 sm:py-24">
<LayoutContainer>
<ol className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-y-12 lg:gap-y-0 lg:gap-x-10 border-t border-b border-stone-900/15">
<InfoPoint label="Address" detail={address} />
<InfoPoint label="Shipping" detail={shippingPhone} mono />
<InfoPoint label="Office" detail={officePhone} mono />
<InfoPoint label="Email" detail={email} mono />
</ol>
</LayoutContainer>
</section>
);
}
function InfoPoint({
label,
detail,
mono = false,
}: {
label: string;
detail: string;
mono?: boolean;
}) {
return (
<li className="relative pt-8 lg:pt-12">
<p className="text-[10px] font-bold uppercase tracking-[0.3em] text-emerald-700">
{label}
</p>
<p
className={`mt-4 text-stone-950 text-base leading-[1.55] ${
mono ? "font-mono tabular-nums tracking-tight" : ""
}`}
>
{detail}
</p>
</li>
);
}
// ── FORM — bottom-border-only editorial inputs ─────────────────────
// The form sits directly on the cream background without a wrapping
// card. Inputs use bottom borders only (with a hairline focus state),
// matching the "atelier-input" treatment used elsewhere in the admin.
// The submit button is the same emerald→amber gradient pill used on
// the homepage and about pages.
function ContactForm({
form,
setForm,
onSubmit,
isSubmitting,
}: {
form: FormState;
setForm: (s: FormState) => void;
onSubmit: (e: React.FormEvent) => void;
isSubmitting: boolean;
}) {
return (
<form onSubmit={onSubmit} className="space-y-10">
<div className="grid gap-10 sm:grid-cols-2">
<Field
id="fld-name"
label="Your Name"
value={form.name}
onChange={(v) => setForm({ ...form, name: v })}
required
placeholder="Jane Smith"
/>
<Field
id="fld-email"
label="Email"
type="email"
value={form.email}
onChange={(v) => setForm({ ...form, email: v })}
required
placeholder="jane@example.com"
/>
</div>
<SelectField
id="fld-topic"
label="Topic"
value={form.topic}
onChange={(v) => setForm({ ...form, topic: v })}
options={[
{ value: "general", label: "General Question" },
{ value: "orders", label: "Orders & Pickup" },
{ value: "wholesale", label: "Wholesale Inquiry" },
{ value: "media", label: "Media & Partnership" },
]}
/>
<TextareaField
id="fld-message"
label="Message"
value={form.message}
onChange={(v) => setForm({ ...form, message: v })}
required
placeholder="How can we help?"
/>
<div className="pt-2">
<button
type="submit"
disabled={isSubmitting}
className="group inline-flex items-center gap-3 rounded-full bg-gradient-to-br from-emerald-700 to-emerald-600 px-9 py-4 text-sm font-bold text-white shadow-[0_8px_32px_rgba(16,185,129,0.3),inset_0_1px_0_rgba(255,255,255,0.2)] hover:-translate-y-0.5 hover:shadow-[0_12px_40px_rgba(16,185,129,0.4),inset_0_1px_0_rgba(255,255,255,0.2)] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-50 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:translate-y-0"
>
<span>{isSubmitting ? "Sending…" : "Send Message"}</span>
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
</svg>
</button>
</div>
</form>
);
}
// Single text input — bottom-border-only editorial treatment.
// Label sits above the input as a small uppercase eyebrow so the
// field is readable on the cream background without a chrome card.
function Field({
id,
label,
value,
onChange,
type = "text",
required,
placeholder,
}: {
id: string;
label: string;
value: string;
onChange: (v: string) => void;
type?: string;
required?: boolean;
placeholder?: string;
}) {
return (
<div>
<label
htmlFor={id}
className="block text-[10px] font-bold uppercase tracking-[0.28em] text-stone-500 mb-3"
>
{label}
</label>
<input
id={id}
type={type}
required={required}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full bg-transparent border-0 border-b border-stone-900/15 px-0 py-3 text-base text-stone-950 placeholder-stone-400 outline-none focus:border-emerald-700 focus:border-b-2 focus:pb-[11px] transition-colors"
/>
</div>
);
}
function SelectField({
id,
label,
value,
onChange,
options,
}: {
id: string;
label: string;
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<div>
<label
htmlFor={id}
className="block text-[10px] font-bold uppercase tracking-[0.28em] text-stone-500 mb-3"
>
{label}
</label>
<div className="relative">
<select
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full appearance-none bg-transparent border-0 border-b border-stone-900/15 px-0 py-3 pr-8 text-base text-stone-950 outline-none focus:border-emerald-700 focus:border-b-2 focus:pb-[11px] transition-colors"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<svg
className="pointer-events-none absolute right-0 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</div>
</div>
);
}
function TextareaField({
id,
label,
value,
onChange,
required,
placeholder,
}: {
id: string;
label: string;
value: string;
onChange: (v: string) => void;
required?: boolean;
placeholder?: string;
}) {
return (
<div>
<label
htmlFor={id}
className="block text-[10px] font-bold uppercase tracking-[0.28em] text-stone-500 mb-3"
>
{label}
</label>
<textarea
id={id}
required={required}
rows={5}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full bg-transparent border-0 border-b border-stone-900/15 px-0 py-3 text-base text-stone-950 placeholder-stone-400 outline-none focus:border-emerald-700 focus:border-b-2 focus:pb-[11px] transition-colors resize-none"
/>
</div>
);
}
function FormSuccess({ onReset }: { onReset: () => void }) {
return (
<div className="relative border-t border-b border-emerald-700/30 py-16 text-center">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 mb-6">
<svg
className="h-6 w-6 text-emerald-700"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<p className="font-display text-stone-950 text-[clamp(1.5rem,2.6vw,2.1rem)] leading-[1.1] tracking-[-0.014em]">
Message received.
</p>
<div className="mx-auto mt-6 h-px w-12 bg-emerald-700/60" />
<p className="mt-7 text-stone-600 leading-[1.65]">
We&rsquo;ll be in touch within one to two business days. If it&rsquo;s
urgent, the office line is the fastest way to reach us.
</p>
<button
type="button"
onClick={onReset}
className="mt-8 text-sm font-semibold text-emerald-700 underline underline-offset-4 hover:text-emerald-900 transition-colors"
>
Send another message
</button>
</div>
);
}
// ── CLOSING — dark editorial quote ─────────────────────────────────
// Same magazine-closing beat the homepage uses: italic Fraunces quote
// centered with a hairline-rule attribution underneath.
function ContactClosingQuote() {
return (
<section className="relative bg-stone-950 py-24 sm:py-32 overflow-hidden">
{/* Top amber rim — hinges against the cream form section. */}
<div aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-amber-300/15" />
<LayoutContainer>
<FadeOnScroll from="up">
<p
className="mx-auto max-w-2xl text-center font-display italic text-stone-100 text-[clamp(1.4rem,2.8vw,2.1rem)] leading-[1.4] tracking-[-0.012em]"
style={{ textWrap: "balance" }}
>
&ldquo;Pick up the phone. Write us a note. Either way, you&rsquo;re
reaching a person who knows the field.&rdquo;
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.15}>
<div className="mx-auto mt-8 h-px w-12 bg-amber-300/70" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.25}>
<p className="mt-5 text-center text-[10px] uppercase tracking-[0.32em] text-stone-400">
Tuxedo Corn · Olathe, Colorado
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.35}>
<div className="mt-12 text-center">
<Link
href="/tuxedo/faq"
className="text-sm font-semibold text-amber-200/85 underline underline-offset-4 hover:text-amber-100 transition-colors"
>
Read the FAQ first
</Link>
</div>
</FadeOnScroll>
</LayoutContainer>
</section>
);
}
+1 -2
View File
@@ -1,5 +1,4 @@
import type { Metadata } from "next";
import ContactClientPage from "./ContactClientPage";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
@@ -40,5 +39,5 @@ export const metadata: Metadata = {
};
export default function TuxedoContactLayout({ children }: { children: React.ReactNode }) {
return <ContactClientPage />;
return <>{children}</>;
}
+5
View File
@@ -0,0 +1,5 @@
import ContactClientPage from "./ContactClientPage";
export default function TuxedoContactPage() {
return <ContactClientPage />;
}
+234 -68
View File
@@ -1,9 +1,11 @@
"use client";
import { useState, useMemo } from "react";
import { useMemo, useState } from "react";
import Link from "next/link";
import LayoutContainer from "@/components/layout/LayoutContainer";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
type FAQItem = {
question: string;
@@ -16,15 +18,18 @@ const FAQ_CATEGORIES = [
items: [
{
question: "How do I place a preorder for corn?",
answer: "Find a stop near you on our homepage and click 'Shop This Stop' to add corn to your cart and select your pickup location. Preordering helps us bring the right amount of corn to each stop.",
answer:
"Find a stop near you on our homepage and click 'Shop This Stop' to add corn to your cart and select your pickup location. Preordering helps us bring the right amount of corn to each stop.",
},
{
question: "Can I order without selecting a pickup stop?",
answer: "Yes. Choose 'Shipping' at checkout and we will mail cooler boxes directly to your home. Shipping is available for orders of 4 or more dozen and is fulfilled after the season ends.",
answer:
"Yes. Choose 'Shipping' at checkout and we will mail cooler boxes directly to your home. Shipping is available for orders of 4 or more dozen and is fulfilled after the season ends.",
},
{
question: "What happens if I miss my pickup time?",
answer: "Please call the office at 970-323-6874 as soon as possible. We will do our best to accommodate, but unclaimed orders may be donated or sold after the pickup window closes.",
answer:
"Please call the office at 970-323-6874 as soon as possible. We will do our best to accommodate, but unclaimed orders may be donated or sold after the pickup window closes.",
},
],
},
@@ -33,15 +38,18 @@ const FAQ_CATEGORIES = [
items: [
{
question: "Do you ship corn?",
answer: "Yes. We ship Olathe Sweet Sweet Corn nationwide. Orders ship as cooler boxes after our field season ends in late summer. A minimum of 4 dozen is required for shipping.",
answer:
"Yes. We ship Olathe Sweet Sweet Corn nationwide. Orders ship as cooler boxes after our field season ends in late summer. A minimum of 4 dozen is required for shipping.",
},
{
question: "How are cooler boxes packaged?",
answer: "Corn is packed in insulated coolers with gel packs to keep it fresh during transit. Shipping costs are calculated at checkout based on your location.",
answer:
"Corn is packed in insulated coolers with gel packs to keep it fresh during transit. Shipping costs are calculated at checkout based on your location.",
},
{
question: "When will my shipped order arrive?",
answer: "Cooler box orders are shipped after the season ends in late summer. You will receive a tracking notification by email once your order ships. Most orders within the continental US arrive within 3-7 business days.",
answer:
"Cooler box orders are shipped after the season ends in late summer. You will receive a tracking notification by email once your order ships. Most orders within the continental US arrive within 37 business days.",
},
],
},
@@ -50,11 +58,13 @@ const FAQ_CATEGORIES = [
items: [
{
question: "How do I set up a wholesale account?",
answer: "Visit /wholesale/register to apply. We review applications and respond within 1-2 business days. Wholesale accounts are available to retailers, restaurants, and farm stands.",
answer:
"Visit /wholesale/register to apply. We review applications and respond within 12 business days. Wholesale accounts are available to retailers, restaurants, and farm stands.",
},
{
question: "Is there a minimum order for wholesale?",
answer: "Minimum wholesale order is $100 per order. Some account types have additional minimums — contact us for details.",
answer:
"Minimum wholesale order is $100 per order. Some account types have additional minimums — contact us for details.",
},
],
},
@@ -63,21 +73,30 @@ const FAQ_CATEGORIES = [
items: [
{
question: "What makes Olathe Sweet different?",
answer: "Olathe Sweet Sweet Corn is grown in the Uncompahgre Valley of Colorado, where high-altitude days and cool nights produce exceptionally sweet, tender corn. We have been growing this variety for over 40 years.",
answer:
"Olathe Sweet Sweet Corn is grown in the Uncompahgre Valley of Colorado, where high-altitude days and cool nights produce exceptionally sweet, tender corn. We have been growing this variety for over 40 years.",
},
{
question: "Is your corn organic?",
answer: "We use regenerative farming practices including no-till methods, cover crops, and reduced chemical inputs. We are not certified organic, but we take a thoughtful approach to land stewardship.",
answer:
"We use regenerative farming practices including no-till methods, cover crops, and reduced chemical inputs. We are not certified organic, but we take a thoughtful approach to land stewardship.",
},
{
question: "How should I store my corn?",
answer: "Store corn in the refrigerator and eat within 3-5 days. Do not husk it until you are ready to cook. For best sweetness, bring a cooler with ice to transport your corn home.",
answer:
"Store corn in the refrigerator and eat within 35 days. Do not husk it until you are ready to cook. For best sweetness, bring a cooler with ice to transport your corn home.",
},
],
},
];
function FAQAccordion({ items, searchQuery }: { items: FAQItem[]; searchQuery: string }) {
function FAQAccordion({
items,
searchQuery,
}: {
items: FAQItem[];
searchQuery: string;
}) {
const [open, setOpen] = useState<string | null>(null);
const filtered = useMemo(() => {
@@ -90,47 +109,74 @@ function FAQAccordion({ items, searchQuery }: { items: FAQItem[]; searchQuery: s
);
}, [items, searchQuery]);
if (filtered.length === 0) {
return (
<div className="space-y-2">
{filtered.length === 0 ? (
<div className="rounded-2xl bg-white p-6 sm:p-8 text-center shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-stone-300">
<p className="text-stone-500 text-sm">No results for &quot;{searchQuery}&quot;</p>
<p className="mt-1 text-stone-400 text-sm">Try a different term or browse all categories below.</p>
<div className="py-10 text-center">
<p className="text-stone-500 text-sm">
No results for &ldquo;{searchQuery}&rdquo;
</p>
<p className="mt-1 text-stone-400 text-sm">
Try a different term or browse the categories below.
</p>
</div>
) : (
<>
);
}
return (
<ul className="divide-y divide-stone-900/15 border-t border-b border-stone-900/15">
{filtered.map((item) => {
const isOpen = open === item.question;
return (
<div
key={item.question}
className="rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60 overflow-hidden transition-all duration-300 hover:ring-stone-300"
>
<button type="button"
<li key={item.question}>
<button
type="button"
onClick={() => setOpen(isOpen ? null : item.question)}
className="flex w-full items-center justify-between px-4 sm:px-6 py-4 sm:py-5 text-left group"
className="group flex w-full items-start justify-between gap-6 py-6 sm:py-7 text-left"
aria-expanded={isOpen}
>
<span className="font-semibold text-stone-950 pr-3 sm:pr-4 text-sm sm:text-[15px] leading-snug group-hover:text-emerald-700 transition-colors">
<span className="font-display text-stone-950 text-[clamp(1.1rem,1.7vw,1.35rem)] leading-[1.3] tracking-[-0.01em] pr-4 group-hover:text-emerald-800 transition-colors">
{item.question}
</span>
<span className={`flex-shrink-0 w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center transition-all duration-300 ${isOpen ? "bg-emerald-600 text-white rotate-180" : "bg-stone-100 text-stone-400 group-hover:bg-stone-200"}`}>
<svg className="w-3 h-3 sm:w-3.5 sm:h-3.5 transition-transform duration-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
<span
aria-hidden="true"
className={`mt-1 flex-shrink-0 inline-flex h-7 w-7 items-center justify-center transition-all duration-200 ${
isOpen
? "rotate-45 text-emerald-700"
: "text-stone-400 group-hover:text-stone-700"
}`}
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
</span>
</button>
<div className={`overflow-hidden transition-all duration-300 ease-out ${isOpen ? "max-h-48 opacity-100" : "max-h-0 opacity-0"}`}>
<div className="px-4 sm:px-6 pb-5 sm:pb-6 pt-1 border-t border-stone-100">
<p className="text-sm text-stone-500 leading-relaxed">{item.answer}</p>
</div>
<div
className={`grid transition-all duration-300 ease-out ${
isOpen
? "grid-rows-[1fr] opacity-100 pb-6 sm:pb-7"
: "grid-rows-[0fr] opacity-0"
}`}
>
<div className="overflow-hidden">
<p className="text-stone-600 leading-[1.7] max-w-3xl pr-10">
{item.answer}
</p>
</div>
</div>
</li>
);
})}
</>
)}
</div>
</ul>
);
}
@@ -141,63 +187,183 @@ export default function TuxedoFAQPage() {
<div className="min-h-screen bg-stone-50">
<StorefrontHeader brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
<main className="py-12 sm:py-16 md:py-20 px-4 sm:px-6">
<div className="max-w-3xl mx-auto">
{/* Page header */}
<div className="text-center mb-10 sm:mb-14">
<p className="text-[10px] sm:text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-3 sm:mb-4">Questions</p>
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-stone-950 leading-tight">FAQ</h1>
<p className="mt-4 sm:mt-5 text-base sm:text-lg text-stone-500 leading-relaxed">Everything you need to know about ordering, pickup, and shipping.</p>
<main>
{/* Hero — cream stone-50 with editorial framing. */}
<section className="relative bg-stone-50 pt-20 pb-12 sm:pt-24 sm:pb-16 border-b border-stone-200/60">
<LayoutContainer>
<div className="mx-auto max-w-3xl text-center">
<FadeOnScroll from="up">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
Help · Frequently Asked
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.08}>
<h1
className="font-display text-stone-950 text-[clamp(2.5rem,5.2vw,4.25rem)] leading-[1.02] tracking-[-0.02em]"
style={{ textWrap: "balance" }}
>
Questions,
<br className="hidden sm:block" />{" "}
<span className="italic text-amber-700/90">answered.</span>
</h1>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.18}>
<div className="mx-auto mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 to-amber-400" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.26}>
<p className="mt-8 text-stone-600 text-lg sm:text-xl leading-[1.65]">
Everything you need to know about ordering, pickup, and
shipping straight from the people who pack the boxes.
</p>
</FadeOnScroll>
</div>
</LayoutContainer>
</section>
{/* Search */}
<div className="mb-8 sm:mb-12 relative">
<div className="absolute inset-y-0 left-4 sm:left-5 flex items-center pointer-events-none">
<svg className="h-4 w-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
{/* Search — editorial bottom-border-only input. */}
<section className="relative bg-stone-50 py-12 sm:py-16">
<LayoutContainer>
<div className="mx-auto max-w-3xl">
<FadeOnScroll from="up">
<div className="relative">
<label htmlFor="faq-search" className="sr-only">
Search questions
</label>
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none">
<svg
className="h-4 w-4 text-stone-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
</div>
<input aria-label="Search Questions..."
<input
id="faq-search"
type="text"
placeholder="Search questions..."
placeholder="Search questions"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-2xl border border-stone-200 bg-white pl-10 sm:pl-11 pr-5 py-3.5 sm:py-4 text-sm sm:text-base text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900/20 transition-all duration-200 shadow-sm hover:shadow-md focus:shadow-lg"
className="w-full bg-transparent border-0 border-b border-stone-900/15 pl-7 pr-9 py-3 text-base text-stone-950 placeholder-stone-400 outline-none focus:border-emerald-700 focus:border-b-2 focus:pb-[11px] transition-colors"
/>
{searchQuery && (
<button type="button"
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute inset-y-0 right-4 flex items-center text-stone-400 hover:text-stone-600 transition-colors"
aria-label="Close">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
aria-label="Clear search"
className="absolute inset-y-0 right-0 flex items-center text-stone-400 hover:text-stone-700 transition-colors"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
</FadeOnScroll>
</div>
</LayoutContainer>
</section>
{/* FAQ categories */}
<div className="space-y-10 sm:space-y-14">
{/* Categories each rendered as a category with hairline-ruled
accordion list. No card wrappers; each item is a single row. */}
<section className="relative bg-stone-50 pb-24 sm:pb-32">
<LayoutContainer>
<div className="mx-auto max-w-3xl space-y-16 sm:space-y-20">
{FAQ_CATEGORIES.map((cat) => (
<div key={cat.category}>
<h2 className="text-[10px] sm:text-xs font-bold uppercase tracking-[0.2em] text-stone-400 mb-4 sm:mb-5">{cat.category}</h2>
<FadeOnScroll from="up">
<p className="text-[10px] font-bold uppercase tracking-[0.32em] text-emerald-700 mb-6">
{cat.category}
</p>
</FadeOnScroll>
<FAQAccordion items={cat.items} searchQuery={searchQuery} />
</div>
))}
</div>
</LayoutContainer>
</section>
{/* Still have questions CTA */}
<div className="mt-12 sm:mt-16 rounded-2xl sm:rounded-3xl bg-stone-950 px-6 sm:px-10 py-10 sm:py-12 text-center shadow-xl">
<p className="text-xl sm:text-2xl font-black text-white tracking-tight">Still have questions?</p>
<p className="mt-2 text-stone-400 text-sm">We are happy to help reach out anytime.</p>
<Link href="/tuxedo/contact" className="mt-6 sm:mt-8 inline-flex items-center gap-2 rounded-full bg-emerald-600 px-6 sm:px-8 py-3 sm:py-4 font-bold text-white hover:bg-emerald-500 active:bg-emerald-700 transition-all text-sm tracking-wider hover:shadow-lg hover:shadow-emerald-900/30">
Contact Us
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
{/* Closing CTA dark editorial panel with italic Fraunces
headline + Contact button. Same closing beat the homepage
and about pages use. */}
<section className="relative bg-stone-950 py-24 sm:py-32 overflow-hidden">
{/* Top amber rim. */}
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 h-px bg-amber-300/15"
/>
<LayoutContainer>
<div className="mx-auto max-w-2xl text-center">
<FadeOnScroll from="up">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-5">
Still Wondering
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.08}>
<h2
className="font-display text-stone-100 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
style={{ textWrap: "balance" }}
>
Still have
<br className="hidden sm:block" />{" "}
<span className="italic text-amber-200/85">questions?</span>
</h2>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.18}>
<div className="mx-auto mt-7 h-px w-16 bg-gradient-to-r from-emerald-500 to-amber-400" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.26}>
<p className="mt-7 text-stone-400 text-lg leading-[1.65]">
We&rsquo;re happy to help reach out anytime. Most
messages get a reply within one business day.
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.34}>
<div className="mt-10">
<Link
href="/tuxedo/contact"
className="group inline-flex items-center gap-3 rounded-full bg-gradient-to-br from-emerald-700 to-emerald-600 px-9 py-4 text-sm font-bold text-white shadow-[0_8px_32px_rgba(16,185,129,0.3),inset_0_1px_0_rgba(255,255,255,0.2)] hover:-translate-y-0.5 hover:shadow-[0_12px_40px_rgba(16,185,129,0.4),inset_0_1px_0_rgba(255,255,255,0.2)] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-950"
>
<span>Contact Us</span>
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
</Link>
</div>
</FadeOnScroll>
</div>
</LayoutContainer>
</section>
</main>
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
+1 -2
View File
@@ -1,5 +1,4 @@
import type { Metadata } from "next";
import FAQClientPage from "./FAQClientPage";
import { serializeJsonForScript } from "@/lib/safe-json";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
@@ -144,7 +143,7 @@ export default function TuxedoFAQLayout({ children }: { children: React.ReactNod
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonForScript(organizationJsonLd) }}
/>
<FAQClientPage />
{children}
</>
);
}
+473 -390
View File
File diff suppressed because it is too large Load Diff
+300 -98
View File
@@ -1,11 +1,13 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { gsap } from "gsap";
import LayoutContainer from "@/components/layout/LayoutContainer";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
import { TUXEDO_IMAGES } from "@/components/storefront/tuxedo-images";
import type { PublicStop } from "@/actions/stops";
type Props = {
@@ -16,22 +18,30 @@ type Props = {
export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [now] = useState(() => new Date());
// Subtle scroll-in stagger for the stops list. The Motion policy in
// ScrollAnimations.tsx caps translation to 16px / 320ms, which matches
// what we want here — the list reveals as a quiet beat, not a show.
useEffect(() => {
if (!containerRef.current) return;
const ctx = gsap.context(() => {
gsap.from(".stop-card", {
y: 30,
gsap.from(".stop-row", {
y: 16,
opacity: 0,
duration: 0.5,
stagger: 0.08,
ease: "power3.out",
duration: 0.32,
stagger: 0.04,
ease: "power1.out",
scrollTrigger: {
trigger: containerRef.current,
start: "top 90%",
toggleActions: "play none none none",
},
});
}, containerRef);
return () => ctx.revert();
}, []);
const now = new Date();
const upcomingStops = stops.filter((s) => new Date(s.date) >= now);
const pastStops = stops.filter((s) => new Date(s.date) < now);
@@ -39,123 +49,315 @@ export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props)
<div ref={containerRef} className="min-h-screen bg-stone-50">
<StorefrontHeader brandName={brandName} brandSlug={brandSlug} brandAccent="green" />
<main className="py-16 md:py-20">
<LayoutContainer>
<div className="max-w-4xl mx-auto mb-12 text-center">
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-emerald-600 mb-4">
{brandName}
</p>
<h1 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 mb-4">
Pickup Stops
</h1>
<p className="text-stone-500 text-lg">
Fresh Olathe Sweet&trade; sweet corn delivered to a stop near you.
</p>
<div className="mt-6 h-1 w-12 bg-emerald-600 mx-auto rounded-full" />
<main>
{/* Hero dark editorial opener with the field photograph as a
soft half-bleed at the bottom, same register as the
Contact page hero. */}
<section className="relative bg-stone-950 overflow-hidden isolate">
{/* Soft field image, bottom-anchored, partially visible
behind the headline column. */}
<div className="absolute inset-x-0 bottom-0 h-2/3 -z-10">
<Image
src={TUXEDO_IMAGES.heroField}
alt=""
fill
sizes="100vw"
quality={85}
priority
className="object-cover object-center filter-editorial opacity-40"
/>
<div className="absolute inset-0 bg-gradient-to-b from-stone-950 via-stone-950/70 to-stone-950" />
</div>
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/30 z-10"
/>
{upcomingStops.length === 0 ? (
<div className="max-w-2xl mx-auto text-center py-16">
<div className="w-20 h-20 rounded-2xl bg-stone-100 flex items-center justify-center mx-auto mb-6">
<svg className="w-10 h-10 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<h2 className="text-2xl font-bold text-stone-800 mb-2">No Upcoming Stops Yet</h2>
<p className="text-stone-500 mb-8 max-w-md mx-auto">
The corn is still ripening in the field. New pickup stops are added every week check back soon, or browse the full season schedule.
<LayoutContainer>
<div className="relative py-20 sm:py-28 lg:py-32 max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-6">
{brandName} · Summer 2026
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<Link
href="/tuxedo"
className="inline-flex items-center gap-2 rounded-2xl bg-stone-950 px-6 py-3 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-900 transition-colors"
<h1
className="font-display text-stone-50 text-[clamp(2.5rem,5.4vw,4.5rem)] leading-[1.02] tracking-[-0.02em]"
style={{ textWrap: "balance" }}
>
Back to Tuxedo Corn
</Link>
Stops
<br className="hidden sm:block" />{" "}
<span className="italic text-amber-200/90">near you.</span>
</h1>
<div className="mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 via-amber-400 to-amber-300/0" />
<p className="mt-8 text-stone-300 text-lg sm:text-xl leading-[1.65]">
Fresh Olathe Sweet&trade; sweet corn delivered to a stop
near you. Preorder for pickup, or just show up we&rsquo;ll
have coolers on the truck.
</p>
<div className="mt-10 flex flex-wrap items-center gap-x-8 gap-y-3 text-[11px] uppercase tracking-[0.28em]">
<span className="text-amber-200/80">
{upcomingStops.length} upcoming
</span>
<span className="text-stone-700">·</span>
<span className="text-stone-400">Updated weekly</span>
<span className="text-stone-700">·</span>
<Link
href="/api/tuxedo/schedule-pdf"
download
className="inline-flex items-center gap-2 rounded-2xl bg-white px-6 py-3 text-sm font-semibold text-stone-700 ring-1 ring-stone-200 hover:bg-stone-50 transition-colors"
className="text-stone-400 hover:text-amber-200/80 underline underline-offset-4 transition-colors"
>
Download Full Schedule
Download full schedule
</Link>
</div>
</div>
</LayoutContainer>
</section>
{/* Stops list hairline-ruled magazine sidebar style. Each
row is a single horizontal hairline + date numerals + city
+ location + time + chevron. No cards, no gradients. */}
<section className="relative bg-stone-50 py-20 sm:py-24">
<LayoutContainer>
{upcomingStops.length === 0 ? (
<EmptyStops />
) : (
<div className="max-w-4xl mx-auto space-y-3">
<div className="mx-auto max-w-5xl">
{/* Eyebrow + hairline-ruled list header */}
<div className="flex items-baseline justify-between gap-4 mb-8">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700">
Upcoming
</p>
<p className="text-[10px] uppercase tracking-[0.3em] text-stone-400 tabular-nums">
{upcomingStops.length} stops
</p>
</div>
<ol className="border-t border-stone-900/15">
{upcomingStops.map((stop) => (
<Link
key={stop.id}
href={`/${brandSlug}/stops/${stop.slug}`}
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-emerald-200"
>
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex-shrink-0">
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex flex-col items-center justify-center text-white shadow-md shadow-emerald-500/20">
<span className="text-[10px] font-bold uppercase opacity-80">
{new Date(stop.date).toLocaleDateString("en-US", { month: "short" })}
</span>
<span className="text-xl font-black leading-none">
{new Date(stop.date).getDate()}
</span>
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-stone-950 group-hover:text-emerald-700 transition-colors">
{stop.city}, {stop.state}
</h3>
<p className="text-sm text-stone-500 truncate">{stop.location}</p>
{stop.address && (
<p className="text-xs text-stone-400 truncate">{stop.address}</p>
)}
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm font-semibold text-stone-700">{stop.time}</p>
{stop.cutoff_time && (
<p className="text-xs text-stone-400">Order by {new Date(stop.cutoff_time).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</p>
)}
</div>
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-emerald-50 flex items-center justify-center group-hover:bg-emerald-500 transition-colors">
<svg className="w-5 h-5 text-emerald-600 group-hover:text-white transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</Link>
<StopRow key={stop.id} stop={stop} brandSlug={brandSlug} />
))}
</ol>
{pastStops.length > 0 && (
<div className="mt-8 pt-8 border-t border-stone-200">
<h2 className="text-sm font-bold uppercase tracking-widest text-stone-400 mb-4">Past Stops</h2>
<div className="space-y-2 opacity-60">
<div className="mt-20">
<div className="flex items-baseline justify-between gap-4 mb-8">
<p className="text-[10px] font-bold uppercase tracking-[0.32em] text-stone-400">
Past Stops
</p>
<p className="text-[10px] uppercase tracking-[0.3em] text-stone-400 tabular-nums">
Last {Math.min(5, pastStops.length)}
</p>
</div>
<ol className="border-t border-stone-900/10 opacity-70">
{pastStops.slice(0, 5).map((stop) => (
<div
<StopRow
key={stop.id}
className="flex items-center gap-4 p-4 rounded-xl bg-white/50 text-stone-500"
>
<div className="w-10 h-10 rounded-lg bg-stone-200 flex items-center justify-center text-xs font-bold text-stone-500">
{new Date(stop.date).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</div>
<div className="flex-1">
<p className="font-medium">{stop.city}, {stop.state}</p>
<p className="text-xs">{stop.location}</p>
</div>
<span className="text-xs">Completed</span>
</div>
stop={stop}
brandSlug={brandSlug}
past
/>
))}
</div>
</ol>
</div>
)}
</div>
)}
</LayoutContainer>
</section>
</main>
<StorefrontFooter brandName={brandName} brandSlug={brandSlug} brandAccent="green" />
</div>
);
}
// ── STOP ROW — hairline-ruled magazine-sidebar cell ────────────────
// Date numerals on the left (large tabular day, small month above),
// city/state + location + time in the middle, chevron on the right.
// All hairline-divided rows; the list reads as a sidebar, not a feed.
function StopRow({
stop,
brandSlug,
past = false,
}: {
stop: PublicStop;
brandSlug: string;
past?: boolean;
}) {
const date = new Date(stop.date);
const day = date.getDate();
const month = date.toLocaleDateString("en-US", { month: "short" });
const weekday = date.toLocaleDateString("en-US", { weekday: "short" });
const content = (
<div className="grid grid-cols-12 gap-4 sm:gap-6 items-center py-5 sm:py-6 group">
{/* Date column — big tabular day, small month/weekday above */}
<div className="col-span-3 sm:col-span-2">
<p
className={`text-[10px] uppercase tracking-[0.28em] tabular-nums ${
past ? "text-stone-400" : "text-emerald-700"
}`}
>
{weekday} · {month}
</p>
<p
className={`mt-1 font-display tabular-nums leading-none tracking-[-0.02em] ${
past
? "text-stone-400 text-3xl sm:text-4xl"
: "text-stone-950 text-4xl sm:text-5xl"
}`}
>
{day}
</p>
</div>
{/* City + location */}
<div className="col-span-9 sm:col-span-6 min-w-0">
<h3
className={`font-display leading-[1.15] tracking-[-0.012em] ${
past
? "text-stone-500 text-base sm:text-lg"
: "text-stone-950 text-lg sm:text-xl group-hover:text-emerald-800 transition-colors"
}`}
>
{stop.city}, {stop.state}
</h3>
<p
className={`mt-1 truncate ${past ? "text-stone-400 text-sm" : "text-stone-500 text-sm"}`}
>
{stop.location}
</p>
{stop.address && (
<p
className={`mt-0.5 truncate ${past ? "text-stone-400 text-xs" : "text-stone-400 text-xs"}`}
>
{stop.address}
</p>
)}
</div>
{/* Time + cutoff */}
<div className="col-span-8 sm:col-span-3 text-right">
<p
className={`font-mono tabular-nums text-sm ${
past ? "text-stone-400" : "text-stone-950"
}`}
>
{stop.time}
</p>
{!past && stop.cutoff_time && (
<p className="mt-1 text-[10px] uppercase tracking-[0.2em] text-stone-400">
Order by{" "}
{new Date(stop.cutoff_time).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
})}
</p>
)}
{past && (
<p className="mt-1 text-[10px] uppercase tracking-[0.2em] text-stone-400">
Completed
</p>
)}
</div>
{/* Chevron — only on upcoming rows */}
<div className="hidden sm:flex col-span-1 justify-end">
{!past && (
<svg
className="w-4 h-4 text-stone-300 group-hover:text-emerald-700 group-hover:translate-x-1 transition-all"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
)}
</div>
</div>
);
const inner = (
<li className="stop-row border-b border-stone-900/15 last:border-b-0">
{content}
</li>
);
if (past) {
return inner;
}
return (
<Link
href={`/${brandSlug}/stops/${stop.slug}`}
className="stop-row block border-b border-stone-900/15 last:border-b-0 hover:bg-stone-100/60 transition-colors"
>
{content}
</Link>
);
}
// ── EMPTY STATE — editorial Fraunces italic copy ──────────────────
// Same "no stops yet" message as before, but rendered as a quiet
// editorial panel instead of an icon-in-rounded-box illustration.
function EmptyStops() {
return (
<div className="mx-auto max-w-2xl text-center py-12">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
Coming Soon
</p>
<h2
className="font-display text-stone-950 text-[clamp(2rem,4.4vw,3.25rem)] leading-[1.05] tracking-[-0.02em]"
style={{ textWrap: "balance" }}
>
The corn is still ripening
<br className="hidden sm:block" />{" "}
<span className="italic text-amber-700/90">in the field.</span>
</h2>
<div className="mx-auto mt-7 h-px w-12 bg-emerald-700/60" />
<p className="mt-7 text-stone-600 text-lg leading-[1.65]">
New pickup stops are added every week. Check back soon, or browse
the full season schedule below.
</p>
<div className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-3">
<Link
href="/tuxedo"
className="group inline-flex items-center gap-3 rounded-full bg-gradient-to-br from-emerald-700 to-emerald-600 px-8 py-3.5 text-sm font-bold text-white shadow-[0_8px_32px_rgba(16,185,129,0.3),inset_0_1px_0_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200"
>
<span>Back to Tuxedo Corn</span>
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
<Link
href="/api/tuxedo/schedule-pdf"
download
className="inline-flex items-center gap-2 rounded-full bg-white px-8 py-3.5 text-sm font-bold text-stone-900 ring-1 ring-stone-200 hover:ring-emerald-600 hover:text-emerald-700 transition-all"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Download Schedule
</Link>
</div>
</div>
);
}
+248 -99
View File
@@ -10,6 +10,7 @@ import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
import { getStorefrontStopById } from "@/actions/storefront";
import { formatDate } from "@/lib/format-date";
import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
type Product = {
id: string;
@@ -31,30 +32,25 @@ type Stop = {
time: string;
location: string;
brand_id: string;
cutoff_time?: string | null;
address?: string | null;
slug?: string;
};
export default function StopPage() {
const BRAND_NAME = "Tuxedo Corn";
const BRAND_SLUG = "tuxedo";
export default function TuxedoStopPage() {
const params = useParams();
const slug = params.id as string;
const [stop, setStop] = useState<{
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
} | null>(null);
const [stop, setStop] = useState<Stop | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [brandSlug, setBrandSlug] = useState("tuxedo");
const [brandAccent, setBrandAccent] = useState<"green" | "orange" | "blue">("green");
useEffect(() => {
async function load() {
const result = await getStorefrontStopById(slug);
if (!result) return;
setStop(result.stop as unknown as Stop);
setProducts(result.products as unknown as Product[]);
}
@@ -64,38 +60,89 @@ export default function StopPage() {
if (!stop) {
return (
<div className="min-h-screen bg-stone-50">
<StorefrontHeader brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
<main className="py-20">
<StorefrontHeader
brandName={BRAND_NAME}
brandSlug={BRAND_SLUG}
brandAccent="green"
/>
<main className="py-32">
<LayoutContainer>
<div className="max-w-5xl mx-auto text-center">
<h1 className="text-5xl font-black tracking-tight text-stone-950">Stop Not Found</h1>
<div className="mx-auto max-w-3xl text-center">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-700 mb-5">
Off the Route
</p>
<h1
className="font-display text-stone-950 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
style={{ textWrap: "balance" }}
>
That stop isn&rsquo;t{" "}
<span className="italic text-amber-700/90">on the calendar.</span>
</h1>
<div className="mx-auto mt-7 h-px w-12 bg-emerald-700/60" />
<p className="mt-7 text-stone-600 leading-[1.65]">
It may have passed, or the URL is off by a character.
Browse the full list to find a stop near you.
</p>
<div className="mt-10">
<Link
href="/tuxedo/stops"
className="group inline-flex items-center gap-3 rounded-full bg-stone-950 px-8 py-3.5 text-sm font-bold text-white hover:bg-stone-800 transition-all"
>
<svg
className="w-4 h-4 transition-transform group-hover:-translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
</svg>
<span>All Stops</span>
</Link>
</div>
</div>
</LayoutContainer>
</main>
<StorefrontFooter
brandName={BRAND_NAME}
brandSlug={BRAND_SLUG}
brandAccent="green"
/>
</div>
);
}
const isBlue = brandAccent === "blue";
const brandLabel = isBlue ? "Indian River Direct" : "Tuxedo Corn";
const date = new Date(stop.date);
const day = date.getDate();
const weekday = date.toLocaleDateString("en-US", { weekday: "long" });
const monthYear = date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
return (
<div className="min-h-screen bg-stone-50">
<StopSetEffect stop={stop} />
<StorefrontHeader
brandName={brandLabel}
brandSlug={brandSlug}
brandAccent={brandAccent}
brandName={BRAND_NAME}
brandSlug={BRAND_SLUG}
brandAccent="green"
/>
<main className="py-16 md:py-20">
<main>
{/* Back nav — kept slim, no white card wrapper. */}
<div className="bg-stone-50 border-b border-stone-200/60">
<LayoutContainer>
<div className="max-w-5xl mx-auto">
{/* Back navigation */}
<div className="mb-10 flex items-center gap-2">
<div className="py-5 flex items-center gap-2 text-sm">
<Link
href={`/${brandSlug}#stops`}
className="flex items-center gap-2 text-sm font-medium text-stone-500 hover:text-stone-800 transition-colors group"
href={`/${BRAND_SLUG}#stops`}
className="flex items-center gap-2 text-stone-500 hover:text-stone-950 transition-colors group"
>
<svg
className="h-4 w-4 transition-transform duration-200 group-hover:-translate-x-1"
@@ -103,89 +150,164 @@ export default function StopPage() {
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
</svg>
All Stops
</Link>
<span className="text-stone-300">/</span>
<span className="text-sm text-stone-700 font-medium">{stop.city}, {stop.state}</span>
<span className="text-stone-700 font-medium truncate">
{stop.city}, {stop.state}
</span>
</div>
</LayoutContainer>
</div>
{/* Stop header */}
<div className="mb-12">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">
{brandLabel}
{/* Hero dark editorial opener. Eyebrow + huge Fraunces
city/state headline with the date numerals set as part of
the type composition, not as a separate stat block. */}
<section className="relative bg-stone-950 overflow-hidden isolate">
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/30 z-10"
/>
<LayoutContainer>
<div className="relative py-20 sm:py-28 lg:py-32 grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-end">
{/* Left: copy + headline */}
<div className="lg:col-span-8">
<FadeOnScroll from="up">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-6">
Pickup Stop · {monthYear}
</p>
<h1 className="text-5xl md:text-7xl font-black tracking-tight text-stone-950 leading-[1.0]">
{stop.city},<br className="hidden md:block" /> {stop.state}
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.08}>
<h1
className="font-display text-stone-50 text-[clamp(2.75rem,7vw,5.5rem)] leading-[0.98] tracking-[-0.025em]"
style={{ textWrap: "balance" }}
>
{stop.city},
<br className="hidden sm:block" />{" "}
<span className="italic text-amber-200/90">
{stop.state}.
</span>
</h1>
<p className="mt-5 max-w-xl text-lg text-stone-500 leading-relaxed">
{isBlue
? "Order fresh Florida citrus for pickup at this stop."
: "Order fresh Olathe Sweet&trade; sweet corn for pickup at this stop."}
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.18}>
<div className="mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 via-amber-400 to-amber-300/0" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.26}>
<p className="mt-8 text-stone-300 text-lg sm:text-xl leading-[1.65] max-w-xl">
Order fresh Olathe Sweet&trade; sweet corn for pickup
at this stop. We&rsquo;ll have it on the truck the
morning of ready when you are.
</p>
<div className="mt-6 h-px w-12 bg-emerald-600" />
</FadeOnScroll>
</div>
{/* Stop info */}
<div className="rounded-3xl bg-white p-8 mb-14 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-xl hover:ring-stone-300/60">
<div className="grid gap-4 md:grid-cols-3">
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50">
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}>
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-emerald-600"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Date</p>
<p className="font-bold text-stone-950">{formatDate(stop.date)}</p>
</div>
</div>
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50">
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}>
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-emerald-600"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Time</p>
<p className="font-bold text-stone-950">{stop.time}</p>
</div>
</div>
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50">
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}>
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-emerald-600"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Location</p>
<p className="font-bold text-stone-950 leading-tight">{stop.location}</p>
</div>
</div>
{/* Right: date as a big editorial stamp. */}
<div className="lg:col-span-4 lg:text-right">
<FadeOnScroll from="right">
<p className="text-[10px] uppercase tracking-[0.32em] text-amber-300/70 mb-2 tabular-nums">
{weekday}
</p>
<p className="font-display tabular-nums text-stone-50 text-[clamp(4rem,8vw,6.5rem)] leading-none tracking-[-0.03em]">
{day}
</p>
<p className="mt-2 text-[11px] uppercase tracking-[0.28em] text-stone-400 tabular-nums">
{date.toLocaleDateString("en-US", {
month: "long",
year: "numeric",
})}
</p>
</FadeOnScroll>
</div>
</div>
</LayoutContainer>
</section>
{/* Available Products — editorial header */}
<section>
<div className="mb-10">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Farm-Direct</p>
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-tight">
Available Products
{/* Stop info — hairline-ruled four-column data grid. Date /
Time / Location / Order by. Same magazine-sidebar register
as the homepage's HarvestEditorial numbers strip. */}
<section className="relative bg-stone-50 py-16 sm:py-20 border-b border-stone-200/60">
<LayoutContainer>
<div className="mx-auto max-w-5xl">
<ol className="grid grid-cols-2 lg:grid-cols-4 gap-y-10 lg:gap-y-0 lg:gap-x-10 border-t border-b border-stone-900/15">
<StopInfoPoint label="Date" detail={formatDate(stop.date)} />
<StopInfoPoint label="Time" detail={stop.time} mono />
<StopInfoPoint label="Location" detail={stop.location} />
<StopInfoPoint
label="Order By"
detail={
stop.cutoff_time
? new Date(stop.cutoff_time).toLocaleTimeString(
"en-US",
{ hour: "numeric", minute: "2-digit" }
)
: "Day-of"
}
mono
/>
</ol>
</div>
</LayoutContainer>
</section>
{/* Available Products cream section with editorial framing,
same register as the homepage ProductsSection header. */}
<section className="relative bg-stone-50 py-24 sm:py-32">
<LayoutContainer>
<div className="mx-auto max-w-5xl">
<header className="mb-14 sm:mb-20 grid grid-cols-1 lg:grid-cols-12 gap-x-10 gap-y-6 items-end">
<div className="lg:col-span-7">
<FadeOnScroll from="up">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
Farm-Direct · This Stop
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.08}>
<h2
className="font-display text-stone-950 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
style={{ textWrap: "balance" }}
>
Preorder
<br className="hidden sm:block" />{" "}
<span className="italic text-amber-700/90">
for this stop.
</span>
</h2>
<div className="mt-5 h-px w-12 bg-emerald-600" />
<p className="mt-5 text-base text-stone-500 leading-relaxed">
Preorder for pickup add items below and we will have them ready at the stop.
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.16}>
<div className="mt-7 h-px w-12 bg-emerald-700/60" />
</FadeOnScroll>
</div>
<div className="lg:col-span-5">
<FadeOnScroll from="up" delay={0.2}>
<p className="text-stone-600 text-base leading-[1.7] lg:max-w-md lg:ml-auto">
Add items below and we&rsquo;ll have them ready at
the stop. Preordering helps us bring the right
amount of corn to each pickup.
</p>
</FadeOnScroll>
</div>
</header>
{products.length === 0 ? (
<div className="rounded-3xl bg-white p-12 text-center shadow-sm ring-1 ring-stone-200/60">
<p className="text-stone-500">No products available for this stop.</p>
<div className="border-t border-b border-stone-900/15 py-16 text-center">
<p className="text-stone-500">
No products available for this stop yet.
</p>
</div>
) : (
<div className="grid gap-8 md:grid-cols-3">
<div className="grid gap-x-8 gap-y-12 md:grid-cols-2 lg:grid-cols-3">
{products.map((product) => (
<ProductCard
key={product.id}
@@ -195,26 +317,53 @@ export default function StopPage() {
price={`$${product.price}`}
type={product.type}
imageUrl={product.image_url}
brandSlug={brandSlug}
brandName={brandLabel}
brandSlug={BRAND_SLUG}
brandName={BRAND_NAME}
brandId={product.brand_id}
brandAccent={brandAccent}
brandAccent="green"
is_taxable={product.is_taxable}
pickup_type={product.pickup_type}
/>
))}
</div>
)}
</section>
</div>
</LayoutContainer>
</section>
</main>
<StorefrontFooter
brandName={brandLabel}
brandSlug={brandSlug}
brandAccent={brandAccent}
brandName={BRAND_NAME}
brandSlug={BRAND_SLUG}
brandAccent="green"
/>
</div>
);
}
// One column of the stop-info data strip — same hairline + label
// treatment as the homepage's HarvestEditorial DataPoint.
function StopInfoPoint({
label,
detail,
mono = false,
}: {
label: string;
detail: string;
mono?: boolean;
}) {
return (
<li className="relative pt-8 lg:pt-12">
<p className="text-[10px] font-bold uppercase tracking-[0.3em] text-emerald-700">
{label}
</p>
<p
className={`mt-4 text-stone-950 text-base leading-[1.55] ${
mono ? "font-mono tabular-nums tracking-tight" : ""
}`}
>
{detail}
</p>
</li>
);
}
+9 -8
View File
@@ -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}
/>
);
+58
View File
@@ -0,0 +1,58 @@
/**
* Cycle 6 Field-worker sub-PWA layout. Overrides the root layout's
* `manifest`, `themeColor`, and `applicationName` so the worker PWA
* installs as "Water Log — Tuxedo" rather than "Route Commerce".
*
* Per Next.js App Router semantics, child layouts can override any
* `Metadata` field set by an ancestor `manifest` and `themeColor`
* both qualify. The root layout still provides fonts + providers.
*/
import type { Metadata, Viewport } from "next";
import { FieldSWRegistration } from "@/components/pwa/FieldSWRegistration";
import { FieldInstallPrompt } from "@/components/pwa/FieldInstallPrompt";
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: "#14532d",
viewportFit: "cover",
};
export const metadata: Metadata = {
title: {
default: "Water Log · Tuxedo",
template: "%s · Water Log",
},
description:
"Field log of Tuxedo water readings + worker time tracking — PIN-protected, mobile-first.",
applicationName: "Water Log",
manifest: "/manifest-field.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Water Log",
},
robots: {
index: false,
follow: false,
},
};
export default function WaterLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{/* Client-side: register the scoped SW + show the field-
branded install prompt. Both are no-op on first paint and
only fire under user-instigated conditions. */}
<FieldSWRegistration />
<FieldInstallPrompt />
{children}
</>
);
}
+1 -1
View File
@@ -17,7 +17,7 @@ import type { Metadata, Viewport } from "next";
import WaterFieldClient from "@/components/water/WaterFieldClient";
export const metadata: Metadata = {
title: "Water Log · Tuxedo Ditch Co.",
title: "Water Log · Tuxedo Corn",
description:
"Field log of headgate water readings — PIN-protected, mobile-only.",
robots: {
-52
View File
@@ -1,52 +0,0 @@
"use client";
import Link from "next/link";
import { m as motion } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
export default function CTASection() {
return (
<section className="py-20 bg-white relative">
<div className="absolute inset-0 bg-gradient-to-b from-stone-100 to-transparent" />
<LayoutContainer>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="relative text-center"
>
<h2 className="text-3xl md:text-4xl font-black tracking-tight text-stone-950">Ready to Order?</h2>
<p className="mt-4 text-stone-500 text-lg">Experience the difference four decades of excellence makes.</p>
<div className="mt-10 flex flex-wrap justify-center gap-4">
<Link
href="/tuxedo#stops"
className="group relative inline-flex items-center gap-3 rounded-full bg-stone-900 px-8 py-4 font-bold text-white overflow-hidden"
>
<span className="absolute inset-0 bg-gradient-to-r from-emerald-700 to-emerald-600 opacity-0 group-hover:opacity-100 transition-opacity" />
<span className="relative flex items-center gap-2">
Find a Stop
<svg className="w-5 h-5 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
</svg>
</span>
</Link>
<Link
href="/tuxedo#products"
className="group relative inline-flex items-center gap-3 rounded-full bg-white px-8 py-4 font-bold text-stone-900 ring-2 ring-stone-200 hover:ring-emerald-600 hover:text-emerald-700 transition-all"
>
<span className="relative">
Shop Products
<svg className="inline w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
</svg>
</span>
</Link>
</div>
</motion.div>
</LayoutContainer>
</section>
);
}
-89
View File
@@ -1,89 +0,0 @@
"use client";
import { m as motion } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
interface ContactSectionProps {
address: string;
}
export default function ContactSection({ address }: ContactSectionProps) {
return (
<section className="py-24 md:py-32 bg-stone-950 text-white relative overflow-hidden">
<div className="absolute inset-0">
<div className="absolute top-0 left-1/3 w-96 h-96 bg-emerald-900/20 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/3 w-80 h-80 bg-amber-900/10 rounded-full blur-3xl" />
</div>
<LayoutContainer>
<div className="relative">
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center mb-16"
>
<span className="text-[11px] font-bold uppercase tracking-[0.25em] text-emerald-400/60">Get in Touch</span>
<h2 className="mt-4 text-4xl md:text-5xl font-black tracking-tight text-white leading-tight">
Visit Our Farm
</h2>
</motion.div>
<div className="grid md:grid-cols-3 gap-12 max-w-4xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.1, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center group"
>
<div className="w-16 h-16 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-emerald-900 transition-colors">
<svg className="w-7 h-7 text-stone-400 group-hover:text-emerald-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Address</p>
<p className="text-stone-300 leading-relaxed">{address}</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.2, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center group"
>
<div className="w-16 h-16 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-emerald-900 transition-colors">
<svg className="w-7 h-7 text-stone-400 group-hover:text-emerald-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" />
</svg>
</div>
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Phone</p>
<p className="text-stone-300">Shipping: 970-323-5631</p>
<p className="text-stone-300 mt-1">Office: 970-323-6874</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.3, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center group"
>
<div className="w-16 h-16 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-emerald-900 transition-colors">
<svg className="w-7 h-7 text-stone-400 group-hover:text-emerald-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Hours</p>
<p className="text-stone-300">MF: 7AM6PM</p>
<p className="text-stone-300 mt-1">Sat & Sun: 8AM2PM</p>
</motion.div>
</div>
</div>
</LayoutContainer>
</section>
);
}
@@ -1,115 +0,0 @@
"use client";
import { m as motion } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
const FAMILY_MEMBERS = [
{
initials: "JH",
name: "John Harold",
role: "Founder",
bio: "John Harold began selling Olathe's sweet corn directly from pickups in the 1970s. With unwavering dedication to community, family, and agriculture, he built Tuxedo Corn into a thriving family operation. Prior to farming, John established the first Group Home on the Western Slope and opened Colorow Care Center in Montrose County.",
era: "1970s",
},
{
initials: "DH",
name: "David Harold",
role: "Second Generation",
bio: "David leads sustainability efforts across Harold farms, implementing drip irrigation and joining the Fair Food Program to ensure responsible labor practices. He continues the family's commitment to raising top-quality corn while protecting the land for future generations.",
era: "Present",
},
{
initials: "JW",
name: "John William Harold",
role: "Mexico Operations",
bio: "John William has worked in Guaymas, Sonora, Mexico for over three decades growing sweet corn, onions, wheat, and vegetables. His international operations extend the Harold family farming tradition across borders.",
era: "1990sPresent",
},
{
initials: "JH",
name: "Joseph Harold",
role: "Third Generation",
bio: "Joseph works at the corn shed during summer and handles photography and beverages, carrying the family tradition into the next generation while learning every aspect of the business.",
era: "Present",
},
];
// Family member card with hover effects
function FamilyCard({ member, index }: { member: typeof FAMILY_MEMBERS[0]; index: number }) {
return (
<motion.div
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.6, delay: index * 0.12, ease: [0.22, 0.61, 0.36, 1] }}
whileHover={{ y: -6 }}
className="group relative"
>
{/* Era badge */}
<div className="absolute -top-3 -left-3 z-10">
<span className="inline-block bg-stone-900 text-stone-200 text-[10px] font-bold uppercase tracking-widest px-3 py-1.5 rounded-full shadow-lg">
{member.era}
</span>
</div>
<div className="relative bg-gradient-to-b from-white to-stone-50 rounded-3xl p-8 shadow-lg shadow-black/5 ring-1 ring-stone-200/40 overflow-hidden h-full">
{/* Decorative top line */}
<motion.div
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-500 origin-left"
/>
{/* Avatar */}
<div className="mb-6 relative">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-stone-900 to-stone-800 flex items-center justify-center shadow-xl shadow-stone-900/30">
<span className="text-2xl font-black text-white tracking-tight">{member.initials}</span>
</div>
{/* Decorative corner accent */}
<div className="absolute -bottom-2 -right-2 w-8 h-8 bg-emerald-600/20 rounded-full blur-sm" />
</div>
{/* Content */}
<div>
<h3 className="text-xl font-black text-stone-950 tracking-tight">{member.name}</h3>
<p className="mt-2 text-[11px] font-bold uppercase tracking-widest text-emerald-700">{member.role}</p>
<p className="mt-4 text-sm leading-relaxed text-stone-600">{member.bio}</p>
</div>
{/* Bottom accent */}
<div className="absolute bottom-0 left-8 right-8 h-px bg-gradient-to-r from-transparent via-stone-200 to-transparent" />
</div>
</motion.div>
);
}
export default function FamilyTimelineSection() {
return (
<section className="py-24 md:py-32 bg-stone-100 relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(16,100,50,0.08)_0%,transparent_50%)]" />
<LayoutContainer>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center mb-20"
>
<span className="text-[11px] font-bold uppercase tracking-[0.25em] text-emerald-700">The People Behind the Corn</span>
<h2 className="mt-4 text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-tight">
The Harold Family
</h2>
<div className="mt-8 mx-auto h-px w-24 bg-gradient-to-r from-emerald-600 via-emerald-400 to-transparent" />
</motion.div>
<div className="grid md:grid-cols-2 gap-8 max-w-5xl mx-auto">
{FAMILY_MEMBERS.map((member, index) => (
<FamilyCard key={member.name} member={member} index={index} />
))}
</div>
</LayoutContainer>
</section>
);
}
-76
View File
@@ -1,76 +0,0 @@
"use client";
import { m as motion } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
export default function MissionSection() {
return (
<section className="py-24 md:py-32 bg-stone-50 relative overflow-hidden">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-emerald-100/30 rounded-full blur-3xl -z-10" />
<LayoutContainer>
<div className="mx-auto max-w-4xl">
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center mb-16"
>
<span className="text-[11px] font-bold uppercase tracking-[0.25em] text-emerald-700">Our Mission</span>
<h2 className="mt-4 text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-tight">
Raising the Best Corn<br />in the World
</h2>
</motion.div>
<div className="grid md:grid-cols-2 gap-12">
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.15, ease: [0.22, 0.61, 0.36, 1] }}
className="prose prose-lg"
>
<p className="text-stone-600 leading-relaxed text-lg">
Tuxedo Corn is the <strong className="text-stone-900">exclusive grower and shipper</strong> of Olathe Sweet Sweet Corn the only corn that carries that prestigious name. When you order from us, you receive the real thing: grown by the only family that planted roots in Olathe, Colorado over forty years ago.
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.25, ease: [0.22, 0.61, 0.36, 1] }}
className="prose prose-lg"
>
<p className="text-stone-600 leading-relaxed text-lg">
Not all sweet corn is equal. Olathe Sweet was developed for our high-altitude mountain climate, where <strong className="text-stone-900">intense summer sun by day meets cool mountain air at night</strong>. That combination coaxes extraordinary sweetness and tenderness out of every single ear.
</p>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.35, ease: [0.22, 0.61, 0.36, 1] }}
className="mt-16 bg-white rounded-3xl p-10 shadow-lg shadow-stone-900/5 ring-1 ring-stone-200/50"
>
<div className="flex items-start gap-6">
<div className="w-14 h-14 rounded-2xl bg-emerald-100 flex items-center justify-center flex-shrink-0">
<svg className="w-7 h-7 text-emerald-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</div>
<div>
<h3 className="text-lg font-bold text-stone-950 mb-3">Grown with Future Generations in Mind</h3>
<p className="text-stone-600 leading-relaxed">
We farm regeneratively no-till and minimal-till methods protect soil structure, cover crops naturally cycle nutrients, and reduced chemical inputs keep our mountain water pure. Every ear is hand-picked at peak freshness, inspected by our dedicated crew. Not machine-harvested. Not sorted by strangers. <strong className="text-stone-900">Hand-picked.</strong>
</p>
</div>
</div>
</motion.div>
</div>
</LayoutContainer>
</section>
);
}
@@ -143,6 +143,7 @@ type FormState = {
showTextAlerts: boolean;
schedulePdfNotes: string;
heroImageUrl: string;
heroVideoUrl: string;
brandPrimaryColor: string;
brandSecondaryColor: string;
brandBgColor: string;
@@ -189,6 +190,7 @@ function buildInitialFormState(settings: BrandSettings | null, brandName: string
showTextAlerts: settings?.show_text_alerts ?? false,
schedulePdfNotes: settings?.schedule_pdf_notes ?? "",
heroImageUrl: settings?.hero_image_url ?? "",
heroVideoUrl: settings?.hero_video_url ?? "",
brandPrimaryColor: settings?.brand_primary_color ?? "#16a34a",
brandSecondaryColor: settings?.brand_secondary_color ?? "#f5f5f4",
brandBgColor: settings?.brand_bg_color ?? "#fafaf9",
@@ -258,6 +260,7 @@ function BrandSettingsFormBody({
showTextAlerts: state.showTextAlerts,
schedulePdfNotes: state.schedulePdfNotes || undefined,
heroImageUrl: state.heroImageUrl || undefined,
heroVideoUrl: state.heroVideoUrl || undefined,
brandPrimaryColor: state.brandPrimaryColor || undefined,
brandSecondaryColor: state.brandSecondaryColor || undefined,
brandBgColor: state.brandBgColor || undefined,
@@ -324,6 +327,7 @@ function BrandSettingsFormBody({
<StorefrontSection
heroTagline={state.heroTagline}
heroImageUrl={state.heroImageUrl}
heroVideoUrl={state.heroVideoUrl}
aboutHeadline={state.aboutHeadline}
aboutSubheadline={state.aboutSubheadline}
customFooterText={state.customFooterText}
@@ -673,6 +677,7 @@ function EmailInvoiceSection({
function StorefrontSection({
heroTagline,
heroImageUrl,
heroVideoUrl,
aboutHeadline,
aboutSubheadline,
customFooterText,
@@ -680,6 +685,7 @@ function StorefrontSection({
}: {
heroTagline: string;
heroImageUrl: string;
heroVideoUrl: string;
aboutHeadline: string;
aboutSubheadline: string;
customFooterText: string;
@@ -719,6 +725,18 @@ function StorefrontSection({
</div>
)}
<AdminInput
label="Hero Video URL (optional)"
helpText="mp4 or webm. Plays muted, looped, full-bleed behind the hero copy. If unset or fails to load, the hero falls back to the Hero Image URL above."
>
<AdminTextInput
type="url"
value={heroVideoUrl}
onChange={(e) => setField("heroVideoUrl", e.target.value)}
placeholder="https://cdn.example.com/hero-corn-fields.mp4"
/>
</AdminInput>
<AdminInput label="About Page Headline">
<AdminTextInput
value={aboutHeadline}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,531 @@
"use client";
/**
* TimeTrackingSmartsheetCard admin `/admin/water-log/settings`
* card for the Time Tracking Smartsheet integration (Cycle 5).
*
* Cycle 7: this card no longer owns the API token. The token lives
* on the workspace (one per brand, owned by the hub card above).
* This card only manages the per-feature mapping: sheet ID + column
* mapping + frequency + sync enabled + recent activity.
*
* Self-contained client component. Takes `brandId` as a prop per
* the brand-isolation convention.
*/
import { useCallback, useEffect, useReducer, useState } from "react";
import {
getTTSmartsheetConfig,
saveTTSmartsheetConfig,
testTTSmartsheetConnection,
getTTSmartsheetRecent,
disableTTSmartsheet,
getTTSmartsheetQueueSummary,
type TTSmartsheetConfigResponse,
type TTSmartsheetRecentEntry,
type TTSmartsheetQueueSummary,
} from "@/actions/time-tracking/smartsheet";
import { TT_SMARTSHEET_FREQUENCIES } from "@/db/schema/time-tracking";
import type {
TTSmartsheetColumnMapping,
TTSmartsheetColumnKey,
TTSmartsheetFrequency,
} from "@/db/schema/time-tracking";
import { formatDateTime } from "@/lib/format-date";
type Props = {
brandId: string;
};
type Status =
| { kind: "idle" }
| { kind: "saved-at"; iso: string }
| { kind: "error"; message: string };
type State = {
config: TTSmartsheetConfigResponse | null;
sheetIdInput: string;
frequency: TTSmartsheetFrequency;
syncEnabled: boolean;
columns: TTColumnOption[];
columnMapping: TTSmartsheetColumnMapping;
recent: TTSmartsheetRecentEntry[];
queue: TTSmartsheetQueueSummary;
loading: boolean;
testing: boolean;
saving: boolean;
status: Status;
};
type TTColumnOption = {
id: string;
title: string;
type: string;
};
type Action =
| { type: "SET_CONFIG"; config: TTSmartsheetConfigResponse }
| { type: "SET_SHEET_ID"; value: string }
| { type: "SET_FREQUENCY"; value: TTSmartsheetFrequency }
| { type: "SET_SYNC_ENABLED"; value: boolean }
| { type: "SET_COLUMNS"; columns: NonNullable<State["columns"]> }
| { type: "SET_COL_MAPPING_VALUE"; key: TTSmartsheetColumnKey; columnId: string | null }
| { type: "SET_RECENT"; entries: TTSmartsheetRecentEntry[] }
| { type: "SET_QUEUE"; queue: TTSmartsheetQueueSummary }
| { type: "SET_LOADING"; loading: boolean }
| { type: "SET_TESTING"; value: boolean }
| { type: "SET_SAVING"; value: boolean }
| { type: "SET_STATUS"; value: Status };
const EMPTY_MAPPING: TTSmartsheetColumnMapping = {
log_id: "",
clock_in: "",
clock_out: null,
worker: null,
task: null,
hours: null,
lunch_minutes: null,
notes: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_CONFIG": {
const cfg = action.config;
return {
...state,
config: cfg,
sheetIdInput: cfg.sheetId ?? "",
frequency: cfg.syncFrequency,
syncEnabled: cfg.syncEnabled,
columnMapping: cfg.columnMapping ?? { ...EMPTY_MAPPING },
loading: false,
};
}
case "SET_SHEET_ID":
return { ...state, sheetIdInput: action.value };
case "SET_FREQUENCY":
return { ...state, frequency: action.value };
case "SET_SYNC_ENABLED":
return { ...state, syncEnabled: action.value };
case "SET_COLUMNS":
return { ...state, columns: action.columns };
case "SET_COL_MAPPING_VALUE":
return {
...state,
columnMapping: {
...state.columnMapping,
[action.key]: action.columnId,
},
};
case "SET_RECENT":
return { ...state, recent: action.entries };
case "SET_QUEUE":
return { ...state, queue: action.queue };
case "SET_LOADING":
return { ...state, loading: action.loading };
case "SET_TESTING":
return { ...state, testing: action.value };
case "SET_SAVING":
return { ...state, saving: action.value };
case "SET_STATUS":
return { ...state, status: action.value };
}
}
function initialState(): State {
return {
config: null,
sheetIdInput: "",
frequency: "hourly",
syncEnabled: false,
columns: [],
columnMapping: { ...EMPTY_MAPPING },
recent: [],
queue: { pending: 0, syncing: 0, synced: 0, failed: 0 },
loading: true,
testing: false,
saving: false,
status: { kind: "idle" },
};
}
export function TimeTrackingSmartsheetCard({ brandId }: Props) {
const [state, dispatch] = useReducer(reducer, undefined, initialState);
const [, force] = useState({}); // for non-react subscribed re-renders
const refresh = useCallback(async () => {
const [cfg, recent, queue] = await Promise.all([
getTTSmartsheetConfig(brandId),
getTTSmartsheetRecent(brandId, 10),
getTTSmartsheetQueueSummary(brandId),
]);
dispatch({ type: "SET_CONFIG", config: cfg });
dispatch({ type: "SET_RECENT", entries: recent });
dispatch({ type: "SET_QUEUE", queue });
}, [brandId]);
useEffect(() => {
void refresh();
}, [refresh]);
// Cycle 7: Test Connection uses the workspace's saved token (no
// per-feature token to pass). The workspace action
// `testSmartsheetWorkspaceConnection` is the hub's button; here we
// call the per-feature test action with no token, which falls
// through to the workspace resolver.
const onTestConnection = useCallback(async () => {
dispatch({ type: "SET_TESTING", value: true });
dispatch({ type: "SET_STATUS", value: { kind: "idle" } });
const result = await testTTSmartsheetConnection(brandId, {
sheetId: state.sheetIdInput,
// No `token` — the action pulls the saved workspace token.
});
if (!result.success) {
dispatch({
type: "SET_STATUS",
value: { kind: "error", message: result.error },
});
dispatch({ type: "SET_TESTING", value: false });
return;
}
dispatch({ type: "SET_COLUMNS", columns: result.columns });
dispatch({ type: "SET_STATUS", value: { kind: "idle" } });
dispatch({ type: "SET_TESTING", value: false });
force({});
}, [brandId, state.sheetIdInput]);
const onSave = useCallback(async () => {
dispatch({ type: "SET_SAVING", value: true });
dispatch({ type: "SET_STATUS", value: { kind: "idle" } });
const result = await saveTTSmartsheetConfig(brandId, {
sheetId: state.sheetIdInput,
// Cycle 7: token is owned by the workspace; this field is
// ignored on save but kept for type compat.
token: "",
syncEnabled: state.syncEnabled,
syncFrequency: state.frequency,
columnMapping: state.columnMapping,
});
if (!result.success) {
dispatch({
type: "SET_STATUS",
value: { kind: "error", message: result.error },
});
dispatch({ type: "SET_SAVING", value: false });
return;
}
dispatch({
type: "SET_CONFIG",
config: result.config,
});
dispatch({
type: "SET_STATUS",
value: { kind: "saved-at", iso: new Date().toISOString() },
});
dispatch({ type: "SET_SAVING", value: false });
}, [brandId, state]);
const onDisable = useCallback(async () => {
dispatch({ type: "SET_SAVING", value: true });
await disableTTSmartsheet(brandId);
await refresh();
dispatch({ type: "SET_SAVING", value: false });
dispatch({
type: "SET_STATUS",
value: { kind: "saved-at", iso: new Date().toISOString() },
});
}, [brandId, refresh]);
if (state.loading) {
return (
<div className="rounded-2xl border border-[#e6dfd1] bg-white p-6">
<div className="h-5 w-40 bg-[#fdfaf2] animate-pulse rounded" />
<div className="mt-3 h-4 w-72 bg-[#fdfaf2] animate-pulse rounded" />
</div>
);
}
const hasWorkspaceToken = state.config?.hasToken === true;
const maskedToken = state.config?.maskedToken ?? null;
return (
<div
className="rounded-2xl border border-[#e6dfd1] bg-white p-6 shadow-sm"
data-test="tt-smartsheet-card"
>
<div className="mb-4 flex items-start justify-between">
<div>
<h2 className="text-xl font-bold text-[#1a4d2e]">
Time Tracking Smartsheet
</h2>
<p className="mt-1 text-sm text-[rgba(60,60,67,0.65)]">
Push every closed clock-out to a sheet inside the connected
Smartsheet workbook. The API token is managed by the hub
card above; this card only configures the sheet mapping.
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-[#1a4d2e]">
<input
type="checkbox"
className="h-4 w-4 rounded border-[#1a4d2e] accent-[#1a4d2e]"
checked={state.syncEnabled}
onChange={(e) =>
dispatch({
type: "SET_SYNC_ENABLED",
value: e.target.checked,
})
}
data-test="tt-smartsheet-enabled"
/>
Enabled
</label>
</div>
{/* Workspace token reference */}
<div className="mb-4 rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-xs text-[rgba(60,60,67,0.65)]">
{hasWorkspaceToken && maskedToken ? (
<>
Using workbook token{" "}
<span className="font-mono text-[#1a4d2e]">{maskedToken}</span>{" "}
from the hub card above.
</>
) : (
<>
No Smartsheet workbook is connected yet. Save the hub card
above with an API token before configuring this sheet.
</>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
Sheet ID or URL
</label>
<input
type="text"
placeholder="e.g. 8309876543210123"
value={state.sheetIdInput}
onChange={(e) =>
dispatch({ type: "SET_SHEET_ID", value: e.target.value })
}
className="w-full rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-sm"
data-test="tt-smartsheet-sheet-id"
/>
<p className="mt-1 text-xs text-[rgba(60,60,67,0.55)]">
Pick the sheet inside the workbook where clock-outs should land.
</p>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
Frequency
</label>
<select
value={state.frequency}
onChange={(e) =>
dispatch({
type: "SET_FREQUENCY",
value: e.target.value as TTSmartsheetFrequency,
})
}
className="w-full rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-sm"
data-test="tt-smartsheet-frequency"
>
{TT_SMARTSHEET_FREQUENCIES.map((f) => (
<option key={f} value={f}>
{f.replace(/_/g, " ")}
</option>
))}
</select>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
disabled={state.testing || !state.sheetIdInput}
onClick={onTestConnection}
className="rounded-md border border-[#1a4d2e] bg-white px-3 py-1.5 text-sm font-semibold text-[#1a4d2e] hover:bg-[#fdfaf2] disabled:cursor-not-allowed disabled:opacity-50"
data-test="tt-smartsheet-test"
>
{state.testing ? "Testing…" : "Test Connection"}
</button>
<button
type="button"
disabled={state.saving}
onClick={onSave}
className="rounded-md bg-[#1a4d2e] px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-[#14432a] disabled:cursor-not-allowed disabled:opacity-50"
data-test="tt-smartsheet-save"
>
{state.saving ? "Saving…" : "Save"}
</button>
{state.config?.configured && state.syncEnabled && (
<button
type="button"
disabled={state.saving}
onClick={onDisable}
className="rounded-md border border-[#a4452b] bg-white px-3 py-1.5 text-sm font-semibold text-[#a4452b] hover:bg-[#fdfaf2] disabled:cursor-not-allowed disabled:opacity-50"
>
Disable
</button>
)}
<span className="ml-2 text-xs text-[rgba(60,60,67,0.55)]">
{state.status.kind === "saved-at"
? `Saved at ${formatDateTime(new Date(state.status.iso))}`
: state.status.kind === "error"
? state.status.message
: state.config?.lastSyncAt
? `Last sync ${formatDateTime(new Date(state.config.lastSyncAt))}`
: "Never synced"}
</span>
</div>
{state.columns.length > 0 && (
<ColumnMappingPanel
columns={state.columns}
mapping={state.columnMapping}
onChange={(key, columnId) =>
dispatch({
type: "SET_COL_MAPPING_VALUE",
key,
columnId,
})
}
/>
)}
<RecentActivity recent={state.recent} queue={state.queue} />
</div>
);
}
function ColumnMappingPanel({
columns,
mapping,
onChange,
}: {
columns: NonNullable<State["columns"]>;
mapping: TTSmartsheetColumnMapping;
onChange: (key: TTSmartsheetColumnKey, columnId: string | null) => void;
}) {
return (
<div className="mt-6 rounded-xl border border-[#e6dfd1] bg-[#fdfaf2] p-4">
<h3 className="text-sm font-semibold text-[#1a4d2e]">Column mapping</h3>
<p className="mt-1 text-xs text-[rgba(60,60,67,0.65)]">
Required: <span className="font-mono">log_id</span>,{" "}
<span className="font-mono">clock_in</span>. Others optional.
</p>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
{(
[
["log_id", "Log ID (UUID, for dedup)", true],
["clock_in", "Clock-in (timestamp)", true],
["clock_out", "Clock-out (timestamp)", false],
["worker", "Worker name", false],
["task", "Task name", false],
["hours", "Hours (decimal)", false],
["lunch_minutes", "Lunch minutes", false],
["notes", "Notes", false],
] as Array<[TTSmartsheetColumnKey, string, boolean]>
).map(([key, label, required]) => (
<label key={key} className="block">
<span className="block text-xs font-medium text-[rgba(60,60,67,0.65)]">
{label}
{required && (
<span className="ml-1 text-[#a4452b]">*</span>
)}
</span>
<select
value={mapping[key] ?? ""}
onChange={(e) =>
onChange(
key,
e.target.value === "" ? null : e.target.value,
)
}
className="mt-1 w-full rounded-md border border-[#e6dfd1] bg-white px-2 py-1.5 text-sm"
>
<option value=""> not mapped </option>
{columns.map((c) => (
<option key={c.id} value={c.id}>
{c.title || `Column ${c.id}`}
</option>
))}
</select>
</label>
))}
</div>
</div>
);
}
function RecentActivity({
recent,
queue,
}: {
recent: TTSmartsheetRecentEntry[];
queue: TTSmartsheetQueueSummary;
}) {
return (
<div className="mt-6">
<h3 className="text-sm font-semibold text-[#1a4d2e]">
Recent activity
</h3>
<div className="mt-2 flex gap-3 text-xs text-[rgba(60,60,67,0.65)]">
<span>Pending: {queue.pending}</span>
<span>Syncing: {queue.syncing}</span>
<span>Synced: {queue.synced}</span>
<span>Failed: {queue.failed}</span>
</div>
<div className="mt-3 overflow-x-auto rounded-xl border border-[#e6dfd1]">
<table className="w-full text-left text-sm">
<thead className="bg-[#fdfaf2] text-xs uppercase tracking-wide text-[rgba(60,60,67,0.55)]">
<tr>
<th className="px-3 py-2">When</th>
<th className="px-3 py-2">Action</th>
<th className="px-3 py-2">Result</th>
<th className="px-3 py-2">Error</th>
</tr>
</thead>
<tbody>
{recent.length === 0 && (
<tr>
<td
className="px-3 py-3 text-center text-[rgba(60,60,67,0.55)]"
colSpan={4}
>
No sync attempts yet.
</td>
</tr>
)}
{recent.map((r) => (
<tr key={r.id} className="border-t border-[#f0e9d8]">
<td className="px-3 py-2 text-xs">
{formatDateTime(new Date(r.createdAt))}
</td>
<td className="px-3 py-2 text-xs">{r.action}</td>
<td className="px-3 py-2 text-xs">
<span
className={
r.success
? "inline-block rounded-full bg-[#1a4d2e]/10 px-2 py-0.5 text-[#1a4d2e]"
: "inline-block rounded-full bg-[#a4452b]/10 px-2 py-0.5 text-[#a4452b]"
}
>
{r.success ? "OK" : "FAIL"}
</span>
</td>
<td className="px-3 py-2 text-xs">
{r.error ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export default TimeTrackingSmartsheetCard;
+275
View File
@@ -0,0 +1,275 @@
/**
* Shell the tabbed shell for the Water Log admin.
*
* Replaces the monolithic `WaterLogAdminPanel` (which was 2117 lines
* covering 9 distinct UI concerns). The shell owns:
*
* - the reducer + initial state (delegate to ./reducer)
* - the localStorage-backed season + recipient settings
* - the "today" derived state (today's date / in-season / today's entries)
* - the tab state
* - the toast dispatcher
*
* Each tab is a focused component under ./tabs that consumes the
* state slice it needs.
*
* Adding a new tab only requires:
* 1. Create the tab component under ./tabs
* 2. Add a TabValue + tabs[] entry below
* 3. Render it conditionally in the {tab === X && ...} block
*/
"use client";
import { useEffect, useMemo, useReducer, useState } from "react";
import { useRouter } from "next/navigation";
import AdminFilterTabs from "@/components/admin/design-system/AdminFilterTabs";
import {
isInIrrigationSeason,
shapeWaterLogEntry,
type WaterLogReportRow,
} from "@/lib/water-log-reporting";
import { formatDate } from "@/lib/format-date";
import type {
AdminEntry,
AdminHeadgate,
AdminIrrigator,
} from "@/actions/water-log/admin";
import { reducer, initState } from "./reducer";
import type { ReportPreviewData } from "./types";
import { HeaderNav } from "./shared/HeaderNav";
import { ToastNotification } from "./shared/Toast";
import { ReportPreviewModal } from "./shared/ReportPreviewModal";
import { TodayTab } from "./tabs/TodayTab";
import { HeadgatesTab } from "./tabs/HeadgatesTab";
import { UsersTab } from "./tabs/UsersTab";
import { TimeTrackingTab } from "./tabs/TimeTrackingTab";
// ── Props ────────────────────────────────────────────────────────────────────
export type ShellProps = {
initialUsers: AdminIrrigator[];
initialHeadgates: AdminHeadgate[];
initialEntries: AdminEntry[];
brandId: string;
canManage: boolean;
};
// ── Tab values ───────────────────────────────────────────────────────────────
type TabValue = "today" | "headgates" | "users" | "time-tracking";
const TABS = [
{
value: "today" as const,
label: "Today",
// could pull counts from props for badges, omitted for now
},
{
value: "headgates" as const,
label: "Headgates",
},
{
value: "users" as const,
label: "Users",
},
// Cycle 3 — embed the existing TimeTrackingAdminPanel (which has
// its own sub-tabs) so the water-log shell becomes a one-stop hub
// for the customer: today's entries, the headgates, who logs them,
// and how many hours they put in.
{
value: "time-tracking" as const,
label: "Time Tracking",
},
];
// ── Main component ───────────────────────────────────────────────────────────
export default function Shell({
initialUsers,
initialHeadgates,
initialEntries,
brandId,
canManage,
}: ShellProps) {
const router = useRouter();
const [tab, setTab] = useState<TabValue>("today");
const [state, dispatch] = useReducer(reducer, {
initialHeadgates,
initialUsers,
initialEntries,
}, initState);
// Resolve "today" once on mount, in the browser, to avoid hydration
// mismatches. The setState is wrapped in an async IIFE so the effect
// doesn't call setState synchronously (which trips the cascading-
// renders ESLint rule).
useEffect(() => {
void (async () => {
const now = new Date();
dispatch({
type: "SET_TODAY",
today: now.toISOString().slice(0, 10),
label: formatDate(now),
});
})();
}, []);
// Derived data ─────────────────────────────────────────────────────────────
const inSeason = useMemo(
() => isInIrrigationSeason(new Date(), state.seasonStart),
[state.seasonStart],
);
const todayEntries = useMemo(() => {
if (!state.today) return [];
return state.entries.filter(
(e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === state.today,
);
}, [state.entries, state.today]);
const todayTotal = useMemo(
() => todayEntries.reduce((s, e) => s + e.measurement, 0),
[todayEntries],
);
const lastEntryByHeadgate = useMemo(() => {
const map = new Map<string, AdminEntry>();
for (const e of state.entries) {
const existing = map.get(e.headgate_id);
if (!existing || e.logged_at > existing.logged_at) {
map.set(e.headgate_id, e);
}
}
return map;
}, [state.entries]);
// Toast helper ─────────────────────────────────────────────────────────────
function notify(msg: string, kind: "ok" | "err" = "ok") {
dispatch({ type: "SET_TOAST", toast: { msg, kind } });
setTimeout(() => dispatch({ type: "CLEAR_TOAST" }), 2800);
}
// ── Persisted UI preferences (lives in the shell so the storage
// contract is owned by one place, not scattered through tabs).
function persistSeason(s: import("@/lib/water-log-reporting").IrrigationSeasonSettings) {
dispatch({ type: "SET_SEASON_START", value: s });
try {
localStorage.setItem("wl_season_settings:v1", JSON.stringify(s));
} catch {}
}
function persistRecipientName(v: string) {
dispatch({ type: "SET_RECIPIENT_NAME", value: v });
try {
localStorage.setItem("wl_recipient_name", v);
} catch {}
}
// Report preview open/close ────────────────────────────────────────────────
function openReportPreview() {
const rows: WaterLogReportRow[] = todayEntries.map(shapeWaterLogEntry);
const unit =
rows.find((r) => r.unit)?.unit ??
state.headgates[0]?.unit ??
"CFS";
const now = new Date();
if (rows.length === 0) {
notify(
`No entries today — report would be skipped (season: ${
inSeason ? "in" : "out"
}).`,
);
return;
}
const preview: ReportPreviewData = {
rows,
total: todayTotal,
unit,
dateLabel: state.todayLabel || formatDate(now),
dateIso: state.today || now.toISOString().slice(0, 10),
recipientName: state.recipientName.trim() || undefined,
inSeason,
};
dispatch({ type: "OPEN_REPORT_PREVIEW", preview });
}
function closeReportPreview() {
dispatch({ type: "CLOSE_REPORT_PREVIEW" });
}
return (
<div className="relative">
<HeaderNav />
<div className="mb-6">
<AdminFilterTabs
activeTab={tab}
onTabChange={(value) => setTab(value as TabValue)}
tabs={TABS.map((t) => ({
value: t.value,
label: t.label,
}))}
showCounts={false}
size="md"
/>
</div>
{tab === "today" && (
<TodayTab
brandId={brandId}
state={state}
todayLabel={state.todayLabel}
todayEntries={todayEntries}
todayTotal={todayTotal}
inSeason={inSeason}
notify={notify}
dispatch={dispatch}
onPreviewReport={openReportPreview}
onSeasonChange={persistSeason}
onRecipientNameChange={persistRecipientName}
/>
)}
{tab === "headgates" && (
<HeadgatesTab
brandId={brandId}
headgates={state.headgates}
lastEntryByHeadgate={lastEntryByHeadgate}
canManage={canManage}
showAddHg={state.forms.showAddHg}
newHg={state.forms.newHg}
savingHg={state.forms.savingHg}
notify={notify}
dispatch={dispatch}
onEdit={(h) => router.push(`/admin/water-log/headgates/${h.id}`)}
/>
)}
{tab === "users" && (
<UsersTab
brandId={brandId}
users={state.users}
canManage={canManage}
showAddUser={state.forms.showAddUser}
newUser={state.forms.newUser}
savingUser={state.forms.savingUser}
newPin={state.forms.newPin}
resetPin={state.forms.resetPin}
notify={notify}
dispatch={dispatch}
/>
)}
{tab === "time-tracking" && (
<TimeTrackingTab brandId={brandId} />
)}
<ToastNotification toast={state.toast} />
<ReportPreviewModal
data={state.reportPreview}
onClose={closeReportPreview}
notify={notify}
/>
</div>
);
}
@@ -0,0 +1,455 @@
"use client";
/**
* SmartsheetHub top-level card for `/admin/water-log/settings`.
*
* Cycle 7. Owns the brand-level Smartsheet workbook connection:
* - API token (encrypted at rest in `smartsheet_workspace`)
* - "Test Connection" button verifies the token against a sheet
* - Connection enable toggle (master kill-switch)
* - Default sheet ID (the "home" tab in the workbook)
* - Last-verified badge
*
* Per-feature sheet mappings (water log + time tracking) live in their
* own cards BELOW this one and inherit the workspace token.
*
* Visual language matches the parent Water Log settings page
* (cream + forest palette, "Field Almanac" style).
*/
import { useCallback, useEffect, useReducer, useState } from "react";
import {
getSmartsheetWorkspace,
saveSmartsheetWorkspace,
testSmartsheetWorkspaceConnection,
disableSmartsheetWorkspace,
} from "@/actions/smartsheet/workspace";
import { formatDateTime } from "@/lib/format-date";
// ── State ──────────────────────────────────────────────────────────────────
type State = {
loading: boolean;
saving: boolean;
message: { type: "success" | "error"; text: string } | null;
workspace: Awaited<ReturnType<typeof getSmartsheetWorkspace>> | null;
// Editable form fields:
tokenInput: string;
defaultSheetIdInput: string;
showToken: boolean;
connectionEnabled: boolean;
};
type Action =
| { type: "load"; workspace: State["workspace"] }
| { type: "loading"; loading: boolean }
| { type: "saving"; saving: boolean }
| { type: "token"; value: string }
| { type: "showToken"; value: boolean }
| { type: "defaultSheet"; value: string }
| { type: "connection"; value: boolean }
| { type: "message"; message: State["message"] }
| { type: "reset-message" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "load":
return {
...state,
workspace: action.workspace,
connectionEnabled: action.workspace?.connectionEnabled ?? true,
defaultSheetIdInput: action.workspace?.defaultSheetId ?? "",
};
case "loading":
return { ...state, loading: action.loading };
case "saving":
return { ...state, saving: action.saving };
case "token":
return { ...state, tokenInput: action.value };
case "showToken":
return { ...state, showToken: action.value };
case "defaultSheet":
return { ...state, defaultSheetIdInput: action.value };
case "connection":
return { ...state, connectionEnabled: action.value };
case "message":
return { ...state, message: action.message };
case "reset-message":
return { ...state, message: null };
}
}
const INITIAL_STATE: State = {
loading: true,
saving: false,
message: null,
workspace: null,
tokenInput: "",
defaultSheetIdInput: "",
showToken: false,
connectionEnabled: true,
};
// ── Component ─────────────────────────────────────────────────────────────
export function SmartsheetHub({ brandId }: { brandId: string }) {
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
const [testResult, setTestResult] = useState<
| { state: "idle" }
| { state: "loading" }
| { state: "ok"; sheetName: string; columnCount: number }
| { state: "error"; message: string }
>({ state: "idle" });
// ── Load on mount + brandId change ──
useEffect(() => {
let cancelled = false;
void (async () => {
dispatch({ type: "loading", loading: true });
try {
const ws = await getSmartsheetWorkspace(brandId);
if (!cancelled) {
dispatch({ type: "load", workspace: ws });
dispatch({ type: "loading", loading: false });
}
} catch (err) {
if (!cancelled) {
dispatch({
type: "message",
message: {
type: "error",
text: err instanceof Error ? err.message : "Failed to load workspace",
},
});
dispatch({ type: "loading", loading: false });
}
}
})();
return () => {
cancelled = true;
};
}, [brandId]);
// ── Save ──
const handleSave = useCallback(async () => {
dispatch({ type: "saving", saving: true });
dispatch({ type: "reset-message" });
try {
const defaultSheetId =
state.defaultSheetIdInput.trim().length > 0
? state.defaultSheetIdInput.trim()
: null;
const result = await saveSmartsheetWorkspace(brandId, {
token: state.tokenInput,
defaultSheetId,
connectionEnabled: state.connectionEnabled,
});
if (!result.success) {
dispatch({
type: "message",
message: { type: "error", text: result.error },
});
return;
}
dispatch({ type: "load", workspace: result.workspace });
// Clear the token input on success — never leave it in the form.
dispatch({ type: "token", value: "" });
dispatch({ type: "showToken", value: false });
dispatch({
type: "message",
message: {
type: "success",
text: state.tokenInput.length > 0
? "Workspace connected."
: "Workspace settings saved.",
},
});
} catch (err) {
dispatch({
type: "message",
message: {
type: "error",
text: err instanceof Error ? err.message : "Save failed",
},
});
} finally {
dispatch({ type: "saving", saving: false });
}
}, [brandId, state.tokenInput, state.defaultSheetIdInput, state.connectionEnabled]);
// ── Test connection ──
const handleTest = useCallback(async () => {
setTestResult({ state: "loading" });
const sheetIdInput =
state.defaultSheetIdInput.trim().length > 0
? state.defaultSheetIdInput.trim()
: state.workspace?.defaultSheetId ?? "";
if (!sheetIdInput) {
setTestResult({
state: "error",
message: "Enter a sheet ID or URL above to test against.",
});
return;
}
try {
const result = await testSmartsheetWorkspaceConnection(
brandId,
sheetIdInput,
state.tokenInput,
);
if (result.success) {
setTestResult({
state: "ok",
sheetName: result.sheetName,
columnCount: result.columnCount,
});
} else {
setTestResult({ state: "error", message: result.error });
}
} catch (err) {
setTestResult({
state: "error",
message: err instanceof Error ? err.message : "Test failed",
});
}
}, [brandId, state.tokenInput, state.defaultSheetIdInput, state.workspace?.defaultSheetId]);
// ── Disable (no-confirm shortcut) ──
const handleDisable = useCallback(async () => {
dispatch({ type: "saving", saving: true });
try {
await disableSmartsheetWorkspace(brandId);
const ws = await getSmartsheetWorkspace(brandId);
dispatch({ type: "load", workspace: ws });
dispatch({
type: "message",
message: { type: "success", text: "Connection disabled. Sheet mappings preserved." },
});
} catch (err) {
dispatch({
type: "message",
message: {
type: "error",
text: err instanceof Error ? err.message : "Disable failed",
},
});
} finally {
dispatch({ type: "saving", saving: false });
}
}, [brandId]);
if (state.loading) {
return (
<div className="rounded-xl border border-[#d4d9d3] bg-white p-6">
<p className="text-sm text-[#5a5d5a]">Loading workspace</p>
</div>
);
}
const isConnected = state.workspace?.configured === true;
const hasToken = state.workspace?.hasToken === true;
const maskedToken = state.workspace?.maskedToken ?? null;
return (
<div className="rounded-xl border border-[#d4d9d3] bg-white p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3
className="text-lg font-medium text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Workbook connection
</h3>
<p className="mt-1 text-sm text-[#5a5d5a]">
One Smartsheet API token powers every feature below (water log, time
tracking, future payroll). Per-feature cards just pick which sheet
inside the workbook to write to.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ${
isConnected && state.connectionEnabled
? "bg-[#dfe9d4] text-[#1a4d2e]"
: isConnected
? "bg-[#f1e8d4] text-[#8a6b3b]"
: "bg-[#f0e1d8] text-[#a4452b]"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
isConnected && state.connectionEnabled
? "bg-[#1a4d2e]"
: isConnected
? "bg-[#8a6b3b]"
: "bg-[#a4452b]"
}`}
/>
{isConnected
? state.connectionEnabled
? "Connected"
: "Paused"
: "Not connected"}
</span>
</div>
</div>
{/* ── Last verified ── */}
{isConnected && state.workspace?.lastTestAt && (
<p className="mt-3 text-xs text-[#5a5d5a]">
Last verified: {formatDateTime(new Date(state.workspace.lastTestAt))}
{state.workspace.lastTestError && (
<span className="ml-2 text-[#a4452b]">
· {state.workspace.lastTestError.slice(0, 80)}
</span>
)}
</p>
)}
{/* ── Form ── */}
<div className="mt-5 space-y-4">
<div>
<label className="block text-sm font-medium text-[#1a4d2e]">
API token
</label>
<div className="mt-1 flex items-center gap-2">
<input
type={state.showToken ? "text" : "password"}
autoComplete="off"
spellCheck={false}
className="flex-1 rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1a4d2e] placeholder:text-[#8a8d8a] focus:border-[#1a4d2e] focus:outline-none focus:ring-1 focus:ring-[#1a4d2e]"
placeholder={
hasToken && maskedToken
? `Saved: ${maskedToken} — paste to rotate`
: "Paste your Smartsheet API token"
}
value={state.tokenInput}
onChange={(e) =>
dispatch({ type: "token", value: e.target.value })
}
data-test="smartsheet-workspace-token"
/>
<button
type="button"
className="rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#5a5d5a] hover:bg-[#fdfaf2]"
onClick={() =>
dispatch({ type: "showToken", value: !state.showToken })
}
>
{state.showToken ? "Hide" : "Show"}
</button>
</div>
<p className="mt-1 text-xs text-[#5a5d5a]">
Encrypted at rest with AES-256-GCM. Never returned to the browser
after save.
</p>
</div>
<div>
<label className="block text-sm font-medium text-[#1a4d2e]">
Default sheet ID (optional)
</label>
<input
type="text"
className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1a4d2e] placeholder:text-[#8a8d8a] focus:border-[#1a4d2e] focus:outline-none focus:ring-1 focus:ring-[#1a4d2e]"
placeholder="e.g. 8309876543210123 (or paste a Smartsheet URL)"
value={state.defaultSheetIdInput}
onChange={(e) =>
dispatch({ type: "defaultSheet", value: e.target.value })
}
data-test="smartsheet-workspace-default-sheet"
/>
<p className="mt-1 text-xs text-[#5a5d5a]">
The &ldquo;home&rdquo; tab when the customer opens the workbook.
Per-feature cards below pick their own sheets independently.
</p>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-sm font-medium text-[#1a4d2e]">
<input
type="checkbox"
className="h-4 w-4 rounded border-[#d4d9d3] text-[#1a4d2e] focus:ring-[#1a4d2e]"
checked={state.connectionEnabled}
onChange={(e) =>
dispatch({ type: "connection", value: e.target.checked })
}
data-test="smartsheet-workspace-enabled"
/>
Connection enabled
</label>
<span className="text-xs text-[#5a5d5a]">
(Master switch pauses all per-feature syncs without losing mappings)
</span>
</div>
</div>
{/* ── Actions ── */}
<div className="mt-5 flex flex-wrap items-center gap-2">
<button
type="button"
className="rounded-lg bg-[#1a4d2e] px-4 py-2 text-sm font-medium text-white hover:bg-[#14432a] disabled:opacity-60"
onClick={handleSave}
disabled={state.saving}
data-test="smartsheet-workspace-save"
>
{state.saving ? "Saving…" : "Save"}
</button>
<button
type="button"
className="rounded-lg border border-[#1a4d2e] bg-white px-4 py-2 text-sm font-medium text-[#1a4d2e] hover:bg-[#fdfaf2] disabled:opacity-60"
onClick={handleTest}
disabled={testResult.state === "loading"}
data-test="smartsheet-workspace-test"
>
{testResult.state === "loading" ? "Testing…" : "Test connection"}
</button>
{isConnected && state.connectionEnabled && (
<button
type="button"
className="rounded-lg border border-[#d4d9d3] bg-white px-4 py-2 text-sm font-medium text-[#8a6b3b] hover:bg-[#fdfaf2] disabled:opacity-60"
onClick={handleDisable}
disabled={state.saving}
>
Pause connection
</button>
)}
</div>
{/* ── Test result ── */}
{testResult.state === "ok" && (
<div className="smartsheet-slide-down mt-4 rounded-lg border border-[#dfe9d4] bg-[#dfe9d4] p-3">
<p className="text-sm font-medium text-[#1a4d2e]">
Connected to &ldquo;{testResult.sheetName}&rdquo;
</p>
<p className="mt-1 text-xs text-[#5a5d5a]">
{testResult.columnCount} columns visible. The test uses the
{state.tokenInput.length > 0 ? " pasted" : " saved"} token.
</p>
</div>
)}
{testResult.state === "error" && (
<div className="smartsheet-slide-down mt-4 rounded-lg border border-[#f0e1d8] bg-[#f0e1d8] p-3">
<p className="text-sm font-medium text-[#a4452b]">
{testResult.message}
</p>
</div>
)}
{/* ── Save message ── */}
{state.message && (
<div
className={`smartsheet-fade-in mt-4 rounded-lg p-3 text-sm ${
state.message.type === "success"
? "bg-[#dfe9d4] text-[#1a4d2e]"
: "bg-[#f0e1d8] text-[#a4452b]"
}`}
>
{state.message.text}
</div>
)}
</div>
);
}
export default SmartsheetHub;
File diff suppressed because it is too large Load Diff
+305
View File
@@ -0,0 +1,305 @@
/**
* Reducer + state initialization for the Water Log admin module.
*
* Pulled out of the monolithic `WaterLogAdminPanel.tsx` so the shell can
* own state and dispatch and pass them down to focused tab components.
*
* localStorage contracts (intentionally narrow):
* - wl_season_settings:v1 IrrigationSeasonSettings JSON
* - wl_recipient_name string
*/
import type {
AdminEntry,
AdminHeadgate,
AdminIrrigator,
} from "@/actions/water-log/admin";
import type { IrrigationSeasonSettings } from "@/lib/water-log-reporting";
import type { Action, State } from "./types";
export const DEFAULT_SEASON: IrrigationSeasonSettings = {
seasonStartMonth: 3,
seasonStartDay: 15,
seasonEndMonth: 10,
seasonEndDay: 15,
};
// ── Init helpers ────────────────────────────────────────────────────────────
function initSeasonStart(): IrrigationSeasonSettings {
if (typeof window === "undefined") return DEFAULT_SEASON;
try {
const saved = localStorage.getItem("wl_season_settings:v1");
return saved ? { ...DEFAULT_SEASON, ...JSON.parse(saved) } : DEFAULT_SEASON;
} catch {
return DEFAULT_SEASON;
}
}
function initRecipientName(): string {
if (typeof window === "undefined") return "";
return localStorage.getItem("wl_recipient_name") ?? "";
}
export function initState(props: {
initialHeadgates: AdminHeadgate[];
initialUsers: AdminIrrigator[];
initialEntries: AdminEntry[];
}): State {
return {
headgates: props.initialHeadgates,
users: props.initialUsers,
entries: props.initialEntries,
toast: null,
forms: {
showAddHg: false,
newHg: { name: "", unit: "CFS", notes: "" },
savingHg: false,
showAddUser: false,
newUser: { name: "", role: "irrigator", lang: "en", phone: "" },
savingUser: false,
newPin: null,
resetPin: null,
},
filters: { dateFrom: "", dateTo: "", headgate: "", user: "", via: "" },
csvLoading: false,
today: "",
todayLabel: "",
showReportSettings: false,
seasonStart: initSeasonStart(),
recipientName: initRecipientName(),
reportPreview: null,
};
}
// ── Reducer ─────────────────────────────────────────────────────────────────
export function reducer(state: State, action: Action): State {
switch (action.type) {
// Headgates
case "ADD_HEADGATE":
return { ...state, headgates: [action.headgate, ...state.headgates] };
case "REMOVE_HEADGATE":
return {
...state,
headgates: state.headgates.filter((h) => h.id !== action.id),
};
case "UPDATE_HEADGATE_TOKEN":
return {
...state,
headgates: state.headgates.map((h) =>
h.id === action.id ? { ...h, headgate_token: action.token } : h,
),
};
// Users
case "ADD_USER":
return { ...state, users: [action.user, ...state.users] };
case "DEACTIVATE_USER":
return {
...state,
users: state.users.map((u) =>
u.id === action.id ? { ...u, active: false } : u,
),
};
// Toast
case "SET_TOAST":
return { ...state, toast: action.toast };
case "CLEAR_TOAST":
return { ...state, toast: null };
// Forms - Headgate
case "TOGGLE_ADD_HG":
return {
...state,
forms: { ...state.forms, showAddHg: !state.forms.showAddHg },
};
case "SET_NEW_HG_NAME":
return {
...state,
forms: {
...state.forms,
newHg: { ...state.forms.newHg, name: action.value },
},
};
case "SET_NEW_HG_UNIT":
return {
...state,
forms: {
...state.forms,
newHg: { ...state.forms.newHg, unit: action.value },
},
};
case "SET_NEW_HG_NOTES":
return {
...state,
forms: {
...state.forms,
newHg: { ...state.forms.newHg, notes: action.value },
},
};
case "RESET_NEW_HG":
return {
...state,
forms: {
...state.forms,
newHg: { name: "", unit: "CFS", notes: "" },
showAddHg: false,
},
};
case "SET_SAVING_HG":
return {
...state,
forms: { ...state.forms, savingHg: action.value },
};
// Forms - User
case "TOGGLE_ADD_USER":
return {
...state,
forms: { ...state.forms, showAddUser: !state.forms.showAddUser },
};
case "SET_NEW_USER_NAME":
return {
...state,
forms: {
...state.forms,
newUser: { ...state.forms.newUser, name: action.value },
},
};
case "SET_NEW_USER_ROLE":
return {
...state,
forms: {
...state.forms,
newUser: { ...state.forms.newUser, role: action.value },
},
};
case "SET_NEW_USER_LANG":
return {
...state,
forms: {
...state.forms,
newUser: { ...state.forms.newUser, lang: action.value },
},
};
case "SET_NEW_USER_PHONE":
return {
...state,
forms: {
...state.forms,
newUser: { ...state.forms.newUser, phone: action.value },
},
};
case "RESET_NEW_USER":
return {
...state,
forms: {
...state.forms,
newUser: { name: "", role: "irrigator", lang: "en", phone: "" },
showAddUser: false,
},
};
case "SET_SAVING_USER":
return {
...state,
forms: { ...state.forms, savingUser: action.value },
};
case "SET_NEW_PIN":
return {
...state,
forms: { ...state.forms, newPin: action.pin },
};
case "CLEAR_NEW_PIN":
return { ...state, forms: { ...state.forms, newPin: null } };
case "SET_RESET_PIN":
return {
...state,
forms: { ...state.forms, resetPin: action.pin },
};
case "CLEAR_RESET_PIN":
return { ...state, forms: { ...state.forms, resetPin: null } };
// Filters
case "SET_FILTER_DATE_FROM":
return {
...state,
filters: { ...state.filters, dateFrom: action.value },
};
case "SET_FILTER_DATE_TO":
return {
...state,
filters: { ...state.filters, dateTo: action.value },
};
case "SET_FILTER_HEADGATE":
return {
...state,
filters: { ...state.filters, headgate: action.value },
};
case "SET_FILTER_USER":
return {
...state,
filters: { ...state.filters, user: action.value },
};
case "SET_FILTER_VIA":
return {
...state,
filters: { ...state.filters, via: action.value },
};
case "CLEAR_FILTERS":
return {
...state,
filters: { dateFrom: "", dateTo: "", headgate: "", user: "", via: "" },
};
// Misc UI
case "SET_CSV_LOADING":
return { ...state, csvLoading: action.value };
case "SET_TODAY":
return { ...state, today: action.today, todayLabel: action.label };
case "TOGGLE_REPORT_SETTINGS":
return {
...state,
showReportSettings: !state.showReportSettings,
};
case "SET_SEASON_START":
return { ...state, seasonStart: action.value };
case "SET_RECIPIENT_NAME":
return { ...state, recipientName: action.value };
case "OPEN_REPORT_PREVIEW":
return { ...state, reportPreview: action.preview };
case "CLOSE_REPORT_PREVIEW":
return { ...state, reportPreview: null };
default:
return state;
}
}
// ── Selectors ────────────────────────────────────────────────────────────────
/** Latest entry per headgate (by logged_at), for the headgate cards. */
export function lastEntryByHeadgateSelector(entries: AdminEntry[]) {
const map = new Map<string, AdminEntry>();
for (const e of entries) {
const existing = map.get(e.headgate_id);
if (!existing || e.logged_at > existing.logged_at) {
map.set(e.headgate_id, e);
}
}
return map;
}
/** Apply the current filter set to the entry list. */
export function filteredEntriesSelector(
entries: AdminEntry[],
filters: State["filters"],
) {
return entries.filter((e) => {
if (filters.dateFrom && e.logged_at < filters.dateFrom) return false;
if (filters.dateTo && e.logged_at > filters.dateTo + "T23:59:59.999Z")
return false;
if (filters.headgate && e.headgate_id !== filters.headgate) return false;
if (filters.user && e.user_id !== filters.user) return false;
if (filters.via && e.submitted_via !== filters.via) return false;
return true;
});
}
/** Today's entries, by logged_date (with logged_at fallback). */
export function todayEntriesSelector(entries: AdminEntry[], today: string) {
if (!today) return [];
return entries.filter(
(e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === today,
);
}
@@ -0,0 +1,34 @@
/**
* HeaderNav top-of-page quick links for the Water Log admin.
*
* Renders the three contextual exits (QR Codes, Settings, Field Admin)
* as secondary `AdminButton`s. Lives outside the tab shell so it's
* always visible regardless of which tab the user is on.
*/
"use client";
import Link from "next/link";
import AdminButton from "@/components/admin/design-system/AdminButton";
import { QrIcon, GearIcon, FieldIcon } from "./icons";
export function HeaderNav() {
return (
<div className="mb-6 flex flex-wrap items-center gap-2">
<Link href="/admin/water-log/headgates">
<AdminButton variant="secondary" size="sm" icon={<QrIcon />}>
QR Codes
</AdminButton>
</Link>
<Link href="/admin/water-log/settings">
<AdminButton variant="secondary" size="sm" icon={<GearIcon />}>
Settings
</AdminButton>
</Link>
<Link href="/water/admin" target="_blank" rel="noopener noreferrer">
<AdminButton variant="secondary" size="sm" icon={<FieldIcon />}>
Field Admin
</AdminButton>
</Link>
</div>
);
}
@@ -0,0 +1,387 @@
/**
* Shared primitives for the Water Log admin module.
*
* These are the small reusable sub-components used by the tabs:
* section headers, stat tiles, status pills, form inputs, the PIN
* banner, the role legend, and the empty state. All use the "Field
* Almanac" palette (cream / forest / clay) so they sit naturally
* together.
*/
"use client";
import * as React from "react";
import { KeyIcon } from "./icons";
// ── SectionHeader ────────────────────────────────────────────────────────────
export function SectionHeader({
index,
title,
subtitle,
action,
}: {
index: string;
title: string;
subtitle: string;
action?: React.ReactNode;
}) {
return (
<header className="mb-3 mt-10 flex items-start justify-between gap-4 border-b border-[#d4d9d3] pb-3">
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-[#1a4d2e]">
{index}
</div>
<h2
className="mt-0.5 text-xl font-semibold text-[#1d1d1f]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{title}
</h2>
<p className="mt-0.5 max-w-xl text-xs text-[#57694e]">{subtitle}</p>
</div>
{action}
</header>
);
}
// ── StatTile ────────────────────────────────────────────────────────────────
export function StatTile({
label,
value,
unit,
accent,
mono,
}: {
label: string;
value: string;
unit: string;
accent?: boolean;
mono?: boolean;
}) {
return (
<div
className={`rounded-lg border p-3 ${
accent ? "border-[#1a4d2e]/30 bg-[#f0fdf4]" : "border-[#d4d9d3] bg-white"
}`}
>
<div className="text-[10px] font-mono uppercase tracking-wider text-[#57694e]">
{label}
</div>
<div className="mt-0.5 flex items-baseline gap-1">
<span
className={`text-lg font-semibold text-[#1a4d2e] ${
mono ? "font-mono" : ""
}`}
>
{value}
</span>
{unit && (
<span className="font-mono text-[10px] uppercase text-[#57694e]">
{unit}
</span>
)}
</div>
</div>
);
}
// ── StatusPill ──────────────────────────────────────────────────────────────
const statusPillMap = {
active: { bg: "bg-[#dcfce7]", fg: "text-[#15803d]", label: "Active" },
inactive: { bg: "bg-[#e8ebe8]", fg: "text-[#57694e]", label: "Inactive" },
closed: { bg: "bg-[#e8ebe8]", fg: "text-[#57694e]", label: "Closed" },
warn: { bg: "bg-[#fef9c3]", fg: "text-[#a16207]", label: "Maint." },
} as const;
export function StatusPill({
kind,
}: {
kind: "active" | "inactive" | "closed" | "warn";
}) {
const s = statusPillMap[kind];
return (
<span
className={`inline-flex shrink-0 items-center rounded-full px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider ${s.bg} ${s.fg}`}
>
{s.label}
</span>
);
}
// ── PinBanner ───────────────────────────────────────────────────────────────
export function PinBanner({
title,
pin,
tone = "ok",
onDismiss,
}: {
title: string;
pin: string;
tone?: "ok" | "warn";
onDismiss: () => void;
}) {
return (
<div
className={`mb-4 flex items-center gap-4 rounded-xl border p-4 ${
tone === "warn"
? "border-[#fde047] bg-[#fef9c3]"
: "border-[#15803d]/40 bg-[#dcfce7]"
}`}
>
<KeyIcon />
<div className="flex-1">
<p className="font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
{title}
</p>
<p
className="font-mono text-2xl font-bold tracking-[0.5em] text-[#1a4d2e]"
aria-label={`PIN ${pin.split("").join(" ")}`}
>
{pin}
</p>
</div>
<button
type="button"
onClick={onDismiss}
className="rounded-lg border border-[#1a4d2e]/30 px-3 py-1.5 text-xs font-semibold text-[#1a4d2e] hover:bg-white"
aria-label="Dismiss"
>
Dismiss
</button>
</div>
);
}
// ── RoleLegend ──────────────────────────────────────────────────────────────
export function RoleLegend() {
return (
<p className="mb-3 flex items-center gap-3 font-mono text-[11px] text-[#57694e]">
<span>
<span className="font-bold text-[#1e3a8a]">ADMIN</span>
<span className="text-[#86868b]"> manage headgates, users, entries</span>
</span>
<span className="text-[#d4d9d3]">·</span>
<span>
<span className="font-bold text-[#15803d]">IRRIGATOR</span>
<span className="text-[#86868b]"> submit entries only</span>
</span>
</p>
);
}
// ── EmptyState ──────────────────────────────────────────────────────────────
export function EmptyState({
icon,
title,
body,
}: {
icon: React.ReactNode;
title: string;
body: string;
}) {
return (
<div className="rounded-xl border border-dashed border-[#d4d9d3] bg-white px-6 py-10 text-center">
<div className="mx-auto mb-3 flex w-12 items-center justify-center text-[#86868b]">
{icon}
</div>
<p
className="text-base font-semibold text-[#1d1d1f]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{title}
</p>
<p className="mx-auto mt-1 max-w-md text-sm text-[#57694e]">{body}</p>
</div>
);
}
// ── Input ───────────────────────────────────────────────────────────────────
export function Input({
label,
value,
onChange,
placeholder,
required,
type = "text",
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
required?: boolean;
type?: string;
}) {
return (
<label className="block">
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
{label}
</span>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
required={required}
className="w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1d1d1f] outline-none transition focus:border-[#1a4d2e] focus:ring-2 focus:ring-[#1a4d2e]/15"
/>
</label>
);
}
// ── Select ──────────────────────────────────────────────────────────────────
export function Select({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (v: string) => void;
options: (string | { value: string; label: string })[];
}) {
return (
<label className="block">
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
{label}
</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1d1d1f] outline-none transition focus:border-[#1a4d2e] focus:ring-2 focus:ring-[#1a4d2e]/15"
>
{options.map((o) => {
if (typeof o === "string") {
return (
<option key={o} value={o}>
{o}
</option>
);
}
return (
<option key={o.value} value={o.value}>
{o.label}
</option>
);
})}
</select>
</label>
);
}
// ── FilterChip ──────────────────────────────────────────────────────────────
export function FilterChip({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center gap-1.5 rounded-lg border border-[#d4d9d3] bg-[#fafaf7] px-2 py-1">
<span className="font-mono text-[10px] uppercase tracking-wider text-[#86868b]">
{label}
</span>
{children}
</div>
);
}
// ── Avatar ──────────────────────────────────────────────────────────────────
export function Avatar({ name, role }: { name: string; role: string }) {
const initials = name
.split(/\s+/)
.flatMap((n) => (n[0] ? [n[0]] : []))
.slice(0, 2)
.join("")
.toUpperCase();
const bg = role === "water_admin" ? "#1a4d2e" : "#57694e";
return (
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full font-mono text-[11px] font-bold text-white"
style={{ background: bg }}
aria-hidden
>
{initials || "·"}
</span>
);
}
// ── MonthSelect / DayInput ──────────────────────────────────────────────────
export function MonthSelect({
value,
onChange,
}: {
value: number;
onChange: (m: number) => void;
}) {
return (
<select
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
className="flex-1 rounded-lg border border-[#d4d9d3] px-2 py-1.5 text-sm"
aria-label="Month"
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>
{new Date(0, i).toLocaleString("en", { month: "short" })}
</option>
))}
</select>
);
}
export function DayInput({
value,
onChange,
}: {
value: number;
onChange: (d: number) => void;
}) {
return (
<input
type="number"
min={1}
max={31}
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10) || 1)}
className="w-16 rounded-lg border border-[#d4d9d3] px-2 py-1.5 text-sm"
aria-label="Day"
/>
);
}
// ── Th / Td (admin-table cells, used by the ReportPreviewModal) ─────────────
export function Th({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<th
className={`px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)] ${className}`}
>
{children}
</th>
);
}
export function Td({
children,
className = "",
colSpan,
}: {
children: React.ReactNode;
className?: string;
colSpan?: number;
}) {
return (
<td
colSpan={colSpan}
className={`px-3 py-2 text-sm text-[var(--admin-text-primary)] ${className}`}
>
{children}
</td>
);
}
@@ -0,0 +1,195 @@
/**
* ReportPreviewModal preview of the daily water report that would be
* sent by the cron. Built on the shared `GlassModal` so ESC, focus
* trapping, and scroll-lock match the rest of /admin.
*
* Triggered from the Today tab (or anywhere that needs to show the
* daily report payload); the preview state lives in the shell reducer.
*/
"use client";
import { useState } from "react";
import GlassModal from "@/components/admin/GlassModal";
import { formatDailyWaterReport } from "@/lib/water-log-reporting";
import type { NotifyFn, ReportPreviewData } from "../types";
import { Th, Td } from "./Primitives";
export function ReportPreviewModal({
data,
onClose,
notify,
}: {
data: ReportPreviewData | null;
onClose: () => void;
notify: NotifyFn;
}) {
const [copied, setCopied] = useState(false);
if (!data) return null;
const { rows, total, unit, dateLabel, dateIso, recipientName, inSeason } = data;
const totalUnit = rows[0]?.unit ?? unit ?? "CFS";
async function handleCopy() {
const text = formatDailyWaterReport(rows, {
recipientName: recipientName,
previewMode: true,
});
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
notify("Report copied to clipboard");
setTimeout(() => setCopied(false), 1600);
} catch {
notify("Copy failed — your browser blocked clipboard access", "err");
}
}
const subtitle =
`${rows.length} ${rows.length === 1 ? "entry" : "entries"} · ` +
`${total.toFixed(2)} ${totalUnit} total · ` +
(inSeason ? "in season" : "out of season");
return (
<GlassModal
title="Daily water report — preview"
subtitle={subtitle}
onClose={onClose}
maxWidth="max-w-2xl"
>
<div className="space-y-4">
{/* Meta line */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-[var(--admin-text-muted)]">
<span>
<span className="font-semibold text-[var(--admin-text-secondary)]">
Date:
</span>{" "}
{dateLabel}
</span>
<span className="hidden sm:inline text-[var(--admin-border)]">·</span>
<span className="font-mono text-[var(--admin-text-secondary)]">
{dateIso}
</span>
{recipientName ? (
<>
<span className="hidden sm:inline text-[var(--admin-border)]">
·
</span>
<span>
<span className="font-semibold text-[var(--admin-text-secondary)]">
Recipient:
</span>{" "}
{recipientName}
</span>
</>
) : null}
</div>
{/* Preview banner — keep it clear this hasn't been sent */}
<div
className="rounded-lg px-3 py-2 text-xs"
style={{
backgroundColor: "var(--admin-warning-bg, #fef9c3)",
color: "var(--admin-warning-text, #a16207)",
}}
>
Preview only. No message has been sent. Daily-report delivery is
handled by the scheduled{" "}
<code className="font-mono">api/cron/water-report</code> endpoint.
</div>
{/* Data table */}
<div className="overflow-hidden rounded-lg border border-[var(--admin-border)]">
<table className="w-full border-collapse text-sm">
<thead>
<tr style={{ backgroundColor: "var(--admin-bg-subtle)" }}>
<Th className="w-[72px]">Time</Th>
<Th>Headgate</Th>
<Th>Irrigator</Th>
<Th className="w-[88px] text-right">Volume</Th>
<Th>Note</Th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
const time = new Date(row.logged_at).toLocaleTimeString(
"en-US",
{ hour: "numeric", minute: "2-digit" },
);
return (
<tr
key={i}
className="border-t border-[var(--admin-border-light)]"
>
<Td className="font-mono text-[var(--admin-text-secondary)]">
{time}
</Td>
<Td className="font-medium text-[var(--admin-text-primary)]">
{row.headgate_name}
</Td>
<Td className="text-[var(--admin-text-secondary)]">
{row.user_name}
</Td>
<Td className="text-right">
<span className="font-mono font-semibold text-[var(--admin-text-primary)]">
{row.measurement.toFixed(2)}
</span>{" "}
<span className="text-xs text-[var(--admin-text-muted)]">
{row.unit}
</span>
</Td>
<Td className="text-[var(--admin-text-muted)]">
{row.notes ?? <span className="opacity-40"></span>}
</Td>
</tr>
);
})}
</tbody>
<tfoot>
<tr
className="border-t-2"
style={{ borderColor: "var(--admin-border)" }}
>
<Td
className="!border-0 pt-2 text-xs font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)]"
colSpan={3}
>
Total
</Td>
<Td className="!border-0 pt-2 text-right">
<span className="font-mono font-bold text-[var(--admin-text-primary)]">
{total.toFixed(2)}
</span>{" "}
<span className="text-xs text-[var(--admin-text-muted)]">
{totalUnit}
</span>
</Td>
<Td className="!border-0 pt-2">
<span className="opacity-40"></span>
</Td>
</tr>
</tfoot>
</table>
</div>
{/* Actions */}
<div className="flex flex-col-reverse items-stretch gap-2 pt-2 sm:flex-row sm:items-center sm:justify-end">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm font-semibold text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
Close
</button>
<button
type="button"
onClick={handleCopy}
className="rounded-lg bg-[var(--admin-accent)] px-4 py-2 text-sm font-bold text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
>
{copied ? "Copied" : "Copy plain text"}
</button>
</div>
</div>
</GlassModal>
);
}
@@ -0,0 +1,24 @@
/**
* ToastNotification bottom-right toast surface used by every tab.
*
* State lives in the parent reducer; this component only renders.
*/
"use client";
import type { Toast as ToastT } from "../types";
export function ToastNotification({ toast }: { toast: ToastT | null }) {
if (!toast) return null;
return (
<div
aria-live="polite"
className={`fixed bottom-6 right-6 z-50 rounded-xl px-4 py-3 font-medium shadow-lg transition-all ${
toast.kind === "ok"
? "border border-[#15803d] bg-[#dcfce7] text-[#15803d]"
: "border border-[#b91c1c] bg-[#fee2e2] text-[#b91c1c]"
}`}
>
{toast.msg}
</div>
);
}
@@ -0,0 +1,159 @@
/**
* Inline icons used by the Water Log admin module.
*
* All accept `currentColor` (or an explicit stroke color for the key
* icon, which always renders forest green for contrast against the
* cream "Field Almanac" palette).
*
* Kept here so each tab can import what it needs without dragging
* in the whole panel.
*/
import * as React from "react";
export function QrIcon() {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<rect x="2" y="2" width="5" height="5" />
<rect x="9" y="2" width="5" height="5" />
<rect x="2" y="9" width="5" height="5" />
<path d="M9 9 H11 V11 H9 Z" />
<path d="M12 9 H14 V12" />
<path d="M9 12 V14 H14" />
</svg>
);
}
export function GearIcon() {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<circle cx="8" cy="8" r="2.2" />
<path d="M8 1.5 V3.5 M8 12.5 V14.5 M1.5 8 H3.5 M12.5 8 H14.5 M3.3 3.3 L4.7 4.7 M11.3 11.3 L12.7 12.7 M3.3 12.7 L4.7 11.3 M11.3 4.7 L12.7 3.3" />
</svg>
);
}
export function FieldIcon() {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<path d="M2 11 L8 5 L11 8 L14 5" />
<path d="M2 14 H14" />
</svg>
);
}
export function ReportIcon() {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<rect x="3" y="2" width="10" height="12" rx="1" />
<path d="M5 5 H11 M5 8 H11 M5 11 H8" />
</svg>
);
}
export function DownloadIcon() {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<path d="M8 2 V10 M5 7 L8 10 L11 7" />
<path d="M2 12 V14 H14 V12" />
</svg>
);
}
export function KeyIcon() {
return (
<svg
width={28}
height={28}
viewBox="0 0 24 24"
fill="none"
stroke="#1a4d2e"
strokeWidth={1.6}
>
<circle cx="8" cy="12" r="4" />
<path d="M12 12 H22 M18 12 V16 M22 12 V15" />
</svg>
);
}
export function UserIcon() {
return (
<svg
width={36}
height={36}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<circle cx="12" cy="8" r="4" />
<path d="M4 21 C4 16 8 14 12 14 C16 14 20 16 20 21" />
</svg>
);
}
export function TableIcon() {
return (
<svg
width={36}
height={36}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<rect x="3" y="4" width="18" height="16" rx="1" />
<path d="M3 10 H21 M9 4 V20" />
</svg>
);
}
export function ClockIcon() {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<circle cx="8" cy="8" r="6" />
<path d="M8 4 V8 L11 10" />
</svg>
);
}
@@ -0,0 +1,272 @@
/**
* HeadgatesTab CRUD surface for water headgates.
*
* Lists every headgate in a card grid, with add/regenerate-token/delete
* controls. Owns its own form state via the shell reducer.
*/
"use client";
import * as React from "react";
import AdminButton from "@/components/admin/design-system/AdminButton";
import {
createWaterHeadgate,
deleteWaterHeadgate,
regenerateHeadgateToken,
type AdminEntry,
type AdminHeadgate,
} from "@/actions/water-log/admin";
import { WaterGauge } from "@/components/water/icons";
import { formatDateTime } from "@/lib/format-date";
import {
EmptyState,
Input,
SectionHeader,
Select,
StatusPill,
} from "../shared/Primitives";
import type { Action, NotifyFn } from "../types";
export type HeadgatesTabProps = {
brandId: string;
headgates: AdminHeadgate[];
lastEntryByHeadgate: Map<string, AdminEntry>;
canManage: boolean;
showAddHg: boolean;
newHg: { name: string; unit: string; notes: string };
savingHg: boolean;
notify: NotifyFn;
dispatch: React.Dispatch<Action>;
onEdit: (h: AdminHeadgate) => void;
};
export function HeadgatesTab({
brandId,
headgates,
lastEntryByHeadgate,
canManage,
showAddHg,
newHg,
savingHg,
notify,
dispatch,
onEdit,
}: HeadgatesTabProps) {
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
const trimmedName = newHg.name.trim();
if (!trimmedName) return;
dispatch({ type: "SET_SAVING_HG", value: true });
const res = await createWaterHeadgate(brandId, trimmedName, newHg.unit, {
notes: newHg.notes.trim() || null,
});
if (res.success && res.headgate) {
dispatch({ type: "ADD_HEADGATE", headgate: res.headgate });
dispatch({ type: "RESET_NEW_HG" });
notify("Headgate added");
} else {
notify(res.error ?? "Failed", "err");
}
dispatch({ type: "SET_SAVING_HG", value: false });
}
async function handleDelete(h: AdminHeadgate) {
if (
!window.confirm(
`Delete "${h.name}"? Existing log entries will be preserved.`,
)
)
return;
const res = await deleteWaterHeadgate(h.id);
if (res.success) {
dispatch({ type: "REMOVE_HEADGATE", id: h.id });
notify("Headgate removed");
} else {
notify(res.error ?? "Failed", "err");
}
}
async function handleRegenerateToken(h: AdminHeadgate) {
if (
!window.confirm(
`Regenerate QR token for "${h.name}"? Existing printed QR codes will stop working.`,
)
)
return;
const res = await regenerateHeadgateToken(h.id);
if (res.success && res.token) {
dispatch({
type: "UPDATE_HEADGATE_TOKEN",
id: h.id,
token: res.token,
});
notify("Token regenerated");
} else {
notify(res.error ?? "Failed", "err");
}
}
return (
<>
<SectionHeader
index="§ 01"
title="Headgates"
subtitle="Physical gates in the ditch network. Each gets a printable QR code so field workers can scan to log a reading without typing the gate name."
action={
canManage ? (
<AdminButton size="sm" onClick={() => dispatch({ type: "TOGGLE_ADD_HG" })}>
{showAddHg ? "Cancel" : "+ Add Headgate"}
</AdminButton>
) : null
}
/>
{showAddHg && canManage && (
<form
onSubmit={handleAdd}
className="mb-4 grid grid-cols-1 gap-3 rounded-xl border border-[#d4d9d3] bg-white p-4 sm:grid-cols-[1fr_120px_1fr_auto]"
>
<Input
label="Name"
value={newHg.name}
onChange={(v) => dispatch({ type: "SET_NEW_HG_NAME", value: v })}
placeholder="e.g. North Field Gate 1"
required
/>
<Select
label="Unit"
value={newHg.unit}
onChange={(v) => dispatch({ type: "SET_NEW_HG_UNIT", value: v })}
options={["CFS", "GPM", "Inches", "AF/Day", "gal", "ac-ft"]}
/>
<Input
label="Notes"
value={newHg.notes}
onChange={(v) => dispatch({ type: "SET_NEW_HG_NOTES", value: v })}
placeholder="Location, GPS, contact…"
/>
<div className="flex items-end">
<AdminButton
type="submit"
disabled={savingHg}
isLoading={savingHg}
fullWidth
>
Add
</AdminButton>
</div>
</form>
)}
{headgates.length === 0 ? (
<EmptyState
icon={<WaterGauge size={36} level={null} status="open" />}
title="No headgates yet"
body="Add a headgate to start logging. Each one gets a unique QR code you can print and post at the gate."
/>
) : (
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{headgates.map((h) => {
const last = lastEntryByHeadgate.get(h.id);
const level =
last && h.high_threshold
? Math.min(1, last.measurement / Number(h.high_threshold))
: null;
return (
<li
key={h.id}
className="group rounded-xl border border-[#d4d9d3] bg-white p-4 transition hover:border-[#1a4d2e]/40 hover:shadow-[0_4px_16px_rgba(26,77,46,0.08)]"
>
<div className="flex items-start gap-3">
<WaterGauge
size={36}
level={level}
status={h.status as "open" | "closed" | "maintenance"}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-[#1d1d1f]">
{h.name}
</h3>
<StatusPill
kind={
!h.active
? "inactive"
: h.status === "closed"
? "closed"
: h.status === "maintenance"
? "warn"
: "active"
}
/>
</div>
<p className="mt-0.5 font-mono text-[11px] uppercase tracking-wider text-[#86868b]">
Unit: {h.unit} · Token: {h.headgate_token.slice(0, 8)}
</p>
{last ? (
<p className="mt-2 text-sm text-[#57694e]">
Last:{" "}
<span className="font-mono font-semibold text-[#1d1d1f]">
{last.measurement} {h.unit}
</span>{" "}
<span className="text-[#86868b]">
by {last.user_name} · {formatDateTime(last.logged_at)}
</span>
</p>
) : (
<p className="mt-2 text-sm italic text-[#86868b]">
No readings yet
</p>
)}
{(h.high_threshold != null || h.low_threshold != null) && (
<p className="mt-1 font-mono text-[11px] text-[#57694e]">
{h.high_threshold != null && (
<>Hi&nbsp;&nbsp;{h.high_threshold}&nbsp;&nbsp;</>
)}
{h.low_threshold != null && (
<>Lo&nbsp;&nbsp;{h.low_threshold}</>
)}
</p>
)}
</div>
</div>
{canManage && (
<div className="mt-3 flex flex-wrap items-center justify-between gap-1 border-t border-[#e8ebe8] pt-3 text-[11px]">
<button
type="button"
onClick={() => onEdit(h)}
className="font-medium text-[#1a4d2e] hover:underline"
aria-label="Edit →"
>
Edit
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleRegenerateToken(h)}
className="text-[#57694e] hover:text-[#1a4d2e]"
aria-label="Rotate token"
>
Rotate token
</button>
<span className="text-[#d4d9d3]">·</span>
<button
type="button"
onClick={() => handleDelete(h)}
className="text-[#b91c1c] hover:text-[#7f1d1d]"
aria-label="Delete"
>
Delete
</button>
</div>
</div>
)}
</li>
);
})}
</ul>
)}
</>
);
}
@@ -0,0 +1,18 @@
/**
* Time Tracking tab embeds the existing TimeTrackingAdminPanel
* (which already has its own sub-tabs: Summary / Workers / Tasks /
* Logs / Settings) inside the Water Log shell.
*
* Cycle 3 this is the customer "hub" surface the water log now
* offers: one place to see today's entries, the headgates, who can
* log them, AND how many hours those workers put in.
*/
import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel";
export type TimeTrackingTabProps = {
brandId: string;
};
export function TimeTrackingTab({ brandId }: TimeTrackingTabProps) {
return <TimeTrackingAdminPanel brandId={brandId} />;
}
@@ -0,0 +1,588 @@
/**
* TodayTab the "Today" surface for the Water Log admin.
*
* Combines the hero gauge + summary stats with the recent entries
* table + filter bar. Holds the entry CRUD, CSV export, and
* "Generate Report" trigger.
*/
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import AdminButton from "@/components/admin/design-system/AdminButton";
import { WaterGauge, SeasonMark } from "@/components/water/icons";
import {
getWaterEntries,
type AdminEntry,
type AdminHeadgate,
type AdminIrrigator,
} from "@/actions/water-log/admin";
import {
downloadWaterLogCSV,
filterWaterLogEntries,
shapeWaterLogEntry,
type IrrigationSeasonSettings,
type WaterLogReportRow,
} from "@/lib/water-log-reporting";
import { formatDateTime } from "@/lib/format-date";
import {
DayInput,
EmptyState,
FilterChip,
MonthSelect,
SectionHeader,
StatTile,
} from "../shared/Primitives";
import { DownloadIcon, ReportIcon, TableIcon } from "../shared/icons";
import type { Action, NotifyFn, State } from "../types";
export type TodayTabProps = {
brandId: string;
state: State;
todayLabel: string;
todayEntries: AdminEntry[];
todayTotal: number;
inSeason: boolean;
notify: NotifyFn;
dispatch: React.Dispatch<Action>;
onPreviewReport: () => void;
onSeasonChange: (s: IrrigationSeasonSettings) => void;
onRecipientNameChange: (v: string) => void;
};
export function TodayTab({
brandId,
state,
todayLabel,
todayEntries,
todayTotal,
inSeason,
notify,
dispatch,
onPreviewReport,
onSeasonChange,
onRecipientNameChange,
}: TodayTabProps) {
const router = useRouter();
async function handleExportCSV() {
dispatch({ type: "SET_CSV_LOADING", value: true });
try {
const all = await getWaterEntries(brandId, 5000);
const shaped: WaterLogReportRow[] = all.map(shapeWaterLogEntry);
const exportFilters = {
dateFrom: state.filters.dateFrom || undefined,
dateTo: state.filters.dateTo || undefined,
headgateId: state.filters.headgate || undefined,
userId: state.filters.user || undefined,
submittedVia: state.filters.via || undefined,
};
const filtered = filterWaterLogEntries(shaped, exportFilters);
downloadWaterLogCSV(`water-log-${state.today}.csv`, filtered);
notify(`Exported ${filtered.length} entries`);
} finally {
dispatch({ type: "SET_CSV_LOADING", value: false });
}
}
return (
<>
<TodaySummary
todayLabel={todayLabel}
todayEntries={todayEntries}
todayTotal={todayTotal}
headgates={state.headgates}
users={state.users}
inSeason={inSeason}
seasonStart={state.seasonStart}
recipientName={state.recipientName}
onToggleReportSettings={() => dispatch({ type: "TOGGLE_REPORT_SETTINGS" })}
onSeasonChange={onSeasonChange}
onRecipientNameChange={onRecipientNameChange}
onPreviewReport={onPreviewReport}
showReportSettings={state.showReportSettings}
/>
<RecentEntriesSection
state={state}
dispatch={dispatch}
onEditEntry={(e) => router.push(`/admin/water-log/entries/${e.id}`)}
onExportCSV={handleExportCSV}
/>
</>
);
}
// ── TodaySummary (hero gauge + stats + report settings toggle) ──────────────
function TodaySummary({
todayLabel,
todayEntries,
todayTotal,
headgates,
users,
inSeason,
showReportSettings,
seasonStart,
recipientName,
onToggleReportSettings,
onSeasonChange,
onRecipientNameChange,
onPreviewReport,
}: {
todayLabel: string;
todayEntries: AdminEntry[];
todayTotal: number;
headgates: AdminHeadgate[];
users: AdminIrrigator[];
inSeason: boolean;
showReportSettings: boolean;
seasonStart: IrrigationSeasonSettings;
recipientName: string;
onToggleReportSettings: () => void;
onSeasonChange: (s: IrrigationSeasonSettings) => void;
onRecipientNameChange: (v: string) => void;
onPreviewReport: () => void;
}) {
return (
<section className="relative overflow-hidden rounded-2xl border border-[#d4d9d3] bg-[#fdfaf2] shadow-[0_1px_0_rgba(0,0,0,0.04)]">
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.18]"
style={{
backgroundImage:
"repeating-linear-gradient(45deg, transparent 0 14px, rgba(26,77,46,0.06) 14px 15px), repeating-linear-gradient(-45deg, transparent 0 14px, rgba(202,138,4,0.04) 14px 15px)",
}}
/>
<div className="relative grid grid-cols-1 gap-6 p-6 md:grid-cols-[200px_1fr] md:gap-8">
{/* Hero gauge */}
<div className="flex flex-col items-center justify-center gap-2">
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
Tuxedo Corn
</div>
<WaterGauge
size={120}
level={
todayEntries.length === 0
? null
: Math.min(1, todayTotal / Math.max(todayTotal, 100))
}
status="open"
ariaLabel="Today's water level"
/>
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
Vol. {todayTotal.toFixed(2)} {headgates[0]?.unit ?? "CFS"}
</div>
</div>
<div className="flex flex-col gap-4">
<div>
<h2
className="text-2xl font-semibold tracking-tight text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Today&apos;s Summary
</h2>
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-[#57694e]">
<SeasonMark inSeason={inSeason} size={14} />
{inSeason ? (
<span className="font-medium text-[#1a4d2e]">
In irrigation season
</span>
) : (
<span className="font-medium text-[#a16207]">
Outside irrigation season
</span>
)}
<span className="text-[#57694e]/60">·</span>
<span className="font-mono text-[#1d1d1f]">
{todayEntries.length}{" "}
{todayEntries.length === 1 ? "entry" : "entries"}
</span>
<span className="text-[#57694e]/60">·</span>
<span className="text-[#57694e]">{todayLabel}</span>
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatTile
label="Total volume"
value={todayTotal.toFixed(2)}
unit={headgates[0]?.unit ?? "CFS"}
accent
/>
<StatTile
label="Headgates"
value={headgates.filter((h) => h.active).length.toString()}
unit={`of ${headgates.length}`}
/>
<StatTile
label="Active irrigators"
value={users.filter((u) => u.active).length.toString()}
unit={`of ${users.length}`}
/>
<StatTile
label="Last entry"
value={
todayEntries.length
? formatDateTime(todayEntries[0].logged_at)
.split(",")
.pop()
?.trim() ?? "—"
: "—"
}
unit=""
mono
/>
</div>
<div className="flex flex-wrap gap-2">
<AdminButton
variant="secondary"
size="sm"
icon={<ReportIcon />}
onClick={onPreviewReport}
>
Preview Report
</AdminButton>
<AdminButton
variant={showReportSettings ? "primary" : "secondary"}
size="sm"
icon={
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<circle cx="8" cy="8" r="2.2" />
<path d="M8 1.5 V3.5 M8 12.5 V14.5 M1.5 8 H3.5 M12.5 8 H14.5 M3.3 3.3 L4.7 4.7 M11.3 11.3 L12.7 12.7 M3.3 12.7 L4.7 11.3 M11.3 4.7 L12.7 3.3" />
</svg>
}
onClick={onToggleReportSettings}
>
Report Settings
</AdminButton>
</div>
{showReportSettings && (
<ReportSettingsInline
season={seasonStart}
onSeasonChange={onSeasonChange}
recipientName={recipientName}
onRecipientNameChange={onRecipientNameChange}
/>
)}
</div>
</div>
</section>
);
}
// ── Inline Report Settings (kept under the toggle button) ───────────────────
function ReportSettingsInline({
season,
onSeasonChange,
recipientName,
onRecipientNameChange,
}: {
season: IrrigationSeasonSettings;
onSeasonChange: (s: IrrigationSeasonSettings) => void;
recipientName: string;
onRecipientNameChange: (v: string) => void;
}) {
return (
<div className="rounded-xl border border-[#d4d9d3] bg-white p-4">
<p className="mb-3 rounded-lg bg-[#fef9c3] px-3 py-2 text-xs text-[#a16207]">
<strong>Preview only.</strong> Daily-report delivery is wired through
the scheduled <code>api/cron/water-report</code> endpoint and respects
these settings.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ReportSeasonRow
label="Season start"
month={season.seasonStartMonth}
day={season.seasonStartDay}
onMonthChange={(m) =>
onSeasonChange({ ...season, seasonStartMonth: m })
}
onDayChange={(d) => onSeasonChange({ ...season, seasonStartDay: d })}
/>
<ReportSeasonRow
label="Season end"
month={season.seasonEndMonth}
day={season.seasonEndDay}
onMonthChange={(m) =>
onSeasonChange({ ...season, seasonEndMonth: m })
}
onDayChange={(d) => onSeasonChange({ ...season, seasonEndDay: d })}
/>
<div className="sm:col-span-2">
<label className="block">
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
Recipient name (used in report salutation)
</span>
<input
type="text"
value={recipientName}
onChange={(e) => onRecipientNameChange(e.target.value)}
placeholder="David"
aria-label="Recipient name"
className="w-full rounded-lg border border-[#d4d9d3] px-3 py-2 text-sm outline-none focus:border-[#1a4d2e]"
/>
</label>
</div>
</div>
</div>
);
}
function ReportSeasonRow({
label,
month,
day,
onMonthChange,
onDayChange,
}: {
label: string;
month: number;
day: number;
onMonthChange: (m: number) => void;
onDayChange: (d: number) => void;
}) {
return (
<div>
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
{label}
</span>
<div className="flex gap-1">
<MonthSelect value={month} onChange={onMonthChange} />
<DayInput value={day} onChange={onDayChange} />
</div>
</div>
);
}
// ── RecentEntriesSection (table + filters) ──────────────────────────────────
function RecentEntriesSection({
state,
dispatch,
onEditEntry,
onExportCSV,
}: {
state: State;
dispatch: React.Dispatch<Action>;
onEditEntry: (e: AdminEntry) => void;
onExportCSV: () => void;
}) {
const filteredEntries = state.entries.filter((e) => {
if (state.filters.dateFrom && e.logged_at < state.filters.dateFrom)
return false;
if (
state.filters.dateTo &&
e.logged_at > state.filters.dateTo + "T23:59:59.999Z"
)
return false;
if (state.filters.headgate && e.headgate_id !== state.filters.headgate)
return false;
if (state.filters.user && e.user_id !== state.filters.user) return false;
if (state.filters.via && e.submitted_via !== state.filters.via)
return false;
return true;
});
return (
<>
<SectionHeader
index="§ 03"
title="Recent Entries"
subtitle="The latest readings logged from the field or the admin panel. Use the filters to narrow, then export to CSV."
action={
<AdminButton
size="sm"
onClick={onExportCSV}
isLoading={state.csvLoading}
icon={<DownloadIcon />}
>
Export CSV
</AdminButton>
}
/>
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-xl border border-[#d4d9d3] bg-white p-2.5">
<FilterChip label="From">
<input
type="date"
value={state.filters.dateFrom}
onChange={(e) =>
dispatch({ type: "SET_FILTER_DATE_FROM", value: e.target.value })
}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: from date"
/>
</FilterChip>
<FilterChip label="To">
<input
type="date"
value={state.filters.dateTo}
onChange={(e) =>
dispatch({ type: "SET_FILTER_DATE_TO", value: e.target.value })
}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: to date"
/>
</FilterChip>
<FilterChip label="Headgate">
<select
value={state.filters.headgate}
onChange={(e) =>
dispatch({ type: "SET_FILTER_HEADGATE", value: e.target.value })
}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: headgate"
>
<option value="">All</option>
{state.headgates.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</FilterChip>
<FilterChip label="User">
<select
value={state.filters.user}
onChange={(e) =>
dispatch({ type: "SET_FILTER_USER", value: e.target.value })
}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: user"
>
<option value="">All</option>
{state.users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</FilterChip>
<FilterChip label="Via">
<select
value={state.filters.via}
onChange={(e) =>
dispatch({ type: "SET_FILTER_VIA", value: e.target.value })
}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: submission source"
>
<option value="">All</option>
<option value="field">Field</option>
<option value="admin">Admin</option>
<option value="app">App</option>
</select>
</FilterChip>
{(state.filters.dateFrom ||
state.filters.dateTo ||
state.filters.headgate ||
state.filters.user ||
state.filters.via) && (
<button
type="button"
onClick={() => dispatch({ type: "CLEAR_FILTERS" })}
className="text-xs font-medium text-[#57694e] hover:text-[#1a4d2e] hover:underline"
aria-label="Clear filters"
>
Clear filters
</button>
)}
<span className="ml-auto font-mono text-[11px] text-[#86868b]">
{filteredEntries.length} of {state.entries.length} entries
</span>
</div>
{filteredEntries.length === 0 ? (
<EmptyState
icon={<TableIcon />}
title="No entries match"
body={
state.entries.length === 0
? "No readings have been logged yet."
: "Try clearing the filters above."
}
/>
) : (
<div className="overflow-hidden rounded-xl border border-[#d4d9d3] bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#d4d9d3] bg-[#f5f3ef] text-left font-mono text-[10px] uppercase tracking-[0.15em] text-[#57694e]">
<th className="px-4 py-2.5">When</th>
<th className="px-4 py-2.5">User</th>
<th className="px-4 py-2.5">Headgate</th>
<th className="px-4 py-2.5 text-right">Measurement</th>
<th className="px-4 py-2.5 text-right">Gallons</th>
<th className="px-4 py-2.5">Method</th>
<th className="px-4 py-2.5">Via</th>
<th className="px-4 py-2.5" aria-label="Actions"></th>
</tr>
</thead>
<tbody>
{filteredEntries.map((e, i) => (
<tr
key={e.id}
className={`group border-b border-[#e8ebe8] last:border-0 transition-colors hover:bg-[#fdfaf2] ${
i % 2 === 0 ? "" : "bg-[#fafaf7]"
}`}
>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-[#57694e]">
{formatDateTime(e.logged_at)}
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-[#1d1d1f]">
{e.user_name}
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-[#1d1d1f]">
{e.headgate_name}
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-right font-mono font-semibold text-[#1d1d1f]">
{e.measurement}{" "}
<span className="text-[10px] text-[#86868b]">{e.unit}</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-right font-mono text-[#57694e]">
{e.total_gallons != null
? e.total_gallons.toFixed(1)
: "—"}
</td>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-[11px] uppercase text-[#57694e]">
{e.method}
</td>
<td className="whitespace-nowrap px-4 py-2.5">
<span
className={`inline-block rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider ${
e.submitted_via === "field"
? "bg-[#dcfce7] text-[#15803d]"
: "bg-[#e8ebe8] text-[#57694e]"
}`}
>
{e.submitted_via}
</span>
</td>
<td className="px-4 py-2.5 text-right text-[11px]">
<button
type="button"
onClick={() => onEditEntry(e)}
className="font-medium text-[#1a4d2e] opacity-0 transition-opacity group-hover:opacity-100 hover:underline"
aria-label="Edit →"
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}
@@ -0,0 +1,270 @@
/**
* UsersTab CRUD surface for water users (irrigators + admins).
*
* Lists every user in a card grid, with add / reset-PIN / deactivate
* controls. PINs are shown one-at-a-time via the PinBanner primitive.
*/
"use client";
import * as React from "react";
import AdminButton from "@/components/admin/design-system/AdminButton";
import {
createWaterUser,
deleteWaterUser,
resetWaterIrrigatorPin,
type AdminIrrigator,
} from "@/actions/water-log/admin";
import { formatDate } from "@/lib/format-date";
import {
Avatar,
EmptyState,
Input,
PinBanner,
RoleLegend,
SectionHeader,
Select,
} from "../shared/Primitives";
import { UserIcon } from "../shared/icons";
import type { Action, NotifyFn, PinInfo, UserForm } from "../types";
export type UsersTabProps = {
brandId: string;
users: AdminIrrigator[];
canManage: boolean;
showAddUser: boolean;
newUser: UserForm;
savingUser: boolean;
newPin: PinInfo | null;
resetPin: PinInfo | null;
notify: NotifyFn;
dispatch: React.Dispatch<Action>;
};
export function UsersTab({
brandId,
users,
canManage,
showAddUser,
newUser,
savingUser,
newPin,
resetPin,
notify,
dispatch,
}: UsersTabProps) {
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
const trimmedName = newUser.name.trim();
if (!trimmedName) return;
dispatch({ type: "SET_SAVING_USER", value: true });
const res = await createWaterUser(
brandId,
trimmedName,
newUser.role,
newUser.lang,
newUser.phone.trim() || null,
);
if (res.success && res.user && res.pin) {
dispatch({ type: "ADD_USER", user: res.user });
dispatch({
type: "SET_NEW_PIN",
pin: { name: res.user.name, pin: res.pin },
});
dispatch({ type: "RESET_NEW_USER" });
notify("User created");
} else {
notify(res.error ?? "Failed", "err");
}
dispatch({ type: "SET_SAVING_USER", value: false });
}
async function handleResetPin(u: AdminIrrigator) {
if (
!window.confirm(
`Reset PIN for "${u.name}"? Their current PIN will stop working.`,
)
)
return;
const res = await resetWaterIrrigatorPin(u.id);
if (res.success && res.pin) {
dispatch({
type: "SET_RESET_PIN",
pin: { name: u.name, pin: res.pin },
});
notify("PIN reset");
} else {
notify(res.error ?? "Failed", "err");
}
}
async function handleDelete(u: AdminIrrigator) {
if (
!window.confirm(
`Deactivate "${u.name}"? Their existing log entries will be preserved.`,
)
)
return;
const res = await deleteWaterUser(u.id);
if (res.success) {
dispatch({ type: "DEACTIVATE_USER", id: u.id });
notify("User deactivated");
} else {
notify(res.error ?? "Failed", "err");
}
}
return (
<>
<SectionHeader
index="§ 02"
title="Water Users"
subtitle="The people who log readings in the field. Each gets a 4-digit PIN. Admin role can manage headgates, users, and entries; Irrigators can only submit."
action={
canManage ? (
<AdminButton
size="sm"
onClick={() => dispatch({ type: "TOGGLE_ADD_USER" })}
>
{showAddUser ? "Cancel" : "+ Add User"}
</AdminButton>
) : null
}
/>
{newPin && (
<PinBanner
title={`New PIN for ${newPin.name}`}
pin={newPin.pin}
onDismiss={() => dispatch({ type: "CLEAR_NEW_PIN" })}
/>
)}
{resetPin && (
<PinBanner
title={`Reset PIN for ${resetPin.name}`}
pin={resetPin.pin}
tone="warn"
onDismiss={() => dispatch({ type: "CLEAR_RESET_PIN" })}
/>
)}
{showAddUser && canManage && (
<form
onSubmit={handleAdd}
className="mb-4 grid grid-cols-1 gap-3 rounded-xl border border-[#d4d9d3] bg-white p-4 sm:grid-cols-[1fr_140px_120px_1fr_auto]"
>
<Input
label="Name"
value={newUser.name}
onChange={(v) =>
dispatch({ type: "SET_NEW_USER_NAME", value: v })
}
placeholder="Full name"
required
/>
<Select
label="Role"
value={newUser.role}
onChange={(v) =>
dispatch({
type: "SET_NEW_USER_ROLE",
value: v as "irrigator" | "water_admin",
})
}
options={[
{ value: "irrigator", label: "Irrigator" },
{ value: "water_admin", label: "Admin" },
]}
/>
<Select
label="Language"
value={newUser.lang}
onChange={(v) =>
dispatch({ type: "SET_NEW_USER_LANG", value: v })
}
options={[
{ value: "en", label: "English" },
{ value: "es", label: "Español" },
]}
/>
<Input
label="Phone"
value={newUser.phone}
onChange={(v) =>
dispatch({ type: "SET_NEW_USER_PHONE", value: v })
}
placeholder="+1…"
type="tel"
/>
<div className="flex items-end">
<AdminButton
type="submit"
disabled={savingUser}
isLoading={savingUser}
fullWidth
>
Add
</AdminButton>
</div>
</form>
)}
<RoleLegend />
{users.length === 0 ? (
<EmptyState
icon={<UserIcon />}
title="No water users yet"
body="Add an irrigator or admin to grant field access. Their PIN is generated automatically — write it down before dismissing."
/>
) : (
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{users.map((u) => (
<li
key={u.id}
className={`flex items-center gap-3 rounded-xl border bg-white px-3 py-2 ${
u.active
? "border-[#d4d9d3]"
: "border-[#d4d9d3] bg-[#f5f3ef] opacity-70"
}`}
>
<Avatar name={u.name} role={u.role} />
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-[#1d1d1f]">
{u.name}
</p>
<p className="font-mono text-[11px] text-[#86868b]">
{u.role === "water_admin" ? "ADMIN" : "IRRIGATOR"} ·{" "}
{u.language_preference === "es" ? "ES" : "EN"}
{u.last_used_at
? ` · seen ${formatDate(u.last_used_at)}`
: " · never signed in"}
</p>
</div>
{canManage && (
<div className="flex shrink-0 items-center gap-1 text-[11px]">
<button
type="button"
onClick={() => handleResetPin(u)}
className="text-[#57694e] hover:text-[#1a4d2e]"
aria-label="PIN"
>
PIN
</button>
<span className="text-[#d4d9d3]">·</span>
<button
type="button"
onClick={() => handleDelete(u)}
className="text-[#b91c1c] hover:text-[#7f1d1d]"
aria-label="Off"
>
Off
</button>
</div>
)}
</li>
))}
</ul>
)}
</>
);
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Shared types for the Water Log admin module.
*
* Split out of the original monolithic `WaterLogAdminPanel.tsx` so each
* tab / shared component can declare its own prop types without
* pulling in the full reducer surface.
*
* @see ./reducer.ts for State + Action.
*/
import type {
AdminEntry,
AdminHeadgate,
AdminIrrigator,
} from "@/actions/water-log/admin";
import type { IrrigationSeasonSettings } from "@/lib/water-log-reporting";
// ── Toast ────────────────────────────────────────────────────────────────────
export type Toast = { msg: string; kind: "ok" | "err" };
// ── Forms ────────────────────────────────────────────────────────────────────
export type HgForm = { name: string; unit: string; notes: string };
export type UserForm = {
name: string;
role: "irrigator" | "water_admin";
lang: string;
phone: string;
};
// ── PIN disclosure ───────────────────────────────────────────────────────────
export type PinInfo = { name: string; pin: string };
// ── Report preview payload ───────────────────────────────────────────────────
export type ReportPreviewData = {
rows: import("@/lib/water-log-reporting").WaterLogReportRow[];
total: number;
unit: string;
dateLabel: string;
dateIso: string;
recipientName?: string;
inSeason: boolean;
};
// ── Reducer surface (mirrored here so callers don't import reducer internals)
export type State = {
headgates: AdminHeadgate[];
users: AdminIrrigator[];
entries: AdminEntry[];
toast: Toast | null;
forms: {
showAddHg: boolean;
newHg: HgForm;
savingHg: boolean;
showAddUser: boolean;
newUser: UserForm;
savingUser: boolean;
newPin: PinInfo | null;
resetPin: PinInfo | null;
};
filters: {
dateFrom: string;
dateTo: string;
headgate: string;
user: string;
via: string;
};
csvLoading: boolean;
today: string;
todayLabel: string;
showReportSettings: boolean;
seasonStart: IrrigationSeasonSettings;
recipientName: string;
reportPreview: ReportPreviewData | null;
};
export type Action =
// Headgates
| { type: "ADD_HEADGATE"; headgate: AdminHeadgate }
| { type: "REMOVE_HEADGATE"; id: string }
| { type: "UPDATE_HEADGATE_TOKEN"; id: string; token: string }
// Users
| { type: "ADD_USER"; user: AdminIrrigator }
| { type: "DEACTIVATE_USER"; id: string }
// Toast
| { type: "SET_TOAST"; toast: Toast }
| { type: "CLEAR_TOAST" }
// Forms - Headgate
| { type: "TOGGLE_ADD_HG" }
| { type: "SET_NEW_HG_NAME"; value: string }
| { type: "SET_NEW_HG_UNIT"; value: string }
| { type: "SET_NEW_HG_NOTES"; value: string }
| { type: "RESET_NEW_HG" }
| { type: "SET_SAVING_HG"; value: boolean }
// Forms - User
| { type: "TOGGLE_ADD_USER" }
| { type: "SET_NEW_USER_NAME"; value: string }
| { type: "SET_NEW_USER_ROLE"; value: "irrigator" | "water_admin" }
| { type: "SET_NEW_USER_LANG"; value: string }
| { type: "SET_NEW_USER_PHONE"; value: string }
| { type: "RESET_NEW_USER" }
| { type: "SET_SAVING_USER"; value: boolean }
| { type: "SET_NEW_PIN"; pin: PinInfo }
| { type: "CLEAR_NEW_PIN" }
| { type: "SET_RESET_PIN"; pin: PinInfo }
| { type: "CLEAR_RESET_PIN" }
// Filters
| { type: "SET_FILTER_DATE_FROM"; value: string }
| { type: "SET_FILTER_DATE_TO"; value: string }
| { type: "SET_FILTER_HEADGATE"; value: string }
| { type: "SET_FILTER_USER"; value: string }
| { type: "SET_FILTER_VIA"; value: string }
| { type: "CLEAR_FILTERS" }
// Misc UI
| { type: "SET_CSV_LOADING"; value: boolean }
| { type: "SET_TODAY"; today: string; label: string }
| { type: "TOGGLE_REPORT_SETTINGS" }
| { type: "SET_SEASON_START"; value: IrrigationSeasonSettings }
| { type: "SET_RECIPIENT_NAME"; value: string }
| { type: "OPEN_REPORT_PREVIEW"; preview: ReportPreviewData }
| { type: "CLOSE_REPORT_PREVIEW" };
// ── Convenience ──────────────────────────────────────────────────────────────
export type NotifyFn = (msg: string, kind?: "ok" | "err") => void;
+372
View File
@@ -0,0 +1,372 @@
"use client";
/**
* FieldPinScreen shared Apple HIG PIN entry, used by both the
* Water Log field app and the Time Tracking field app.
*
* Visual system (cycle 11 visual cohesion):
* - Surface: `#F2F2F7` Apple system grey (matches iOS Keyboard,
* secondarySystemBackground). Same surface for both apps so a
* worker stepping from `/water` into `/tuxedo/time-clock` (or
* vice versa) feels they switched modes, not apps.
* - Brand mark: a 22px-rounded square with a subtle greenwhite
* gradient, holding the brand icon. The icon is the only thing
* that differs between apps Droplet for water, Clock for
* time. The square + gradient + ring shadow + tracked wordmark
* strap are the recognizable signature.
* - Four large digit boxes (68×58) that mirror the value of a
* hidden <input>. The hidden input is the source of truth so
* iOS's QuickType / password-manager suggestions fire
* correctly. Boxes never echo a digit back; they show a filled
* dot, with a blinking caret in the active slot.
* - Full-width forest-green primary with an inner highlight; on
* idle: low-contrast grey placeholder.
* - Inline error replaces the caption. Failure = 0.4s shake of
* the digit-box row (`field-shake` keyframe, mirrors
* `water-shake` semantically three diminishing oscillations
* so it reads as "no, that wasn't right").
*
* Props are intentionally a small surface (length, onSubmit, app
* identity, translations, brand color) so both call sites stay
* thin. The `shakeEventName` lets the parent fire the same shake
* when the verify-result returned from the server is "PIN wrong".
*/
import {
useCallback,
useEffect,
useRef,
useState,
type FormEvent,
} from "react";
import {
Droplet,
Clock,
Spinner,
type IconProps,
} from "@/components/field/icons";
// ── Domain types ──────────────────────────────────────────────────────────
export type FieldPinIcon = "droplet" | "clock";
export type FieldPinTranslations = {
/** Headline above the digit boxes. */
title: string;
/** Caption below the headline (and the in-line error slot). */
caption: string;
/** Unlock button label at idle. */
unlock: string;
/** Unlock button label while verifying. */
verifying: string;
/** Footer hint (e.g. "Forgot your PIN? Ask the office"). */
forgot: string;
/** Fallback error when `onSubmit` throws. */
genericError: string;
/** a11y label for the hidden PIN input. */
a11yPin: string;
/** a11y label for the digit-box tap target. */
a11yPinEntry: string;
};
export type FieldPinBrand = {
/** Wordmark strap (already tracked-uppercase in the rendered DOM). */
wordmark: string;
/** Brand color used for the primary button, active digit ring, etc. */
primary?: string;
/** Domain icon shown inside the brand mark. */
icon: FieldPinIcon;
};
type Props = {
/** Called when the user submits a `length`-digit PIN. May throw. */
onSubmit: (pin: string) => Promise<void>;
/** PIN length, matches `PIN_MIN`/`PIN_MAX` from water-log-pin. */
length?: 4 | 5 | 6 | 7 | 8;
brand: FieldPinBrand;
translations: FieldPinTranslations;
/**
* If a parent has a separate "PIN wrong" result path (e.g. server
* returned `{ ok: false }` instead of throwing), it can dispatch
* a DOM CustomEvent of this name to trigger the same shake + reset
* the input gets on a thrown submit error.
*/
shakeEventName?: string;
};
// ── Brand mark ────────────────────────────────────────────────────────────
const ICONS: Record<FieldPinIcon, (p: IconProps) => React.JSX.Element> = {
droplet: Droplet,
clock: Clock,
};
function BrandMark({ icon, primary = "#14532D" }: { icon: FieldPinIcon; primary?: string }) {
const Icon = ICONS[icon];
return (
<div
className="mb-7 flex justify-center"
aria-hidden
>
<div
className="flex h-20 w-20 items-center justify-center rounded-[22px]"
style={{
background:
"linear-gradient(160deg, #ffffff 0%, #f0f7f2 60%, #e1efe5 100%)",
boxShadow:
"0 1px 0 rgba(255,255,255,0.9) inset, 0 6px 16px rgba(20,83,45,0.18), 0 1px 3px rgba(0,0,0,0.06)",
}}
>
<Icon size={32} className="" style={{ color: primary }} />
</div>
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────
export function FieldPinScreen({
onSubmit,
length = 4,
brand,
translations: t,
shakeEventName = "field-pin-shake",
}: Props) {
const [pin, setPin] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [shake, setShake] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const timer = window.setTimeout(
() => inputRef.current?.focus(),
80,
);
return () => window.clearTimeout(timer);
}, []);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value.replace(/\D/g, "").slice(0, length);
setPin(next);
if (error) setError(null);
},
[error, length],
);
const resetForFailure = useCallback(() => {
setShake(true);
window.setTimeout(() => setShake(false), 420);
setPin("");
inputRef.current?.focus();
}, []);
const submit = useCallback(
async (e?: FormEvent) => {
e?.preventDefault();
if (pin.length !== length || submitting) return;
setSubmitting(true);
setError(null);
try {
await onSubmit(pin);
// Don't reset here — the parent will navigate away.
} catch (err) {
setError(err instanceof Error ? err.message : t.genericError);
resetForFailure();
} finally {
setSubmitting(false);
}
},
[pin, length, submitting, onSubmit, t.genericError, resetForFailure],
);
// External shake trigger (parent fires this when verify returns false).
useEffect(() => {
function onShake() {
resetForFailure();
}
window.addEventListener(shakeEventName, onShake);
return () => window.removeEventListener(shakeEventName, onShake);
}, [resetForFailure, shakeEventName]);
const ready = pin.length === length && !submitting;
const primary = brand.primary ?? "#14532D";
return (
// `min-h-[100dvh]` covers the viewport regardless of parent layout
// — the parent wraps us in a fixed-inset-0 container on the
// Time Tracking route, but we don't depend on that to fill.
// `100dvh` excludes iOS Safari's collapsing chrome; the legacy
// `100vh` fallback keeps Android/older browsers full-bleed too.
<div className="flex min-h-[100dvh] flex-col bg-[#F2F2F7]">
{/* ── Branded header (Apple HIG liquid glass) ───────────────── */}
<header
className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]"
style={{
// Fades the chrome into the form below so the brand strap
// feels suspended in the air rather than locked to the
// top — same trick Apple uses on the iOS lock screen.
background:
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.62) 60%, rgba(242,242,247,0) 100%)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
boxShadow:
"inset 0 -0.5px 0 0 rgba(0,0,0,0.04)",
}}
>
<div className="mx-auto flex max-w-md items-center justify-center gap-2">
{(() => {
const HeaderIcon = ICONS[brand.icon];
return <HeaderIcon size={18} style={{ color: primary }} />;
})()}
<span
className="text-[12px] font-bold tracking-[0.22em]"
style={{
color: primary,
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
{brand.wordmark}
</span>
</div>
</header>
{/* ── Centered content ─────────────────────────────────────── */}
<main className="flex flex-1 flex-col items-center justify-center px-6 pb-[max(env(safe-area-inset-bottom),1.25rem)]">
<div className="w-full max-w-sm">
<BrandMark icon={brand.icon} primary={primary} />
<h1
className="mb-2 text-center text-[28px] font-bold leading-[1.15] tracking-tight text-[#1d1d1f]"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
{t.title}
</h1>
<p
className={[
"mb-9 min-h-[20px] text-center text-[15px]",
error ? "text-[#FF3B30]" : "text-[rgba(60,60,67,0.6)]",
].join(" ")}
role={error ? "alert" : undefined}
aria-live={error ? "assertive" : undefined}
>
{error ?? t.caption}
</p>
{/* ── Digit boxes ────────────────────────────────────── */}
<form onSubmit={submit}>
<input
ref={inputRef}
type="password"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="one-time-code"
maxLength={length}
value={pin}
onChange={handleChange}
className="sr-only"
aria-label={t.a11yPin}
disabled={submitting}
/>
<button
type="button"
onClick={() => inputRef.current?.focus()}
aria-label={t.a11yPinEntry}
className="mx-auto mb-8 flex w-full justify-center gap-3 outline-none"
style={
shake
? {
animation:
"field-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97)",
}
: undefined
}
>
{Array.from({ length }).map((_, i) => {
const filled = i < pin.length;
const active = i === pin.length;
return (
<div
key={i}
className="relative flex h-[68px] w-[58px] items-center justify-center rounded-[14px] bg-white text-[34px] font-bold leading-none text-[#1d1d1f]"
style={{
boxShadow: active
? `0 0 0 2px ${primary}, 0 1px 2px rgba(0,0,0,0.04), 0 4px 14px rgba(20,83,45,0.18)`
: "0 0 0 1px rgba(60,60,67,0.18), 0 1px 2px rgba(0,0,0,0.04)",
transition: "box-shadow 180ms cubic-bezier(0.32,0.72,0,1)",
}}
>
{filled ? (
<span className="relative" aria-hidden>
<span
className="block h-[14px] w-[14px] rounded-full"
style={{ background: "#1d1d1f" }}
/>
</span>
) : null}
{active && !filled && (
<span
aria-hidden
className="block h-[28px] w-[2px] rounded-full"
style={{
background: primary,
animation: "field-blink 1.05s steps(2, start) infinite",
}}
/>
)}
</div>
);
})}
</button>
<button type="submit" className="sr-only" tabIndex={-1}>
{t.unlock}
</button>
</form>
{/* ── Unlock button ─────────────────────────────────── */}
<button
type="button"
onClick={() => submit()}
disabled={!ready}
className={[
"flex h-[58px] w-full items-center justify-center gap-2 rounded-[14px] text-[17px] font-semibold",
"transition-[transform,background-color,opacity] duration-200",
"active:scale-[0.985]",
ready
? "text-white shadow-[0_6px_18px_rgba(20,83,45,0.28),inset_0_1px_0_rgba(255,255,255,0.16)]"
: "bg-[rgba(120,120,128,0.16)] text-[rgba(60,60,67,0.4)]",
].join(" ")}
style={ready ? { background: primary } : undefined}
>
{submitting ? (
<>
<Spinner
size={20}
className="text-white"
style={{ animation: "spin 0.8s linear infinite" }}
/>
{t.verifying}
</>
) : (
<>{t.unlock}</>
)}
</button>
{/* Footer hint */}
<p className="mt-7 text-center text-[12px] text-[rgba(60,60,67,0.45)]">
{t.forgot}
</p>
</div>
</main>
</div>
);
}
export default FieldPinScreen;
+16
View File
@@ -0,0 +1,16 @@
/**
* Field-domain icons (re-exports the SF-Symbol-style primitives from
* the water icon set, plus the shared `IconProps` type). This module
* is the canonical import path for shared field app icons so that
* `FieldPinScreen` and any future shared field components don't have
* to reach into `@/components/water/mobile/icons`.
*
* The actual icon definitions still live in
* `@/components/water/mobile/icons` because they're shared by both
* the water and time-tracking field apps, and moving them would
* touch too many callers in one go. When the next field app shows
* up, it should import from this barrel.
*/
export type { IconProps } from "@/components/water/mobile/icons";
export { Droplet, Clock, Spinner } from "@/components/water/mobile/icons";
+168
View File
@@ -0,0 +1,168 @@
/**
* FieldInstallPrompt install prompt for the worker sub-PWA.
*
* Cycle 6 sibling of `InstallPrompt` (mounted from the root layout).
* This one fires only on /water routes and uses the field-PWA
* branding (forest green icon, "Water Log — Tuxedo") so the prompt
* that surfaces matches the manifest that will be installed.
*
* Same `BeforeInstallPromptEvent` capture pattern as InstallPrompt;
* split into a separate component file because the worker surface
* has its own theming + copy.
*/
"use client";
import { useEffect, useSyncExternalStore, useCallback, useReducer } from "react";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
type PromptState = {
deferred: BeforeInstallPromptEvent | null;
visible: boolean;
};
type PromptAction =
| { type: "captured"; event: BeforeInstallPromptEvent }
| { type: "show" }
| { type: "hide" }
| { type: "dismissed" }
| { type: "reset" };
function promptReducer(state: PromptState, action: PromptAction): PromptState {
switch (action.type) {
case "captured":
return { deferred: action.event, visible: false };
case "show":
return state.deferred ? { ...state, visible: true } : state;
case "hide":
return { deferred: null, visible: false };
case "dismissed":
return { ...state, visible: false };
case "reset":
return { deferred: null, visible: false };
}
}
const INITIAL_PROMPT_STATE: PromptState = { deferred: null, visible: false };
export function FieldInstallPrompt() {
const [prompt, dispatch] = useReducer(promptReducer, INITIAL_PROMPT_STATE);
const isInstalled = useSyncExternalStore(
() => () => {},
() =>
typeof window !== "undefined" &&
(window.matchMedia("(display-mode: standalone)").matches ||
// iOS Safari uses navigator.standalone for the home-screen PWA
// launch. Cover both.
(typeof (navigator as Navigator & { standalone?: boolean }).standalone ===
"boolean" &&
(navigator as Navigator & { standalone?: boolean }).standalone === true)),
() => false,
);
const mounted = useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
useEffect(() => {
if (isInstalled) return;
if (typeof window === "undefined") return;
const handleBeforeInstall = (e: Event) => {
e.preventDefault();
dispatch({ type: "captured", event: e as BeforeInstallPromptEvent });
// Show the field prompt sooner than the main one — workers
// know they want this installed on day one.
window.setTimeout(() => {
dispatch({ type: "show" });
}, 4000);
};
const handleAppInstalled = () => {
dispatch({ type: "reset" });
};
window.addEventListener("beforeinstallprompt", handleBeforeInstall);
window.addEventListener("appinstalled", handleAppInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", handleBeforeInstall);
window.removeEventListener("appinstalled", handleAppInstalled);
};
}, [isInstalled]);
const handleInstall = useCallback(async () => {
const deferred = prompt.deferred;
if (!deferred) return;
await deferred.prompt();
const { outcome } = await deferred.userChoice;
void outcome;
dispatch({ type: "reset" });
}, [prompt.deferred]);
const handleDismiss = useCallback(() => {
dispatch({ type: "dismissed" });
try {
sessionStorage.setItem("field-pwa-install-dismissed", "true");
} catch {
// ignore
}
}, []);
if (!mounted || !prompt.visible || isInstalled || !prompt.deferred) {
return null;
}
return (
<div className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 bg-white rounded-2xl shadow-2xl border border-[#1a4d2e]/30 p-4 z-50">
<div className="flex items-start gap-3">
<div className="w-12 h-12 bg-[#1a4d2e] rounded-xl flex items-center justify-center shrink-0">
<svg
className="w-6 h-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"
/>
</svg>
</div>
<div className="flex-1">
<h3 className="font-semibold text-[#1a4d2e]">
Install Water Log
</h3>
<p className="text-sm text-[rgba(60,60,67,0.65)] mt-1">
One tap to a full-screen PIN entry from your home screen.
</p>
</div>
</div>
<div className="flex gap-2 mt-4">
<button
type="button"
onClick={handleDismiss}
className="flex-1 px-4 py-2 text-sm font-medium text-[rgba(60,60,67,0.55)] hover:text-[rgba(60,60,67,0.85)] transition-colors"
>
Not now
</button>
<button
type="button"
onClick={handleInstall}
className="flex-1 px-4 bg-[#1a4d2e] text-white rounded-lg text-sm font-medium hover:bg-[#14432a] py-2 transition-colors"
>
Install
</button>
</div>
</div>
);
}
export default FieldInstallPrompt;
@@ -0,0 +1,43 @@
/**
* FieldSWRegistration registers /sw-field.js with scope=/water.
*
* Cycle 6 component used by the worker sub-PWA. The component is
* mounted from src/app/water/layout.tsx (a thin client boundary
* inside the otherwise-server layout) so the registration only
* happens on /water routes never on /admin, /tuxedo, etc.
*
* No-ops on:
* - Server (guards with typeof navigator)
* - repeat mounts (uses a module-scoped flag so React strict-mode
* double-mount doesn't double-register)
*
* Same registration pattern as next-pwa we use the raw
* ServiceWorkerContainer API to stay framework-free.
*/
"use client";
import { useEffect } from "react";
let registered = false;
export function FieldSWRegistration() {
useEffect(() => {
if (typeof navigator === "undefined") return;
if (!("serviceWorker" in navigator)) return;
if (registered) return;
registered = true;
void navigator.serviceWorker
.register("/sw-field.js", { scope: "/water" })
.catch((err) => {
// Surface to console for ops; never block the UI. The
// app works without the SW — it just loses offline shell
// caching for /water.
console.error("[field-sw] registration failed", err);
// Allow a retry on a later mount.
registered = false;
});
}, []);
return null;
}
export default FieldSWRegistration;
+317 -158
View File
@@ -1,12 +1,11 @@
"use client";
import Image from "next/image";
import { useEffect, useReducer, useRef } from "react";
import { useEffect, useReducer, useRef, useState } from "react";
import { m as motion } from "framer-motion";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
// Register GSAP plugins
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger);
}
@@ -16,14 +15,19 @@ type TuxedoVideoHeroProps = {
title: string;
description: string;
olatheSweetLogoUrl?: string | null;
/** URL of a video (mp4/webm) to play muted & looped as the hero backdrop. */
videoUrl?: string | null;
/** Image shown as the video poster and as the static fallback if the
* video fails to load, is blocked by autoplay policies, or the user
* prefers reduced motion. Without this, the gradient stack stands on
* its own. */
posterUrl?: string | null;
primaryButton?: string;
secondaryButton?: string;
onPrimaryClick?: () => void;
onSecondaryClick?: () => void;
};
const VIDEO_URL = "/videos/tuxedo-hero.mp4";
const HERO_STATS = [
{ stat: "40+", label: "Years Growing" },
{ stat: "3", label: "Generations" },
@@ -47,14 +51,25 @@ function reducer(state: State, action: Action): State {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// CINEMATIC HERO - SCROLL-DRIVEN ANIMATIONS
// ─────────────────────────────────────────────────────────────────────────────
/**
* The big thesis panel for the storefront. Composes, in z-order:
* - poster image (Next/Image, full-bleed, always present)
* - optional muted looping video (renders only when its URL loads)
* - gradient overlays (warm gold -> deep night)
* - foreground editorial content (logo / eyebrow / title / copy / CTAs / stats)
*
* The hero is designed to feel complete as a STILL even without the video.
* If `videoUrl` is missing, errors, or autoplay is blocked, the poster
* image stays as the background and the page is not left with an empty
* dark hole.
*/
export default function TuxedoVideoHero({
eyebrow,
title,
description,
olatheSweetLogoUrl,
videoUrl,
posterUrl,
primaryButton,
secondaryButton,
onPrimaryClick,
@@ -64,22 +79,41 @@ export default function TuxedoVideoHero({
const sectionRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [state, dispatch] = useReducer(reducer, initialState);
// ─── GSAP SCROLL ANIMATIONS ────────────────────────────────────────────────
// Motion pass: the original used a 1.15 video scale, an 80px y-shift on the
// hero content, a parallax -100/+80 movement on decorative orbs, and a 1s
// 40px slide-up entrance with power3.out easing. All of those are strong
// vestibular triggers. The motion here is now:
// - tiny entrance slide (8px) on first paint, no scale, 320ms, linear-ish ease
// - video stays static on scroll (no parallax)
// - decorative orbs float on a 10s CSS keyframe (gentle, opt-in to reduced motion)
// - scroll indicator just fades on scroll, no positional shift
// `videoPlaying` reflects whether the <video> successfully produced frames.
// It can flip false at any time: 404, codec error, autoplay blocked, or
// prefers-reduced-motion. We tear the video element out of the DOM when
// it's false so the poster image underneath becomes the visible backdrop.
const [videoPlaying, setVideoPlaying] = useState(false);
// ─── Lifecycle: gate autoplay off if the user prefers reduced motion. ────
useEffect(() => {
if (typeof window === "undefined") return;
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
if (!mq || !mq.matches) return;
// Pause + suppress the video entirely for reduced-motion users.
const v = videoRef.current;
if (v && !v.paused) v.pause();
}, []);
// ─── APPLE-QUALITY MOTION PASS ──────────────────────────────────────────
// Choreographed entrance — logo → eyebrow → rule → title-words →
// description → CTAs → stats. Each item: opacity 0→1 + Y 16→0 +
// blur(6px)→0 with power3.out (≈ cubic-bezier(0.16, 1, 0.3, 1), the
// curve Apple/Linear use for sheet handoffs and Vision Pro scroll).
//
// Scroll-fade now holds through the FULL hero (was 40% top — too
// aggressive, content disappeared before user finished reading).
useEffect(() => {
if (typeof window === "undefined" || !sectionRef.current) return;
const prefersReduced =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => {
// Scroll progress tracking (the bar at the top of the page).
ScrollTrigger.create({
trigger: sectionRef.current,
start: "top top",
@@ -90,50 +124,74 @@ export default function TuxedoVideoHero({
},
});
// Hero content — gentle fade on scroll, NO positional shift.
// Hero content — fade through the FULL scroll length so the type
// holds its presence as the user reads. scrub: 0.6 keeps it
// physically tied to the scrollbar, not floating away.
if (contentRef.current) {
gsap.to(contentRef.current, {
opacity: 0,
ease: "power1.out",
ease: "none",
scrollTrigger: {
trigger: sectionRef.current,
start: "top top",
end: "40% top",
scrub: 1.2,
end: "bottom top",
scrub: 0.6,
},
});
}
// Video — no scale, no parallax. Just a static background.
// Staggered entrance for content elements — small Y, no scale, light ease.
const contentElements = gsap.utils.toArray<Element>(".hero-reveal");
contentElements.forEach((el) => {
if (prefersReduced) {
// Snap everything to end state. No choreography.
gsap.set(".hero-reveal, .hero-word", {
opacity: 1,
y: 0,
filter: "none",
});
} else {
// Block-level cascade — logo first, then everything else.
const reveals = gsap.utils.toArray<HTMLElement>(".hero-reveal");
reveals.forEach((el, i) => {
gsap.fromTo(
el,
{ opacity: 0, y: 8 },
{ opacity: 0, y: 16, filter: "blur(6px)" },
{
opacity: 1,
y: 0,
duration: 0.32,
ease: "power1.out",
scrollTrigger: {
trigger: el,
start: "top 95%",
toggleActions: "play none none none",
},
filter: "blur(0px)",
duration: 0.55,
delay: 0.05 + i * 0.08,
ease: "power3.out",
}
);
});
// Scroll indicator — just fades, no bounce, no positional shift.
// Word-level cascade for the title — runs after the eyebrow +
// rule have settled so the words appear once the runway is set.
const titleWords = gsap.utils.toArray<HTMLElement>(".hero-word");
if (titleWords.length) {
gsap.fromTo(
titleWords,
{ opacity: 0, y: 18, filter: "blur(8px)" },
{
opacity: 1,
y: 0,
filter: "blur(0px)",
duration: 0.6,
delay: 0.35,
stagger: 0.05,
ease: "power3.out",
}
);
}
}
gsap.to(".scroll-indicator", {
opacity: 0,
scrollTrigger: {
trigger: sectionRef.current,
start: "10% top",
end: "30% top",
scrub: 1,
scrub: 0.5,
},
});
}, sectionRef);
@@ -141,7 +199,6 @@ export default function TuxedoVideoHero({
return () => ctx.revert();
}, []);
// Smooth scroll to stops section
const handlePrimaryClick = () => {
const stopsSection = document.getElementById("stops");
if (stopsSection) {
@@ -157,13 +214,18 @@ export default function TuxedoVideoHero({
<section
ref={sectionRef}
className="relative min-h-screen flex items-center overflow-hidden"
className="relative min-h-screen flex items-end overflow-hidden isolate bg-stone-950"
>
<HeroBackground videoRef={videoRef} />
<HeroBackdrop
videoUrl={videoUrl}
posterUrl={posterUrl}
videoRef={videoRef}
onVideoStateChange={setVideoPlaying}
/>
<div
ref={contentRef}
className="relative z-10 mx-auto w-full max-w-6xl px-6 pb-32 pt-32 flex flex-col justify-end"
className="relative z-10 mx-auto w-full max-w-6xl px-6 pb-24 md:pb-32 pt-32"
>
<div className="max-w-3xl">
<HeroContent
@@ -180,7 +242,7 @@ export default function TuxedoVideoHero({
</div>
<ScrollIndicator onClick={handlePrimaryClick} />
<CornerDecorations />
<CornerDecorations videoPlaying={videoPlaying} />
</section>
<HeroAnimations />
@@ -188,10 +250,10 @@ export default function TuxedoVideoHero({
);
}
// ── Scroll Progress Bar ───────────────────────────────────────────────────────
// ── Scroll Progress Bar ───────────────────────────────────────────────
function ScrollProgressBar({ scrollProgress }: { scrollProgress: number }) {
return (
<div className="fixed top-0 left-0 right-0 h-1 z-[1000] bg-black/20 scroll-progress-bar">
<div className="fixed top-0 left-0 right-0 h-1 z-[1000] bg-black/20 scroll-progress-bar pointer-events-none">
<div
className="h-full bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-400"
style={{
@@ -203,43 +265,111 @@ function ScrollProgressBar({ scrollProgress }: { scrollProgress: number }) {
);
}
// ── Hero Background (video + overlays + orbs) ────────────────────────────────
function HeroBackground({ videoRef }: { videoRef: React.RefObject<HTMLVideoElement | null> }) {
// ── Backdrop: poster + optional video + overlays ─────────────────────────
function HeroBackdrop({
videoUrl,
posterUrl,
videoRef,
onVideoStateChange,
}: {
videoUrl?: string | null;
posterUrl?: string | null;
videoRef: React.RefObject<HTMLVideoElement | null>;
onVideoStateChange: (playing: boolean) => void;
}) {
// On video failure we set this true and let the poster image take over.
// Suppresses DOM noise in the failure case (no broken <video> controls).
return (
<>
{/* Poster as still fallback. Uses Next/Image so the LCP image is
optimised by the framework; sits underneath everything with
`object-cover: contain-ar-correct` style. Slow Ken Burns drift
animates the poster when the video isn't covering it (initial
2-3s of load, or if the video fails) adds ambient motion to
the first impression without competing with the video. */}
{posterUrl ? (
<Image
src={posterUrl}
alt=""
aria-hidden="true"
fill
sizes="100vw"
priority
quality={80}
style={{
objectFit: "cover",
objectPosition: "center",
zIndex: 0,
}}
className="select-none pointer-events-none animate-tvh-kenburns filter-editorial"
/>
) : null}
{/*
Video: render unconditionally when a URL is provided, but mark the
element as `data-failed` once any of the error events fire so we
can hide it with CSS instead of pulling it from the DOM (keeps
the network-friendly fetch behaviour). `autoPlay muted playsInline`
are the three browser flags that together unblock iOS + Android
autoplay; React renders them as proper boolean attributes.
`preload="metadata"` keeps the first frame available without
pulling the whole file on slow connections.
*/}
{videoUrl ? (
<video
ref={videoRef}
autoPlay
muted
loop
playsInline
tabIndex={-1}
preload="metadata"
disablePictureInPicture
controlsList="nodownload nofullscreen noremoteplayback"
aria-hidden="true"
className="absolute inset-0 w-full h-[120%] object-cover"
style={{ zIndex: 0 }}
src={VIDEO_URL}
/>
{/* Gradient overlays */}
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 1,
background: "linear-gradient(135deg, rgba(255,215,0,0.12) 0%, rgba(255,193,7,0.06) 50%, rgba(255,235,0,0.04) 100%)",
tabIndex={-1}
className="absolute inset-0 w-full h-full object-cover transition-opacity duration-500 data-[failed=true]:opacity-0 data-[failed=true]:pointer-events-none"
style={{ zIndex: 1 }}
poster={posterUrl ?? undefined}
src={videoUrl}
onCanPlay={() => onVideoStateChange(true)}
onError={() => onVideoStateChange(false)}
onStalled={() => {
// If we never fire `canPlay`, the element stays hidden via
// the `data-failed` data-attribute on the next paint.
onVideoStateChange(false);
}}
/>
) : null}
{/* Tint layer adds warmth without darkening the underlying field.
`mix-blend-multiply` keeps the golden hour of the photograph. */}
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 2,
background: "radial-gradient(ellipse at center, rgba(255,200,0,0.08) 0%, rgba(0,0,0,0.4) 50%, rgba(0,0,0,0.9) 100%)",
background:
"linear-gradient(135deg, rgba(255,193,7,0.10) 0%, rgba(255,213,79,0.05) 50%, transparent 100%)",
mixBlendMode: "multiply",
}}
/>
{/* Vignette — keeps eyes on the centered type. */}
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 3,
background: "linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.2) 50%, rgba(0,0,0,0.05) 100%)",
background:
"radial-gradient(ellipse at 50% 60%, rgba(0,0,0,0) 0%, rgba(0,0,0,0.35) 60%, rgba(0,0,0,0.85) 100%)",
}}
/>
{/* Base darken — helps white text legibility in either regime. */}
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 4,
background:
"linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.05) 100%)",
}}
/>
@@ -248,40 +378,39 @@ function HeroBackground({ videoRef }: { videoRef: React.RefObject<HTMLVideoEleme
);
}
// ── Floating Orbs ─────────────────────────────────────────────────────────────
// ── Floating Orbs ───────────────────────────────────────────────────────
function FloatingOrbs() {
// Slowed from the original 950ms (which felt like a strobe) to 7s/8s
// so the gold/green ambient lights drift like sunset on the field.
return (
<div className="absolute inset-0 pointer-events-none z-[4]">
{/* Golden orb top-left */}
<div className="absolute inset-0 pointer-events-none z-[5]" aria-hidden="true">
<div
className="parallax-float absolute top-1/4 left-1/4 w-64 h-64 rounded-full opacity-20"
className="absolute top-1/4 left-1/4 w-64 h-64 rounded-full opacity-20 animate-tvh-float-slow"
style={{
background: "radial-gradient(circle, rgba(255,215,0,0.4) 0%, transparent 70%)",
background:
"radial-gradient(circle, rgba(232,163,23,0.45) 0%, transparent 70%)",
filter: "blur(8px)",
animation: "tvh-float-slow 950ms ease-in-out infinite",
}}
/>
{/* Emerald orb bottom-right */}
<div
className="parallax-float absolute bottom-1/3 right-1/4 w-48 h-48 rounded-full opacity-15"
className="absolute bottom-1/3 right-1/4 w-48 h-48 rounded-full opacity-15 animate-tvh-float-delayed"
style={{
background: "radial-gradient(circle, rgba(16,185,129,0.4) 0%, transparent 70%)",
background:
"radial-gradient(circle, rgba(63,110,58,0.45) 0%, transparent 70%)",
filter: "blur(8px)",
animation: "tvh-float-delayed 950ms ease-in-out infinite",
}}
/>
{/* Subtle grain overlay */}
<div
className="absolute inset-0 opacity-[0.03]"
className="absolute inset-0 opacity-[0.085] mix-blend-overlay pointer-events-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
}}
/>
</div>
);
}
// ── Hero Content (logo, eyebrow, title, description, CTAs, stats) ────────────
// ── Hero Content (logo, eyebrow, title, description, CTAs, stats) ──────
type HeroContentProps = {
logoSrc: string | null;
eyebrow?: string;
@@ -307,14 +436,24 @@ function HeroContent({
<>
<HeroLogo logoSrc={logoSrc} />
<div className="hero-reveal mb-6">
<div className="hero-reveal mt-6 flex items-center gap-4">
{eyebrow && (
<p className="text-[11px] font-bold uppercase tracking-[0.3em] text-amber-400/80">
<p className="text-[11px] font-bold uppercase tracking-[0.3em] text-amber-300/90">
{eyebrow}
</p>
)}
<div className="mt-3 h-px w-16 bg-gradient-to-r from-emerald-500 to-transparent" />
{/* Altitude ribbon runs alongside the existing eyebrow with a
hairline divider, like a magazine masthead. Reinforces the
geographic / altitude voice of the brand. */}
<span
aria-hidden="true"
className="hidden sm:block h-px flex-1 max-w-[64px] bg-amber-300/35"
/>
<p className="hidden sm:block text-[10px] font-bold uppercase tracking-[0.32em] text-amber-200/65">
5,360 ft · Uncompahgre Valley
</p>
</div>
<div className="mt-3 h-px w-16 bg-gradient-to-r from-amber-300/80 to-transparent" />
<HeroTitle title={title} />
<HeroDescription description={description} />
@@ -333,12 +472,7 @@ function HeroContent({
function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
return (
<motion.div
className="hero-reveal mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.32, delay: 0.05, ease: "easeOut" }}
>
<div className="hero-reveal mb-8 md:mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]">
<div className="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" />
{logoSrc ? (
<Image
@@ -351,25 +485,34 @@ function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
priority
/>
) : null}
</motion.div>
</div>
);
}
function HeroTitle({ title }: { title: string }) {
// Subtle white/cool text shadow replaces the yellow tint (was reading
// neon). Letter-spacing tightened on the largest sizes so the words
// sit closer — feels like a printed farm-stand sign at highway scale.
// Title is split into words so the Apple-quality motion pass can
// choreograph a per-word blur-up — the signature hero move.
return (
<h1
className="hero-reveal text-6xl md:text-7xl lg:text-8xl font-black tracking-tight text-white leading-[0.95] mb-6"
className="text-6xl md:text-7xl lg:text-8xl font-black tracking-[-0.025em] text-white leading-[0.95] mb-6"
style={{
textShadow: "0 4px 30px rgba(0,0,0,0.3), 0 0 60px rgba(255,215,0,0.1)",
textShadow:
"0 4px 30px rgba(0,0,0,0.45), 0 1px 1px rgba(0,0,0,0.25)",
}}
aria-label={title}
>
{title.split(" ").map((word, idx) => (
{title.split(" ").map((word, i) => (
<span
key={`${idx}-${word}`}
className="inline-block mr-4"
key={i}
className="hero-word inline-block"
style={{
animationDelay: `${0.2 + idx * 0.1}s`,
marginRight: "0.18em",
willChange: "transform, opacity, filter",
}}
aria-hidden
>
{word}
</span>
@@ -381,7 +524,7 @@ function HeroTitle({ title }: { title: string }) {
function HeroDescription({ description }: { description: string }) {
return (
<p
className="hero-reveal text-xl md:text-2xl text-white/70 leading-relaxed max-w-xl mb-10"
className="hero-reveal text-xl md:text-2xl text-white/75 leading-relaxed max-w-xl mb-10"
style={{ fontWeight: 300 }}
>
{description}
@@ -416,13 +559,17 @@ function PrimaryButton({ label, onClick }: { label: string; onClick: () => void
return (
<motion.button
onClick={onClick}
className="group relative rounded-full px-10 py-4 text-sm font-bold tracking-widest uppercase overflow-hidden"
className="group relative rounded-full px-10 py-4 text-sm font-bold tracking-widest uppercase overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-950"
style={{
background: "linear-gradient(135deg, #059669 0%, #10b981 100%)",
boxShadow: "0 8px 32px rgba(16, 185, 129, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)",
background: "linear-gradient(135deg, #15803d 0%, #16a34a 100%)",
boxShadow:
"0 8px 32px rgba(22, 163, 74, 0.35), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
whileHover={{ y: -3 }}
transition={{ type: "spring", stiffness: 320, damping: 22 }}
aria-label={label}
>
<span className="relative z-10 flex items-center gap-3">
<span className="relative z-10 flex items-center gap-3 text-white">
<span>{label}</span>
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-1"
@@ -434,34 +581,22 @@ function PrimaryButton({ label, onClick }: { label: string; onClick: () => void
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</span>
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
}}
/>
</motion.button>
);
}
function SecondaryButton({
label,
onClick,
}: {
label: string;
onClick?: () => void;
}) {
function SecondaryButton({ label, onClick }: { label?: string; onClick?: () => void }) {
if (!label) return null;
return (
<button type="button"
<button
type="button"
onClick={onClick}
className="group flex items-center gap-3 text-sm font-semibold"
style={{ color: "rgba(255,255,255,0.8)" }}
aria-label="Next">
<span className="uppercase tracking-widest text-[11px]">{label}</span>
<span
className="w-10 h-10 rounded-full border flex items-center justify-center transition-all duration-300 group-hover:bg-white/10 group-hover:border-white/30"
style={{ borderColor: "rgba(255,255,255,0.3)" }}
className="group flex items-center gap-3 text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-950 rounded-full"
style={{ color: "rgba(255,255,255,0.85)" }}
aria-label={label}
>
<span className="uppercase tracking-widest text-[11px]">{label}</span>
<span className="w-10 h-10 rounded-full border flex items-center justify-center transition-all duration-300 group-hover:bg-white/10 group-hover:border-white/40">
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-0.5"
fill="none"
@@ -478,18 +613,18 @@ function SecondaryButton({
function HeroStats() {
return (
<div className="hero-reveal mt-16 flex flex-wrap gap-12">
<div className="hero-reveal mt-12 md:mt-16 flex flex-wrap gap-x-10 gap-y-6 md:gap-x-14">
{HERO_STATS.map((item) => (
<div key={item.label} className="text-center">
<div key={item.label}>
<div
className="text-3xl md:text-4xl font-black text-white"
style={{ textShadow: "0 2px 20px rgba(0,0,0,0.3)" }}
className="text-2xl md:text-3xl font-black text-white tabular-nums"
style={{ textShadow: "0 2px 20px rgba(0,0,0,0.4)" }}
>
{item.stat}
</div>
<div
className="text-[10px] uppercase tracking-[0.2em] mt-1"
style={{ color: "rgba(255,255,255,0.5)" }}
className="text-[10px] uppercase tracking-[0.25em] mt-1"
style={{ color: "rgba(255,255,255,0.55)" }}
>
{item.label}
</div>
@@ -499,98 +634,112 @@ function HeroStats() {
);
}
// ── Scroll Indicator ──────────────────────────────────────────────────────────
// ── Scroll Indicator ─────────────────────────────────────────────────────
function ScrollIndicator({ onClick }: { onClick: () => void }) {
return (
<div
className="scroll-indicator absolute bottom-10 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-3"
className="scroll-indicator absolute bottom-8 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-3"
style={{ pointerEvents: "auto" }}
>
<button type="button"
<button
type="button"
onClick={onClick}
className="flex flex-col items-center gap-2 text-white/50 hover:text-white/80 transition-colors duration-200"
className="flex flex-col items-center gap-2 text-white/55 hover:text-white/85 transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 rounded-full"
aria-label="Scroll to stops"
>
<span className="text-[10px] uppercase tracking-[0.25em] font-medium">Explore</span>
<div
className="w-6 h-10 rounded-full border border-white/30 flex items-start justify-center p-1.5"
>
<div
className="w-1.5 h-3 rounded-full bg-white tvh-scroll-indicator"
style={{ animationDuration: "950ms" }}
/>
<div className="w-6 h-10 rounded-full border border-white/30 flex items-start justify-center p-1.5">
<div className="w-1.5 h-3 rounded-full bg-white animate-tvh-scroll-dot" />
</div>
</button>
</div>
);
}
// ── Corner Decorations ────────────────────────────────────────────────────────
function CornerDecorations() {
// ── Corner Decorations ───────────────────────────────────────────────────
function CornerDecorations({ videoPlaying }: { videoPlaying: boolean }) {
// The decorative SVG rings on the corners are interesting but distract
// when the video is already playing. We tint them by the current
// backdrop mode instead of always animating them.
return (
<>
<div
className="parallax-float absolute top-8 right-8 opacity-30"
style={{ animationDelay: "1s" }}
className="absolute top-8 right-8 opacity-25 hidden md:block"
aria-hidden="true"
>
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none">
<path
d="M32 4C48 16, 56 32, 48 56C40 64, 24 64, 8 56C0 32, 16 16, 32 4"
stroke="rgba(255,215,0,0.5)"
stroke="rgba(232,163,23,0.55)"
strokeWidth="1"
fill="none"
/>
<path
d="M32 12C44 22, 48 36, 42 52C36 58, 26 58, 18 52C12 36, 20 22, 32 12"
stroke="rgba(255,215,0,0.3)"
stroke="rgba(232,163,23,0.35)"
strokeWidth="1"
fill="none"
/>
</svg>
</div>
<div
className="parallax-float absolute bottom-20 left-8 opacity-20"
style={{ animationDelay: "1.5s" }}
className="absolute bottom-20 left-8 opacity-20 hidden md:block"
aria-hidden="true"
>
<svg className="w-12 h-12" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="20" stroke="rgba(16,185,129,0.4)" strokeWidth="1" />
<circle cx="24" cy="24" r="14" stroke="rgba(16,185,129,0.3)" strokeWidth="1" />
<circle cx="24" cy="24" r="8" stroke="rgba(16,185,129,0.2)" strokeWidth="1" />
<circle cx="24" cy="24" r="20" stroke="rgba(63,110,58,0.55)" strokeWidth="1" />
<circle cx="24" cy="24" r="14" stroke="rgba(63,110,58,0.4)" strokeWidth="1" />
<circle cx="24" cy="24" r="8" stroke="rgba(63,110,58,0.25)" strokeWidth="1" />
</svg>
</div>
</>
);
}
// ── Hero Animations (global keyframes + reduced-motion overrides) ─────────────
// ── Hero Animations (global keyframes + reduced-motion overrides) ───────
function HeroAnimations() {
return (
<style dangerouslySetInnerHTML={{ __html: `
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes tvh-float-slow {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(6px, -8px); }
50% { transform: translate(8px, -10px); }
}
@keyframes tvh-float-delayed {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-8px, 6px); }
50% { transform: translate(-10px, 8px); }
}
@keyframes tvh-scroll-indicator {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(3px); }
@keyframes tvh-scroll-dot {
0%, 100% { transform: translateY(0); opacity: 1; }
50% { transform: translateY(8px); opacity: 0.4; }
}
.tvh-scroll-indicator {
animation: tvh-scroll-indicator 2.4s cubic-bezier(0.16, 1, 0.3, 1) infinite;
/* Slow Ken Burns on the hero poster. Animates while the video is
still loading (first ~2s) or if the video fails entirely.
Scaled subtly + tiny pan so it reads as ambient, not motion. */
@keyframes tvh-kenburns {
0% { transform: scale(1.02) translate(0, 0); }
50% { transform: scale(1.07) translate(-0.6%, -0.4%); }
100% { transform: scale(1.10) translate(-1.2%, -0.8%); }
}
.parallax-float {
.animate-tvh-kenburns {
animation: tvh-kenburns 26s ease-in-out infinite alternate;
will-change: transform;
}
.hero-reveal {
will-change: opacity, transform;
.animate-tvh-float-slow {
animation: tvh-float-slow 7s ease-in-out infinite;
}
.animate-tvh-float-delayed {
animation: tvh-float-delayed 8s ease-in-out infinite;
}
.animate-tvh-scroll-dot {
animation: tvh-scroll-dot 2.4s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
button, a {
@@ -598,7 +747,11 @@ function HeroAnimations() {
}
@media (prefers-reduced-motion: reduce) {
.parallax-float, .animate-bounce, .hero-reveal {
.animate-tvh-float-slow,
.animate-tvh-float-delayed,
.animate-tvh-scroll-dot,
.animate-tvh-kenburns,
.hero-reveal {
animation: none !important;
transition: none !important;
}
@@ -606,7 +759,13 @@ function HeroAnimations() {
opacity: 1 !important;
transform: none !important;
}
/* Pause any looping media so the still poster stays put. */
video {
animation-play-state: paused !important;
}
`}} />
}
`,
}}
/>
);
}
@@ -0,0 +1,35 @@
/**
* Editorial imagery for the Tuxedo storefront, sourced from the original
* tuxedocorn.com WordPress library and re-hosted on our MinIO bucket so
* the brand controls its own media. These are stable, brand-specific
* assets they don't need to be admin-configurable yet. (If we ever
* add more brands, lift these into `brand_settings` columns.)
*
* Lifted out of `src/app/tuxedo/page.tsx` so the about page, FAQ page,
* and any future Tuxedo subpage can share the same library without
* duplicating the URL list.
*/
export const TUXEDO_IMAGES = {
/** Wide panoramic corn row with irrigation canal + Colorado sky the
* hero poster on the homepage. */
heroField:
"https://s3.crispygoat.com/videos/wp-import/images/tuxedo-field-irrigation.jpg",
/** Pre-storm purple sky over deep-green tasseled rows receding to
* mountains the editorial pull-quote band on the homepage + about. */
stormField:
"https://s3.crispygoat.com/videos/wp-import/images/tuxedo-field-storm.jpg",
/** White-haired founder inspecting tassels editorial-graded version
* of the original WP shot. Golden-hour lighting, shallow DOF. */
founderPortrait:
"https://s3.crispygoat.com/videos/wp-import/images/tuxedo-founder-john-editorial.jpg",
/** Single yellow ear with silk and dark green bokeh the product
* moment in the editorial "Why" replacement. */
earMacro:
"https://s3.crispygoat.com/videos/wp-import/images/tuxedo-ear-macro.jpg",
/** Packing-shed packshot dozens of husked cobs piled on the table
* the morning of pick. Documentary, not styled. */
piledCobs:
"https://s3.crispygoat.com/videos/wp-import/images/corn-field-zane-original.jpg",
} as const;
export type TuxedoImageKey = keyof typeof TUXEDO_IMAGES;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,264 @@
/**
* DailyRouteSummary the cross-link surface that shows BOTH water-log
* activity AND time-tracking totals for a single worker-day, side by
* side. This is the "Route summary shows both water totals and time
* totals" deliverable from the brief.
*
* Two flavours:
* - <DailyRouteSummary worker=... /> focused on one worker
* - <FleetRouteSummary brand=... /> table of every worker today
*
* Used by the Water Log admin (per-user detail) and the Time Tracking
* admin (overview tab). The data fetch is delegated to the route-summary
* server action so we only run it once per render.
*/
"use client";
import * as React from "react";
import Link from "next/link";
import type { RouteTimeSummary, WorkerRouteRow } from "@/actions/time-tracking/route-summary";
function fmtHM(min: number): string {
if (min <= 0) return "0m";
const h = Math.floor(min / 60);
const m = min % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function fmtGal(g: number): string {
if (g <= 0) return "0 gal";
if (g >= 1000) return `${(g / 1000).toFixed(g >= 10000 ? 0 : 1)}k gal`;
return `${Math.round(g).toLocaleString()} gal`;
}
function fmtClockTime(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
}
// ── Single-worker view ─────────────────────────────────────────────────────
export function DailyRouteSummary({
summary,
compact = false,
}: {
summary: RouteTimeSummary;
compact?: boolean;
}) {
const { water, time } = summary;
const statusTone =
time.clockedIn
? "bg-emerald-100 text-emerald-800 ring-emerald-300"
: time.workedMinutesToday > 0
? "bg-zinc-100 text-zinc-700 ring-zinc-300"
: "bg-amber-50 text-amber-800 ring-amber-300";
const tsBadge = (() => {
switch (time.timesheetStatus) {
case "draft":
return { label: "TS · Draft", tone: "bg-zinc-100 text-zinc-700" };
case "submitted":
return { label: "TS · Submitted", tone: "bg-amber-100 text-amber-800" };
case "approved":
return { label: "TS · Approved", tone: "bg-emerald-100 text-emerald-800" };
case "rejected":
return { label: "TS · Rejected", tone: "bg-rose-100 text-rose-800" };
default:
return null;
}
})();
return (
<div
className={`rounded-2xl ring-1 ring-zinc-200 bg-white shadow-sm ${
compact ? "p-4" : "p-5"
}`}
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
Daily Route Summary
</p>
<h3 className="mt-0.5 text-base font-semibold text-zinc-900">
{summary.workerName}
</h3>
</div>
<div className="flex flex-wrap items-center gap-2">
<span
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ring-1 ${statusTone}`}
>
{time.clockedIn
? `On the clock · ${fmtClockTime(time.clockInAt)}`
: time.workedMinutesToday > 0
? "Clocked out"
: "Not started"}
</span>
{tsBadge && (
<span
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${tsBadge.tone}`}
>
{tsBadge.label}
</span>
)}
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
{/* Water side */}
<div className="rounded-xl bg-sky-50/60 p-3 ring-1 ring-sky-100">
<div className="flex items-center gap-2">
<svg viewBox="0 0 24 24" className="h-4 w-4 text-sky-700" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2.5c4 5 7 8.5 7 12a7 7 0 0 1-14 0c0-3.5 3-7 7-12z" />
</svg>
<p className="text-[11px] font-semibold uppercase tracking-wider text-sky-700">
Water Log
</p>
</div>
<dl className="mt-2 space-y-1 text-sm">
<div className="flex justify-between">
<dt className="text-sky-900/70">Entries</dt>
<dd className="font-semibold text-sky-900">{water.entryCount}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sky-900/70">Total</dt>
<dd className="font-semibold text-sky-900">{fmtGal(water.totalGallons)}</dd>
</div>
{!compact && water.headgatesVisited.length > 0 && (
<div className="flex justify-between gap-2">
<dt className="shrink-0 text-sky-900/70">Headgates</dt>
<dd className="truncate text-right text-xs text-sky-900/80" title={water.headgatesVisited.join(", ")}>
{water.headgatesVisited.length} unique
</dd>
</div>
)}
</dl>
</div>
{/* Time side */}
<div className="rounded-xl bg-violet-50/60 p-3 ring-1 ring-violet-100">
<div className="flex items-center gap-2">
<svg viewBox="0 0 24 24" className="h-4 w-4 text-violet-700" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<p className="text-[11px] font-semibold uppercase tracking-wider text-violet-700">
Time Tracking
</p>
</div>
<dl className="mt-2 space-y-1 text-sm">
<div className="flex justify-between">
<dt className="text-violet-900/70">Worked</dt>
<dd className="font-semibold text-violet-900">{fmtHM(time.workedMinutesToday)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-violet-900/70">Regular / OT</dt>
<dd className="font-semibold text-violet-900">
{fmtHM(time.regularMinutesToday)} / {fmtHM(time.overtimeMinutesToday)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-violet-900/70">Break</dt>
<dd className="font-semibold text-violet-900">{fmtHM(time.breakMinutesToday)}</dd>
</div>
</dl>
</div>
</div>
{!compact && time.timesheetId && (
<div className="mt-3 flex justify-end">
<Link
href={`/admin/time-tracking/timesheets/${time.timesheetId}`}
className="text-xs font-medium text-violet-700 hover:text-violet-900"
>
View timesheet
</Link>
</div>
)}
</div>
);
}
// ── Fleet view (table) ─────────────────────────────────────────────────────
export function FleetRouteSummary({
brandId,
rows,
date,
}: {
brandId: string;
rows: WorkerRouteRow[];
date: string;
}) {
return (
<div className="overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200 shadow-sm">
<div className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
Today's Route
</p>
<h3 className="text-base font-semibold text-zinc-900">
Crew {date}
</h3>
</div>
<span className="rounded-full bg-zinc-100 px-2.5 py-1 text-[11px] font-semibold text-zinc-700">
{rows.filter((r) => r.time.isClockedIn).length} on clock ·{" "}
{rows.length} active
</span>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-zinc-200 text-sm">
<thead className="bg-zinc-50 text-left text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2">Worker</th>
<th className="px-4 py-2 text-right">Water Entries</th>
<th className="px-4 py-2 text-right">Gallons</th>
<th className="px-4 py-2 text-right">Time Today</th>
<th className="px-4 py-2 text-right">Clock</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-6 text-center text-sm text-zinc-500">
No active workers yet.
</td>
</tr>
)}
{rows.map((r) => (
<tr key={r.workerId} className={!r.active ? "opacity-60" : ""}>
<td className="px-4 py-2 font-medium text-zinc-900">{r.workerName}</td>
<td className="px-4 py-2 text-right text-zinc-700">{r.water.entryCount}</td>
<td className="px-4 py-2 text-right text-sky-700">{fmtGal(r.water.totalGallons)}</td>
<td className="px-4 py-2 text-right font-semibold text-violet-700">
{fmtHM(r.time.workedMinutesToday)}
</td>
<td className="px-4 py-2 text-right">
{r.time.isClockedIn ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-800">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-600" />
On
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-semibold text-zinc-600">
Off
</span>
)}
</td>
<td className="px-4 py-2 text-right">
<Link
href={`/admin/water-log/users/${r.workerId}?from=/admin/water-log`}
className="text-xs font-medium text-zinc-600 hover:text-zinc-900"
>
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,236 @@
"use client";
/**
* Manual time entry admin form to add a manual log to a timesheet.
* Captures start, end (optional), break, task, optional GPS, and a
* mandatory reason (required for payroll / labor-law compliance).
*/
import React, { useState, useTransition } from "react";
import { addManualTimeEntry } from "@/actions/time-tracking/entries";
function captureGps(): Promise<{ lat: number; lng: number; accuracy_m?: number } | null> {
return new Promise((resolve) => {
if (typeof navigator === "undefined" || !navigator.geolocation) {
resolve(null);
return;
}
navigator.geolocation.getCurrentPosition(
(pos) =>
resolve({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
accuracy_m: pos.coords.accuracy,
}),
() => resolve(null),
{ enableHighAccuracy: true, timeout: 5000 },
);
});
}
export default function ManualEntryForm({
brandId,
workerId,
periodStart,
periodEnd,
onClose,
onCreated,
}: {
brandId: string;
workerId: string;
periodStart: string;
periodEnd: string;
onClose: () => void;
onCreated: (entryId: string) => void;
}) {
const [date, setDate] = useState(periodStart);
const [clockIn, setClockIn] = useState("08:00");
const [clockOut, setClockOut] = useState("17:00");
const [hasClockOut, setHasClockOut] = useState(true);
const [breakMin, setBreakMin] = useState(30);
const [taskName, setTaskName] = useState("Field work");
const [notes, setNotes] = useState("");
const [reason, setReason] = useState("");
const [gps, setGps] = useState<{ lat: number; lng: number; accuracy_m?: number } | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function toIso(d: string, t: string): string {
return new Date(`${d}T${t}:00`).toISOString();
}
function handleCaptureGps() {
setError(null);
void captureGps().then((g) => {
if (!g) {
setError("Couldn't get GPS — that's OK, you can save without it.");
} else {
setGps(g);
}
});
}
function handleSubmit() {
setError(null);
if (!reason || reason.length < 3) {
setError("A manual-entry reason is required (min 3 chars).");
return;
}
startTransition(async () => {
const r = await addManualTimeEntry({
brandId,
workerId,
taskName,
clockIn: toIso(date, clockIn),
clockOut: hasClockOut ? toIso(date, clockOut) : null,
lunchBreakMinutes: breakMin,
notes: notes || undefined,
manualReason: reason,
gps: gps ?? undefined,
});
if (!r.success) {
setError(r.error ?? "Save failed");
return;
}
onCreated(r.entry_id!);
});
}
return (
<div className="rounded-2xl border border-zinc-200 bg-white p-4">
<h3 className="mb-3 text-sm font-bold uppercase tracking-wider text-zinc-500">
Add manual entry
</h3>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<Field label="Date">
<input
type="date"
value={date}
min={periodStart}
max={periodEnd}
onChange={(e) => setDate(e.target.value)}
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</Field>
<Field label="Task">
<input
type="text"
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</Field>
<Field label="Clock in">
<input
type="time"
value={clockIn}
onChange={(e) => setClockIn(e.target.value)}
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</Field>
<Field label="Clock out">
<div className="flex items-center gap-2">
<input
type="time"
value={clockOut}
disabled={!hasClockOut}
onChange={(e) => setClockOut(e.target.value)}
className="rounded-md border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-100"
/>
<label className="flex items-center gap-1 text-xs text-zinc-600">
<input
type="checkbox"
checked={hasClockOut}
onChange={(e) => setHasClockOut(e.target.checked)}
/>
Has clock-out
</label>
</div>
</Field>
<Field label="Break (minutes)">
<input
type="number"
min={0}
max={720}
value={breakMin}
onChange={(e) => setBreakMin(Number(e.target.value))}
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</Field>
<Field label="GPS (optional)">
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCaptureGps}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs hover:bg-zinc-50"
>
Capture current location
</button>
{gps && (
<span className="text-xs text-zinc-600">
{gps.lat.toFixed(4)}, {gps.lng.toFixed(4)}
{gps.accuracy_m != null && ` ±${Math.round(gps.accuracy_m)}m`}
</span>
)}
</div>
</Field>
</div>
<div className="mt-3">
<Field label="Manual entry reason (required)">
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Forgot to clock in — was at the barn from 7am"
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</Field>
</div>
<div className="mt-3">
<Field label="Notes (optional)">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</Field>
</div>
{error && (
<div className="mt-3 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800">
{error}
</div>
)}
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded-md border border-zinc-300 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-50"
>
Cancel
</button>
<button
disabled={pending}
onClick={handleSubmit}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Saving…" : "Add entry"}
</button>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex flex-col gap-1">
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
{label}
</span>
{children}
</label>
);
}
@@ -0,0 +1,476 @@
"use client";
/**
* Timesheet detail totals card, entries table, audit trail, and
* the approval workflow buttons (submit / approve / reject / unlock /
* add manual entry).
*
* Status-driven UI:
* - draft / rejected: "Submit for review" button + manual entry editor
* - submitted: "Approve" / "Reject" buttons (supervisor/admin)
* - approved (locked): "Unlock" button (admin only) + locked banner
*/
import React, { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
submitTimesheet,
approveTimesheet,
rejectTimesheet,
unlockTimesheet,
} from "@/actions/time-tracking/timesheets";
import { deleteTimeEntry } from "@/actions/time-tracking/entries";
import type { TimesheetStatus } from "@/db/schema/time-tracking";
import type {
TimesheetSummary,
TimesheetEntry,
AuditEntry,
} from "@/actions/time-tracking/timesheets";
import ManualEntryForm from "./ManualEntryForm";
type Caller = {
id: string;
role: string;
display_name: string | null;
email: string | null;
isAdmin: boolean;
};
function statusColor(s: TimesheetStatus): {
bg: string;
fg: string;
label: string;
} {
switch (s) {
case "draft":
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: "Draft" };
case "submitted":
return { bg: "bg-amber-100", fg: "text-amber-800", label: "Submitted" };
case "approved":
return { bg: "bg-emerald-100", fg: "text-emerald-800", label: "Approved · Locked" };
case "rejected":
return { bg: "bg-rose-100", fg: "text-rose-800", label: "Rejected" };
default: {
const _exhaustive: never = s;
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: _exhaustive };
}
}
}
function formatHM(min: number): string {
const h = Math.floor(min / 60);
const m = min % 60;
return `${h}h ${m}m`;
}
function fmtTs(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export default function TimesheetDetail({
summary,
entries,
audit,
caller,
}: {
summary: TimesheetSummary;
entries: TimesheetEntry[];
audit: AuditEntry[];
caller: Caller;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [comment, setComment] = useState("");
const [rejectReason, setRejectReason] = useState("");
const [unlockNote, setUnlockNote] = useState("");
const [showManual, setShowManual] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const isLocked = !!summary.locked_at;
const canSubmit =
!isLocked && (summary.status === "draft" || summary.status === "rejected");
const canApprove =
!isLocked &&
summary.status === "submitted" &&
(caller.isAdmin || caller.role === "supervisor");
const canUnlock = isLocked && caller.isAdmin;
const canEditEntries = !isLocked && caller.isAdmin;
function call(action: () => Promise<{ success: boolean; error?: string }>) {
setError(null);
startTransition(async () => {
const r = await action();
if (!r.success) setError(r.error ?? "Action failed");
else router.refresh();
});
}
const c = statusColor(summary.status);
return (
<div className="space-y-6">
{/* Top bar: status + actions */}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-zinc-200 bg-white p-4">
<div className="flex items-center gap-3">
<span
className={`inline-block rounded-full px-3 py-1 text-xs font-semibold ${c.bg} ${c.fg}`}
>
{c.label}
</span>
{summary.submitted_at && (
<span className="text-xs text-zinc-500">
Submitted {fmtTs(summary.submitted_at)}
</span>
)}
{summary.reviewed_at && (
<span className="text-xs text-zinc-500">
Reviewed by {summary.reviewed_by_label ?? "admin"}{" "}
{fmtTs(summary.reviewed_at)}
</span>
)}
{summary.unlocked_at && summary.unlock_audit_note && (
<span className="text-xs italic text-rose-600">
Unlocked {fmtTs(summary.unlocked_at)} · &ldquo;{summary.unlock_audit_note}&rdquo;
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<a
href={`/api/time-tracking/timesheets/${summary.id}/export?format=csv`}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
CSV
</a>
<a
href={`/api/time-tracking/timesheets/${summary.id}/export?format=pdf`}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
PDF
</a>
{canEditEntries && (
<button
onClick={() => setShowManual((v) => !v)}
className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800"
>
{showManual ? "Hide manual entry" : "+ Manual entry"}
</button>
)}
</div>
</div>
{error && (
<div className="rounded-md border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">
{error}
</div>
)}
{isLocked && (
<div className="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
<strong>Locked.</strong> Entries below are read-only.{" "}
{canUnlock
? "Use Unlock to make changes."
: "Ask an admin to unlock if a correction is required."}
</div>
)}
{/* Totals */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<Stat label="Total" value={formatHM(summary.total_minutes)} />
<Stat label="Regular" value={formatHM(summary.regular_minutes)} />
<Stat label="Overtime" value={formatHM(summary.overtime_minutes)} />
<Stat label="Break" value={formatHM(summary.break_minutes)} />
<Stat label="Entries" value={String(summary.entry_count)} />
</div>
{/* Manual entry editor */}
{showManual && canEditEntries && (
<ManualEntryForm
brandId={summary.brand_id}
workerId={summary.field_worker_id}
periodStart={summary.period_start}
periodEnd={summary.period_end}
onClose={() => setShowManual(false)}
onCreated={() => {
setShowManual(false);
router.refresh();
}}
/>
)}
{/* Approval actions */}
{(canSubmit || canApprove || canUnlock) && (
<div className="rounded-2xl border border-zinc-200 bg-white p-4">
<h3 className="mb-3 text-sm font-bold uppercase tracking-wider text-zinc-500">
Workflow
</h3>
<div className="flex flex-wrap items-end gap-3">
{canSubmit && (
<div className="flex flex-1 flex-col gap-2">
<label className="text-xs text-zinc-500">
Notes for reviewer (optional)
</label>
<input
type="text"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="e.g. includes a 2h drive to second field"
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
disabled={pending}
/>
<button
disabled={pending}
onClick={() => call(() => submitTimesheet(summary.id, comment || undefined))}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
>
Submit for review
</button>
</div>
)}
{canApprove && (
<>
<div className="flex flex-1 flex-col gap-2">
<label className="text-xs text-zinc-500">
Approver comment (optional)
</label>
<input
type="text"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Looks good"
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
disabled={pending}
/>
<div className="flex gap-2">
<button
disabled={pending}
onClick={() =>
call(() => approveTimesheet(summary.id, comment || undefined))
}
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
>
Approve & lock
</button>
<button
disabled={pending}
onClick={() => {
if (rejectReason.length < 3) {
setError("A rejection reason (min 3 chars) is required.");
return;
}
call(() => rejectTimesheet(summary.id, rejectReason));
}}
className="rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-50"
>
Reject
</button>
</div>
</div>
<div className="flex flex-1 flex-col gap-2">
<label className="text-xs text-zinc-500">
Rejection reason (required if rejecting)
</label>
<input
type="text"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="Tuesday needs lunch break added"
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
disabled={pending}
/>
</div>
</>
)}
{canUnlock && (
<div className="flex flex-1 flex-col gap-2">
<label className="text-xs font-semibold text-rose-700">
Unlock audit note (required, min 5 chars)
</label>
<textarea
value={unlockNote}
onChange={(e) => setUnlockNote(e.target.value)}
rows={2}
placeholder="Payroll found an under-reported hour on Wednesday. Re-locking after correction."
className="rounded-md border border-rose-300 px-3 py-2 text-sm"
disabled={pending}
/>
<button
disabled={pending || unlockNote.length < 5}
onClick={() => call(() => unlockTimesheet(summary.id, unlockNote))}
className="rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-50"
>
Unlock timesheet
</button>
</div>
)}
</div>
</div>
)}
{/* Entries */}
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
<div className="border-b border-zinc-200 bg-zinc-50 px-4 py-3 text-sm font-semibold text-zinc-700">
Entries ({entries.length})
</div>
{entries.length === 0 ? (
<div className="p-8 text-center text-sm text-zinc-500">
No entries yet for this period.
</div>
) : (
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-white text-left text-[11px] font-bold uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-3">Date</th>
<th className="px-4 py-3">Task</th>
<th className="px-4 py-3">Clock in / out</th>
<th className="px-4 py-3 text-right">Break</th>
<th className="px-4 py-3 text-right">Total</th>
<th className="px-4 py-3">GPS</th>
<th className="px-4 py-3">Kind</th>
{canEditEntries && <th className="px-4 py-3 text-right">Actions</th>}
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{entries.map((e) => (
<tr key={e.id}>
<td className="px-4 py-3 text-zinc-700">
{e.clock_in.slice(0, 10)}
</td>
<td className="px-4 py-3 text-zinc-900">{e.task_name}</td>
<td className="px-4 py-3 tabular-nums text-zinc-700">
{e.clock_in.slice(11, 16)} {" "}
{e.clock_out ? e.clock_out.slice(11, 16) : "—"}
</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
{e.lunch_break_minutes}m
</td>
<td className="px-4 py-3 text-right font-medium tabular-nums text-zinc-900">
{formatHM(e.total_minutes)}
</td>
<td className="px-4 py-3">
{e.gps_verified ? (
<span title="GPS verified (≤100m)">📍</span>
) : (
<span className="text-zinc-400"></span>
)}
</td>
<td className="px-4 py-3">
{e.entry_kind === "manual" ? (
<span
title={e.manual_reason ?? ""}
className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800"
>
manual
</span>
) : (
<span className="text-[11px] text-zinc-500">clock</span>
)}
</td>
{canEditEntries && (
<td className="px-4 py-3 text-right">
{confirmDelete === e.id ? (
<span className="space-x-2">
<button
disabled={pending}
onClick={() =>
call(async () => {
const r = await deleteTimeEntry(
e.id,
"Removed from timesheet detail page",
);
return r;
})
}
className="rounded bg-rose-600 px-2 py-0.5 text-[11px] font-medium text-white"
>
Confirm
</button>
<button
onClick={() => setConfirmDelete(null)}
className="rounded border border-zinc-300 px-2 py-0.5 text-[11px]"
>
Cancel
</button>
</span>
) : (
<button
onClick={() => setConfirmDelete(e.id)}
className="text-[11px] text-rose-600 hover:underline"
>
Delete
</button>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Audit trail */}
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
<div className="border-b border-zinc-200 bg-zinc-50 px-4 py-3 text-sm font-semibold text-zinc-700">
Audit trail ({audit.length})
</div>
{audit.length === 0 ? (
<div className="p-8 text-center text-sm text-zinc-500">No audit entries.</div>
) : (
<ul className="divide-y divide-zinc-100">
{audit.map((a) => (
<li key={a.id} className="px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<div>
<span className="font-mono text-[11px] uppercase tracking-wider text-zinc-500">
{a.action}
</span>{" "}
<span className="text-zinc-900">{a.actor_label}</span>
</div>
<span className="text-xs text-zinc-500">
{fmtTs(a.created_at)}
</span>
</div>
{Object.keys(a.details).length > 0 && (
<pre className="mt-1 overflow-x-auto rounded bg-zinc-50 px-3 py-2 text-[11px] text-zinc-600">
{JSON.stringify(a.details, null, 2)}
</pre>
)}
</li>
))}
</ul>
)}
</div>
<div className="text-sm">
<Link
href="/admin/time-tracking/timesheets"
className="text-zinc-600 hover:underline"
>
Back to timesheets
</Link>
</div>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-zinc-200 bg-white p-3">
<div className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
{label}
</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-zinc-900">
{value}
</div>
</div>
);
}
@@ -0,0 +1,207 @@
"use client";
/**
* Timesheets list status filter, worker filter, period filter,
* per-row summary + click-through to the detail page. Mobile-friendly.
*/
import React, { useMemo } from "react";
import Link from "next/link";
import type { TimesheetStatus } from "@/db/schema/time-tracking";
import type { TimesheetSummary } from "@/actions/time-tracking/timesheets";
type Worker = { id: string; name: string; role: string };
type Filters = {
status: TimesheetStatus | "all";
workerId?: string;
periodStart?: string;
periodEnd?: string;
};
function statusColor(s: TimesheetStatus): {
bg: string;
fg: string;
label: string;
} {
switch (s) {
case "draft":
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: "Draft" };
case "submitted":
return { bg: "bg-amber-100", fg: "text-amber-800", label: "Submitted" };
case "approved":
return { bg: "bg-emerald-100", fg: "text-emerald-800", label: "Approved · Locked" };
case "rejected":
return { bg: "bg-rose-100", fg: "text-rose-800", label: "Rejected" };
default: {
// exhaustiveness guard
const _exhaustive: never = s;
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: _exhaustive };
}
}
}
function formatHM(min: number): string {
const h = Math.floor(min / 60);
const m = min % 60;
return `${h}h ${m}m`;
}
export default function TimesheetsList({
rows,
total,
workers,
filters,
}: {
rows: TimesheetSummary[];
total: number;
workers: Worker[];
filters: Filters;
}) {
const workerById = useMemo(() => {
const m = new Map<string, Worker>();
for (const w of workers) m.set(w.id, w);
return m;
}, [workers]);
return (
<div className="space-y-4">
{/* Filter bar */}
<form className="flex flex-wrap items-end gap-3 rounded-2xl border border-zinc-200 bg-white p-4">
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
Status
</label>
<select
name="status"
defaultValue={filters.status}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
>
<option value="all">All</option>
<option value="draft">Draft</option>
<option value="submitted">Submitted</option>
<option value="approved">Approved / Locked</option>
<option value="rejected">Rejected</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
Worker
</label>
<select
name="workerId"
defaultValue={filters.workerId ?? ""}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
>
<option value="">All workers</option>
{workers.map((w) => (
<option key={w.id} value={w.id}>
{w.name} ({w.role})
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
Period start
</label>
<input
name="periodStart"
type="date"
defaultValue={filters.periodStart ?? ""}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
Period end
</label>
<input
name="periodEnd"
type="date"
defaultValue={filters.periodEnd ?? ""}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
/>
</div>
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800"
>
Apply
</button>
<Link
href="/admin/time-tracking/timesheets"
className="rounded-md border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
>
Reset
</Link>
</form>
{rows.length === 0 ? (
<div className="rounded-2xl border border-dashed border-zinc-300 bg-white p-10 text-center text-sm text-zinc-500">
No timesheets match the current filters.
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-left text-[11px] font-bold uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-3">Worker</th>
<th className="px-4 py-3">Period</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3 text-right">Total</th>
<th className="px-4 py-3 text-right">OT</th>
<th className="px-4 py-3 text-right">Entries</th>
<th className="px-4 py-3 text-right">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.map((r) => {
const c = statusColor(r.status);
return (
<tr key={r.id} className="hover:bg-zinc-50">
<td className="px-4 py-3">
<Link
href={`/admin/time-tracking/timesheets/${r.id}`}
className="font-medium text-zinc-900 hover:underline"
>
{r.worker_name}
</Link>
<span className="ml-2 text-xs text-zinc-500">
{r.worker_role}
</span>
</td>
<td className="px-4 py-3 text-zinc-700">
{r.period_start} {r.period_end}
</td>
<td className="px-4 py-3">
<span
className={`inline-block rounded-full px-2 py-0.5 text-[11px] font-semibold ${c.bg} ${c.fg}`}
>
{c.label}
</span>
</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
{formatHM(r.total_minutes)}
</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
{formatHM(r.overtime_minutes)}
</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
{r.entry_count}
</td>
<td className="px-4 py-3 text-right text-xs text-zinc-500">
{new Date(r.updated_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+302
View File
@@ -19,6 +19,23 @@ function prefersReducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches === true;
}
// ─────────────────────────────────────────────────────────────────────────────
// APPLE MOTION TOKENS — used by the AppleReveal / AppleWordReveal / AppleSticky
// primitives below. Designed to feel like iOS sheet handoffs and Vision
// Pro scrolltelling: snappy departure, soft landing.
//
// Easing: power3.out ≈ cubic-bezier(0.16, 1, 0.3, 1)
// Distance: 18px max (Apple scroll fades never translate far)
// Duration: 0.55s primary, 0.4s secondary — measured, never rushed
// Stagger: 0.06s between siblings — visible cadence without lag
// Blur: 6px → 0px (subtle depth-of-field unfocus on reveal)
// ─────────────────────────────────────────────────────────────────────────────
export const APPLE_EASE = "power3.out";
export const APPLE_DURATION = 0.55;
export const APPLE_DURATION_FAST = 0.4;
export const APPLE_DISTANCE = 18;
export const APPLE_STAGGER = 0.06;
// ─────────────────────────────────────────────────────────────────────────────
// SCROLL-REVEAL WRAPPER - Layers content as user scrolls
// Motion-reduced: max 12px translation (was 60px), 320ms (was 1s),
@@ -322,3 +339,288 @@ export function FadeOnScroll({
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// AppleReveal — premium scroll-reveal for editorial sections.
//
// Reveal choreography: opacity 0→1 + Y 18→0 + filter blur(6px)→0.
// Uses power3.out (≈ Apple/Linear's cubic-bezier(0.16, 1, 0.3, 1)) with
// 0.55s primary duration. Triggers at "top 88%" so it fires just before
// the element is fully in view — feels anticipatory, never late.
//
// One-shot: toggleActions "play none none none" so reverse scroll does
// not re-fire. Reduced-motion safe (CSS transition only, ~280ms).
// ─────────────────────────────────────────────────────────────────────────────
interface AppleRevealProps {
children: React.ReactNode;
className?: string;
delay?: number;
duration?: number;
distance?: number;
blur?: number;
/** When true, fires on mount rather than on scroll. Useful for hero content. */
immediate?: boolean;
}
export function AppleReveal({
children,
className = "",
delay = 0,
duration = APPLE_DURATION,
distance = APPLE_DISTANCE,
blur = 6,
immediate = false,
}: AppleRevealProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (typeof window === "undefined" || !ref.current) return;
if (prefersReducedMotion()) {
// Just snap to the end state. No animation, no filter.
ref.current.style.opacity = "1";
ref.current.style.transform = "none";
ref.current.style.filter = "none";
return;
}
const ctx = gsap.context(() => {
gsap.fromTo(
ref.current,
{
opacity: 0,
y: distance,
filter: `blur(${blur}px)`,
willChange: "transform, opacity, filter",
},
{
opacity: 1,
y: 0,
filter: "blur(0px)",
duration,
delay,
ease: APPLE_EASE,
...(immediate
? {}
: {
scrollTrigger: {
trigger: ref.current,
start: "top 88%",
toggleActions: "play none none none",
},
}),
}
);
}, ref);
return () => ctx.revert();
}, [delay, duration, distance, blur, immediate]);
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// AppleStaggerReveal — staggered siblings (eyebrow → rule → h2 → lede).
//
// Wrap any block of children with `data-apple-stagger` on each child you
// want to reveal in sequence. The wrapper coordinates a 0.06s stagger
// between siblings. Each sibling uses the AppleReveal choreography.
// ─────────────────────────────────────────────────────────────────────────────
interface AppleStaggerProps {
children: React.ReactNode;
className?: string;
/** Initial delay before the first sibling fires. */
startDelay?: number;
/** Per-sibling stagger in seconds. */
stagger?: number;
}
export function AppleStagger({
children,
className = "",
startDelay = 0,
stagger = APPLE_STAGGER,
}: AppleStaggerProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (typeof window === "undefined" || !ref.current) return;
if (prefersReducedMotion()) {
const items = ref.current.querySelectorAll<HTMLElement>("[data-apple-stagger]");
items.forEach((el) => {
el.style.opacity = "1";
el.style.transform = "none";
el.style.filter = "none";
});
return;
}
const ctx = gsap.context(() => {
const items = gsap.utils.toArray<HTMLElement>("[data-apple-stagger]");
if (!items.length) return;
gsap.fromTo(
items,
{ opacity: 0, y: APPLE_DISTANCE, filter: "blur(6px)" },
{
opacity: 1,
y: 0,
filter: "blur(0px)",
duration: APPLE_DURATION,
delay: startDelay,
stagger,
ease: APPLE_EASE,
scrollTrigger: {
trigger: ref.current,
start: "top 88%",
toggleActions: "play none none none",
},
}
);
}, ref);
return () => ctx.revert();
}, [startDelay, stagger]);
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// AppleStickySection — sticky scrolltelling.
//
// Puts its first child into `position: sticky` while the rest of the
// content scrolls past it. Perfect for the "PullQuoteBand" /
// "HarvestEditorial" hero moments where one big element should stay on
// screen while supporting copy reveals underneath.
//
// Usage:
// <AppleStickySection>
// <YourBigImage />
// <YourCopyReveals />
// </AppleStickySection>
//
// The container takes the height of all its children; the sticky child
// stays pinned to the top of the viewport while subsequent siblings
// scroll past it.
// ─────────────────────────────────────────────────────────────────────────────
interface AppleStickySectionProps {
children: React.ReactNode;
className?: string;
/** Top offset for the sticky child (e.g. "0" or "10vh"). */
top?: string;
}
export function AppleStickySection({
children,
className = "",
top = "0",
}: AppleStickySectionProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
// Mark the first child sticky. Done in JS so we don't fight SSR
// (sticky inside flex / grid can have surprises on first paint).
const first = ref.current.firstElementChild as HTMLElement | null;
if (first) {
first.style.position = "sticky";
first.style.top = top;
// Hardware-accelerated; smoother during scroll.
first.style.willChange = "transform";
}
// Add a scroll-linked parallax to the sticky child — 12% drift
// over its full scroll length. Subtle depth cue.
if (!prefersReducedMotion() && first) {
const ctx = gsap.context(() => {
gsap.fromTo(
first,
{ yPercent: -3 },
{
yPercent: 3,
ease: "none",
scrollTrigger: {
trigger: ref.current,
start: "top top",
end: "bottom bottom",
scrub: true,
},
}
);
}, ref);
return () => ctx.revert();
}
}, [top]);
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// AppleCountUp — refined counter animation.
//
// Uses power3.out (Apple expo-out) over 1.6s — slower than the previous
// 2s linear, but the eased curve makes the *perceived* duration feel
// shorter (lands on the target faster, with a soft tail).
// ─────────────────────────────────────────────────────────────────────────────
interface AppleCountUpProps {
end: number;
duration?: number;
className?: string;
format?: (n: number) => string;
}
export function AppleCountUp({
end,
duration = 1.6,
className = "",
format = (n) => Math.floor(n).toLocaleString(),
}: AppleCountUpProps) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (!ref.current) return;
if (prefersReducedMotion()) {
ref.current.textContent = format(end);
return;
}
const obj = { v: 0 };
const ctx = gsap.context(() => {
gsap.to(obj, {
v: end,
duration,
ease: APPLE_EASE,
scrollTrigger: {
trigger: ref.current,
start: "top 88%",
toggleActions: "play none none none",
},
onUpdate: () => {
if (ref.current) ref.current.textContent = format(obj.v);
},
});
}, ref);
return () => ctx.revert();
}, [end, duration, format]);
return (
<span ref={ref} className={className}>
0
</span>
);
}
+12 -4
View File
@@ -63,14 +63,22 @@ export function MobileWaterHeadgateList({
paddingTop: "env(safe-area-inset-top)",
}}
>
{/* ── Top nav ──────────────────────────────────────────────── */}
{/* ── Top nav (Apple HIG liquid glass) ─────────────────────── */}
<header
className="sticky top-0 z-30 flex h-11 items-center justify-between border-b border-[rgba(60,60,67,0.12)] bg-[rgba(255,255,255,0.85)] px-2"
className="sticky top-0 z-30 flex h-11 items-center justify-between px-2"
style={{
paddingTop: "env(safe-area-inset-top)",
height: "calc(44px + env(safe-area-inset-top))",
backdropFilter: "blur(20px) saturate(180%)",
WebkitBackdropFilter: "blur(20px) saturate(180%)",
// Gradient + saturate(180%) reads as "liquid glass" — the
// gold standard iOS Settings / Notes / Mail chrome uses
// the same recipe. Single shadow with two insets handles
// the top highlight + bottom hairline in one rule.
background:
"linear-gradient(180deg, rgba(255,255,255,0.88) 0%, rgba(255,255,255,0.62) 100%)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
boxShadow:
"inset 0 0.5px 0 0 rgba(255,255,255,1), inset 0 -0.5px 0 0 rgba(0,0,0,0.06)",
}}
>
<div className="flex items-center gap-1">
+40 -27
View File
@@ -26,12 +26,12 @@
*/
import {
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
} from "react";
import { SegmentedControl } from "./SegmentedControl";
import { ThresholdMeter } from "./ThresholdMeter";
import type { FieldHeadgate, MobileUnit } from "./types";
import { isIntegerUnit, MOBILE_UNITS } from "./types";
import { ChevronLeft, Clock, Gauge, Logout, Pencil, Pin, Spinner } from "./icons";
@@ -101,19 +101,10 @@ export function MobileWaterLogForm({
return () => window.clearInterval(id);
}, []);
// ── Threshold hint derived from the headgate. ────────────────────
const thresholdHint = useMemo(() => {
const h = headgate.high_threshold;
const l = headgate.low_threshold;
const u = formatUnit(unit, lang);
if (h == null && l == null) return null;
if (h != null && l != null) {
return t.log.normalRange(l, h, u);
}
if (h != null) return t.log.alertAbove(h, u);
// TS narrowing: reaching this line means `h == null && l != null`.
return t.log.alertBelow(l as number, u);
}, [headgate, unit, lang, t]);
// ── Threshold hint is now rendered by <ThresholdMeter /> below —
// the meter derives its own zone state from the live value +
// headgate thresholds, so the form no longer needs a
// useMemo for the static caption. ─────────────────────
const submitted = (e?: FormEvent) => {
e?.preventDefault();
@@ -165,13 +156,21 @@ export function MobileWaterLogForm({
paddingTop: "env(safe-area-inset-top)",
}}
>
{/* ── Top nav ────────────────────────────────────────────── */}
{/* ── Top nav (Apple HIG liquid glass) ─────────────────────── */}
<header
className="sticky top-0 z-30 flex items-center gap-2 border-b border-[rgba(60,60,67,0.12)] bg-[rgba(255,255,255,0.85)] px-2 py-2"
className="sticky top-0 z-30 flex items-center gap-2 px-2 py-2"
style={{
paddingTop: "calc(env(safe-area-inset-top) + 4px)",
backdropFilter: "blur(20px) saturate(180%)",
WebkitBackdropFilter: "blur(20px) saturate(180%)",
// Mirror the headgate-list chrome so the worker feels the
// same shell as they navigate. Gradient + saturate(180%)
// gives the modern iOS look; insets deliver the highlight
// and hairline in one rule.
background:
"linear-gradient(180deg, rgba(255,255,255,0.88) 0%, rgba(255,255,255,0.62) 100%)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
boxShadow:
"inset 0 0.5px 0 0 rgba(255,255,255,1), inset 0 -0.5px 0 0 rgba(0,0,0,0.06)",
}}
>
<button
@@ -290,11 +289,16 @@ export function MobileWaterLogForm({
{unitLabel}
</span>
</div>
{thresholdHint && (
<p className="mt-3 border-t border-[rgba(60,60,67,0.10)] pt-3 text-[13px] text-[rgba(60,60,67,0.6)]">
{thresholdHint}
</p>
)}
{/* Live threshold meter the screen's signature. Shows
* the entered value's position against the headgate's
* configured low/high thresholds, with a color-coded
* thumb and zone label. */}
<ThresholdMeter
value={measurement}
unit={unit}
lowThreshold={headgate.low_threshold}
highThreshold={headgate.high_threshold}
/>
</div>
{/* Unit segmented control labels use the localized
@@ -374,13 +378,22 @@ export function MobileWaterLogForm({
)}
</div>
{/* ── Sticky submit bar ───────────────────────────────── */}
{/* ── Sticky submit bar (Apple HIG liquid glass) ──────────── */}
{/* Gradient runs the OPPOSITE direction from the top nav
brighter where it meets the form (top edge), slightly
more transparent where it flows into the safe area.
The hairline lives on the top edge of the bar so it
reads as a divider, not a frame. */}
<div
className="sticky bottom-0 z-20 border-t border-[rgba(60,60,67,0.10)] bg-[rgba(255,255,255,0.92)] px-4 py-3"
className="sticky bottom-0 z-20 px-4 py-3"
style={{
paddingBottom: "max(env(safe-area-inset-bottom), 0.75rem)",
backdropFilter: "blur(20px) saturate(180%)",
WebkitBackdropFilter: "blur(20px) saturate(180%)",
background:
"linear-gradient(0deg, rgba(255,255,255,0.65) 0%, rgba(255,255,255,0.88) 100%)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
boxShadow:
"inset 0 0.5px 0 0 rgba(0,0,0,0.06), inset 0 -0.5px 0 0 rgba(255,255,255,1)",
}}
>
<button
+286 -12
View File
@@ -5,13 +5,14 @@
* Tuxedo Water Log experience.
*
* Flow:
* pin verify headgates pick log submit success
*
* (logout)
* (logout)(anywhere)
* pin verify tabbed shell
*
* success Log Another log (cleared)
* success Back to Headgates headgates
* [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,26 +22,56 @@
* 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 {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import {
verifyWaterPin,
submitWaterEntry,
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 +87,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 +157,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 +221,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 +323,220 @@ 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 → Apple HIG pass) ──────────────────────────
// Sticky top nav for the Tuxedo worker experience. Stays visible
// across Headgates → Log → Success AND on the Time tab.
//
// Two HIG principles in play:
// 1. Use UISegmentedControl for the mode switch (not two pills).
// The control is a true iOS segmented control: gray track,
// sliding white pill, spring timing. The same widget appears
// as the unit selector inside the log form, just bigger.
// 2. Use a "liquid glass" chrome on the bar itself: gradient
// background, strong saturate(180%), inner top highlight, and
// a hairline at the bottom edge — not a flat colored band.
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 px-3"
style={{
paddingTop: "env(safe-area-inset-top)",
height: "calc(44px + env(safe-area-inset-top))",
// "Liquid glass": top catches more light than the bottom,
// so the gradient runs brighter at the top and slightly
// more transparent below. Saturation boost makes any
// background read with a hint of brand warmth as the
// worker scrolls headgates past the bar.
background:
"linear-gradient(180deg, rgba(255,255,255,0.88) 0%, rgba(255,255,255,0.62) 100%)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
// Inner highlight + hairline (one box-shadow with two
// insets reads cheaper than separate pseudo-elements).
boxShadow:
"inset 0 0.5px 0 0 rgba(255,255,255,1), inset 0 -0.5px 0 0 rgba(0,0,0,0.06)",
}}
>
<MiniSegmented
ariaLabel="Worker navigation"
value={activeTab}
onChange={onChange}
options={[
{ value: "headgates", label: t.tab.headgates },
{ value: "time", label: t.tab.time },
]}
/>
<div className="flex items-center gap-1.5 pr-0.5">
{irrigatorName && (
<span className="hidden text-[12px] font-medium text-[rgba(60,60,67,0.55)] sm:inline">
{irrigatorName}
</span>
)}
<button
type="button"
onClick={onLogout}
aria-label={t.tab.logout}
className="rounded-full px-2.5 py-1 text-[13px] font-semibold text-[#14532d] active:bg-[rgba(120,120,128,0.16)]"
>
{t.tab.logout}
</button>
</div>
</div>
);
}
// ── MiniSegmented (nav-bar-sized UISegmentedControl) ──────────────
// Sliding-pill variant of SegmentedControl tuned for the 32pt nav
// bar context. Reuses the same measurement + spring-timing approach
// as the larger control in `SegmentedControl.tsx` so the two feel
// like one widget at different sizes.
function MiniSegmented<V extends string>({
options,
value,
onChange,
ariaLabel,
}: {
options: ReadonlyArray<{ value: V; label: ReactNode }>;
value: V;
onChange: (next: V) => void;
ariaLabel: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
// Sliding indicator rect. The indicator uses
// `transform: translateX(...)` with Apple's spring curve so the
// transition reads as "physical," not "fade."
const [rect, setRect] = useState<{ x: number; w: number } | null>(null);
const measure = useCallback(() => {
const root = containerRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(
`[data-seg-key="${value}"]`,
);
if (!btn) return;
const rootRect = root.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
// `x` is measured from the container's content edge (after the
// 2px padding) so the indicator rides just inside the track.
setRect({ x: btnRect.left - rootRect.left - 2, w: btnRect.width });
}, [value]);
useLayoutEffect(() => {
measure();
}, [measure]);
// Re-measure whenever the option set changes (e.g. labels
// translate via the Lang context and the active segment width
// shifts) so the indicator stays in place.
useLayoutEffect(() => {
const root = containerRef.current;
if (!root) return;
const ro = new ResizeObserver(measure);
ro.observe(root);
return () => ro.disconnect();
}, [measure]);
return (
<div
ref={containerRef}
role="tablist"
aria-label={ariaLabel}
className="relative inline-flex h-8 items-stretch rounded-[9px] p-[2px]"
style={{ background: "rgba(120,120,128,0.20)" }}
>
{rect && (
<div
aria-hidden
className="absolute rounded-[7px] bg-white"
style={{
top: 2,
bottom: 2,
left: 2,
width: rect.w,
transform: `translateX(${rect.x}px)`,
boxShadow:
"0 3px 8px rgba(0,0,0,0.06), 0 3px 1px rgba(0,0,0,0.04), inset 0 -0.5px 0 0 rgba(0,0,0,0.04), inset 0 0.5px 0 0 rgba(255,255,255,1)",
transition:
"transform 320ms cubic-bezier(0.32, 0.72, 0, 1), width 320ms cubic-bezier(0.32, 0.72, 0, 1)",
}}
/>
)}
{options.map((opt) => {
const selected = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={selected}
data-seg-key={opt.value}
onClick={() => onChange(opt.value)}
className={[
"relative z-10 select-none px-3 text-[13px] font-semibold",
"flex items-center justify-center",
"min-w-[78px] active:opacity-80",
selected ? "text-[#1d1d1f]" : "text-[rgba(60,60,67,0.65)]",
].join(" ")}
style={{
height: 28,
transition:
"color 220ms cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
<span className="truncate">{opt.label}</span>
</button>
);
})}
</div>
);
}
export default MobileWaterApp;
+27 -276
View File
@@ -1,297 +1,48 @@
"use client";
/**
* MobileWaterPinScreen PIN entry screen.
* MobileWaterPinScreen water-side wrapper around the shared
* `FieldPinScreen`. Same brand color, water-specific icon and
* wordmark, and translations lifted from the existing Lang context.
*
* Visual structure (Apple HIG):
* - Minimal nav bar with "TUXEDO WATER LOG" branding (system
* small-caps via tracking).
* - Centered droplet + lock icon pair as a brand anchor.
* - Headline ("Enter PIN") + supporting caption.
* - Four large digit boxes (64pt square) that mirror the value of
* a hidden <input>. Tapping the row focuses the hidden input
* the native numeric keyboard rises from the bottom.
* - Full-width primary "Unlock" button (HIG success blue).
* - Inline error pill replaces the caption on failure, with a
* gentle shake.
*
* The hidden input is the source of truth so iOS's QuickType /
* password-manager suggestions work correctly we never rebuild
* the digit boxes from a synthetic onChange.
* Cycle 11 (visual cohesion) lifted the actual rendering code into
* `src/components/field/FieldPinScreen.tsx` so Time Tracking can use
* the same component with a clock icon + a different wordmark.
* Behavior is byte-identical to the previous implementation; only
* the rendering primitive changed.
*/
import {
useCallback,
useEffect,
useRef,
useState,
type FormEvent,
} from "react";
import { Droplet, Lock, Spinner } from "./icons";
import { FieldPinScreen } from "@/components/field/FieldPinScreen";
import { useLang } from "@/lib/water-log/lang-context";
type Props = {
/** Called when the user submits a 4-8 digit PIN. */
onSubmit: (pin: string) => Promise<void>;
/** PIN length, matches `PIN_MIN`/`PIN_MAX` from water-log-pin. */
length?: 4 | 5 | 6 | 7 | 8;
};
export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
const { t } = useLang();
const [pin, setPin] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [shake, setShake] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-focus the hidden input when the screen mounts so the
// keyboard rises immediately. iOS sometimes needs a tiny delay
// for the focus to take after the page transition.
useEffect(() => {
const t = window.setTimeout(() => inputRef.current?.focus(), 80);
return () => window.clearTimeout(t);
}, []);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
// Strip non-digits, clamp to `length`.
const next = e.target.value.replace(/\D/g, "").slice(0, length);
setPin(next);
if (error) setError(null);
},
[error, length],
);
const submit = useCallback(
async (e?: FormEvent) => {
e?.preventDefault();
if (pin.length !== length || submitting) return;
setSubmitting(true);
setError(null);
try {
await onSubmit(pin);
// Don't reset here — the parent will navigate away.
} catch (err) {
// Network / unexpected error
setError(
err instanceof Error ? err.message : t.pin.genericError,
);
setShake(true);
window.setTimeout(() => setShake(false), 420);
setPin("");
} finally {
setSubmitting(false);
}
},
[pin, length, submitting, onSubmit, t.pin.genericError],
);
const onFailureShake = useCallback(() => {
setShake(true);
window.setTimeout(() => setShake(false), 420);
setPin("");
inputRef.current?.focus();
}, []);
// Expose the shake trigger via a custom event so the parent can
// fire it after a failed verify (parent owns the verify call).
useEffect(() => {
function onShake() {
onFailureShake();
}
window.addEventListener("water-pin-shake", onShake);
return () => window.removeEventListener("water-pin-shake", onShake);
}, [onFailureShake]);
return (
<div className="flex h-full flex-col bg-[#F2F2F7]">
{/* ── Branded header ───────────────────────────────────────── */}
<header
className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]"
style={{
background:
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(242,242,247,0) 100%)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
<FieldPinScreen
onSubmit={onSubmit}
length={length}
brand={{
wordmark: t.brand.waterLog,
icon: "droplet",
primary: "#14532D",
}}
>
<div className="mx-auto flex max-w-md items-center justify-center gap-2">
<Droplet size={18} className="text-[#14532d]" />
<span
className="text-[12px] font-bold tracking-[0.22em] text-[#14532d]"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
translations={{
title: t.pin.title,
caption: t.pin.caption,
unlock: t.pin.unlock,
verifying: t.pin.verifying,
forgot: t.pin.forgot,
genericError: t.pin.genericError,
a11yPin: t.pin.a11yPin,
a11yPinEntry: t.pin.a11yPinEntry,
}}
>
{t.brand.waterLog}
</span>
</div>
</header>
{/* ── Centered content ─────────────────────────────────────── */}
<main className="flex flex-1 flex-col items-center justify-center px-6 pb-[max(env(safe-area-inset-bottom),1.25rem)]">
<div className="w-full max-w-sm">
{/* Brand mark */}
<div className="mb-7 flex justify-center">
<div
className="flex h-20 w-20 items-center justify-center rounded-[22px]"
style={{
background:
"linear-gradient(160deg, #ffffff 0%, #f0f7f2 60%, #e1efe5 100%)",
boxShadow:
"0 1px 0 rgba(255,255,255,0.9) inset, 0 6px 16px rgba(20,83,45,0.18), 0 1px 3px rgba(0,0,0,0.06)",
}}
>
<Lock size={32} className="text-[#14532d]" />
</div>
</div>
<h1
className="mb-2 text-center text-[28px] font-bold leading-[1.15] tracking-tight text-[#1d1d1f]"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
{t.pin.title}
</h1>
{/* Caption OR inline error */}
<p
className={[
"mb-9 min-h-[20px] text-center text-[15px]",
error ? "text-[#FF3B30]" : "text-[rgba(60,60,67,0.6)]",
].join(" ")}
role={error ? "alert" : undefined}
aria-live={error ? "assertive" : undefined}
>
{error ?? t.pin.caption}
</p>
{/* ── Digit boxes ────────────────────────────────────── */}
{/* The hidden input is the source of truth and gives us
* native iOS numeric keyboard + autofill. The visible
* boxes mirror its value. */}
<form onSubmit={submit}>
<input
ref={inputRef}
type="password"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="one-time-code"
maxLength={length}
value={pin}
onChange={handleChange}
// Keep the hidden input in the layout flow so its
// keyboard rises correctly. Visually hidden via the
// standard "sr-only" technique.
className="sr-only"
aria-label={t.pin.a11yPin}
disabled={submitting}
shakeEventName="water-pin-shake"
/>
<button
type="button"
onClick={() => inputRef.current?.focus()}
aria-label={t.pin.a11yPinEntry}
className="mx-auto mb-8 flex w-full justify-center gap-3 outline-none"
>
{Array.from({ length }).map((_, i) => {
const filled = i < pin.length;
const active = i === pin.length;
return (
<div
key={i}
className="relative flex h-[68px] w-[58px] items-center justify-center rounded-[14px] bg-white text-[34px] font-bold leading-none text-[#1d1d1f]"
style={{
boxShadow: active
? "0 0 0 2px #14532d, 0 1px 2px rgba(0,0,0,0.04), 0 4px 14px rgba(20,83,45,0.18)"
: "0 0 0 1px rgba(60,60,67,0.18), 0 1px 2px rgba(0,0,0,0.04)",
transition: "box-shadow 180ms cubic-bezier(0.32,0.72,0,1)",
}}
>
{filled ? (
<span className="relative">
{/* Solid dot — never reveals the digit back */}
<span
aria-hidden
className="block h-[14px] w-[14px] rounded-full bg-[#1d1d1f]"
/>
</span>
) : null}
{/* Caret blink on the active box (iOS-style). */}
{active && !filled && (
<span
aria-hidden
className="block h-[28px] w-[2px] rounded-full bg-[#14532d]"
style={{
animation: "water-blink 1.05s steps(2, start) infinite",
}}
/>
)}
</div>
);
})}
</button>
{/* Hidden submit so Enter from the keyboard triggers Unlock */}
<button type="submit" className="sr-only" tabIndex={-1}>
{t.pin.unlock}
</button>
</form>
{/* ── Unlock button ─────────────────────────────────── */}
<button
type="button"
onClick={() => submit()}
disabled={pin.length !== length || submitting}
className={[
"flex h-[58px] w-full items-center justify-center gap-2 rounded-[14px] text-[17px] font-semibold",
"transition-[transform,background-color,opacity] duration-200",
"active:scale-[0.985]",
pin.length === length && !submitting
? "bg-[#14532d] text-white shadow-[0_6px_18px_rgba(20,83,45,0.28),inset_0_1px_0_rgba(255,255,255,0.16)]"
: "bg-[rgba(120,120,128,0.16)] text-[rgba(60,60,67,0.4)]",
].join(" ")}
style={{
// Pressable feedback — springy scale.
transform: shake ? "translateX(0)" : undefined,
}}
>
{submitting ? (
<>
<Spinner
size={20}
className="text-white"
style={{ animation: "spin 0.8s linear infinite" }}
/>
{t.pin.verifying}
</>
) : (
<>{t.pin.unlock}</>
)}
</button>
{/* Footer hint */}
<p className="mt-7 text-center text-[12px] text-[rgba(60,60,67,0.45)]">
{t.pin.forgot}
</p>
</div>
</main>
{/* Apply the shake via inline animation when `shake` is true.
* The `water-shake` keyframe lives in `src/app/globals.css`
* alongside the rest of the Water Log animation set. */}
{shake && (
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 top-1/2 z-50 mx-auto max-w-sm"
style={{
animation: "water-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97)",
}}
/>
)}
</div>
);
}
+13 -8
View File
@@ -14,8 +14,10 @@
* - "Back to Headgates" (white secondary with thin border)
* - Subtle thank-you micro-copy at the bottom.
*
* The animations use the global `water-ring` + `water-draw` keyframes
* from `src/app/globals.css`.
* The animations use the shared `field-ring` + `field-draw` keyframes
* (declared in `src/app/globals.css`). Cycle 11 collapsed the legacy
* `water-*` names so this screen and the Time Tracking clocked-out
* screen share the same reveal choreography.
*/
import { useEffect, useState } from "react";
import { Grid, Refresh } from "./icons";
@@ -71,14 +73,17 @@ export function MobileWaterSuccessScreen({
paddingBottom: "env(safe-area-inset-bottom)",
}}
>
{/* Brand strip — same as PIN screen for visual continuity. */}
{/* Brand strip same liquid-glass recipe as the PIN screen so
the success state feels like a sibling, not a stranger. */}
<header
className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]"
style={{
background:
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(242,242,247,0) 100%)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.62) 60%, rgba(242,242,247,0) 100%)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
boxShadow:
"inset 0 -0.5px 0 0 rgba(0,0,0,0.04)",
}}
>
<div className="mx-auto flex max-w-md items-center justify-center gap-2">
@@ -119,7 +124,7 @@ export function MobileWaterSuccessScreen({
strokeDashoffset="264"
style={{
animation:
"water-ring 420ms cubic-bezier(0.32, 0.72, 0, 1) forwards",
"field-ring 420ms cubic-bezier(0.32, 0.72, 0, 1) forwards",
["--ring-from" as string]: "264",
}}
/>
@@ -135,7 +140,7 @@ export function MobileWaterSuccessScreen({
strokeDashoffset="62"
style={{
animation:
"water-draw 320ms cubic-bezier(0.32, 0.72, 0, 1) 280ms forwards",
"field-draw 320ms cubic-bezier(0.32, 0.72, 0, 1) 280ms forwards",
["--draw-from" as string]: "62",
}}
/>
@@ -0,0 +1,298 @@
"use client";
/**
* ThresholdMeter a live "blip" indicator under the 44pt
* measurement input that ties the form to its real domain (a
* worker reading a real gauge) instead of being a decorative
* gauge trophy.
*
* This is the screen's signature element. It mirrors Apple's
* Volume Limit / Screen Time sliders: a neutral track with
* colored zones (red green red) and a white thumb with a
* colored ring that slides horizontally as the worker types.
*
* Visual layering, back to front:
* 1. Neutral track (iOS systemGray, 6px tall, fully rounded).
* 2. Optional colored zones (only when thresholds are set):
* below-low · between low-high · above-high.
* 3. Thumb (18px white circle with a 3px colored ring) that
* uses `transform: translateX(...)` with Apple's standard
* spring curve, so the motion reads as physical.
* 4. Two-line status caption: left = color-coded zone label,
* right = numeric scale (monospaced).
*
* Auto-grows the visual scale to fit the entered value when no
* thresholds are configured, so the bar still has a usable
* range even on first-run headgates without admin-configured
* limits.
*/
import { useMemo } from "react";
import { formatMeasurement, formatUnit } from "./format";
import { isIntegerUnit } from "./types";
import { useLang } from "@/lib/water-log/lang-context";
type Zone = "none" | "low" | "normal" | "high";
type Props = {
/** Raw input value (string from the input). Empty → bar shows no thumb. */
value: string;
/** Selected unit. Captions + scale thumb reposition on changes. */
unit: string;
/** Headgate's configured low threshold, or null. */
lowThreshold: number | null;
/** Headgate's configured high threshold, or null. */
highThreshold: number | null;
};
// Apple system colors — pulled from HIG. Reusing the same palette
// as the success button (systemGreen) and the error toast
// (systemRed) so the meter's color language is already familiar
// to anyone who's used the rest of the app.
const COLOR_TRACK = "rgba(120,120,128,0.16)";
const COLOR_NORMAL = "#34C759";
const COLOR_ALERT = "#FF3B30";
export function ThresholdMeter({
value,
unit,
lowThreshold,
highThreshold,
}: Props) {
const { t, lang } = useLang();
const numeric = parseFloat(value);
const hasValue =
value.trim().length > 0 && Number.isFinite(numeric) && numeric >= 0;
const integerUnit = isIntegerUnit(unit);
const unitLabel = formatUnit(unit, lang);
const hasThresholds = lowThreshold != null || highThreshold != null;
// ── Visual scale (max value the track represents) ────────
// Grow to whichever is largest: 1.5× the high threshold (so
// there's always a "tail" of alert zone past the alert line),
// 2× the low threshold (so the in-range area starts before
// the low edge — symmetric on either side), or 1.3× the
// entered value (so the thumb isn't pinned at the right edge
// for the typical first entry).
const maxScale = useMemo(() => {
const seed = hasValue ? numeric : 0;
if (highThreshold != null && lowThreshold != null) {
return Math.max(highThreshold * 1.5, lowThreshold * 0.6, seed * 1.2, 10);
}
if (highThreshold != null) {
return Math.max(highThreshold * 1.5, seed * 1.2, 10);
}
if (lowThreshold != null) {
return Math.max(lowThreshold * 1.8, seed * 1.3, 10);
}
return Math.max(seed * 1.3, 10);
}, [hasValue, numeric, lowThreshold, highThreshold]);
// ── Marker position & zone fills ───────────────────────
const markerPct = hasValue
? Math.min(1, Math.max(0, numeric / maxScale))
: 0;
const lowPct =
lowThreshold != null
? Math.min(1, Math.max(0, lowThreshold / maxScale))
: 0;
const highPct =
highThreshold != null
? Math.min(1, Math.max(0, highThreshold / maxScale))
: 1;
// ── Zone classification ─────────────────────────────────
const zone: Zone = !hasValue
? "none"
: lowThreshold != null && numeric < lowThreshold
? "low"
: highThreshold != null && numeric > highThreshold
? "high"
: "normal";
// ── Captions ────────────────────────────────────────────
const displayValue = hasValue
? formatMeasurement(numeric, integerUnit)
: null;
let leftLabel: string;
let leftColor: string;
let rightLabel: string | null;
if (!hasValue) {
if (hasThresholds && lowThreshold != null && highThreshold != null) {
leftLabel = t.log.meterNormalRange(
formatMeasurement(lowThreshold, integerUnit),
formatMeasurement(highThreshold, integerUnit),
unitLabel,
);
} else if (highThreshold != null) {
leftLabel = t.log.alertAbove(
formatMeasurement(highThreshold, integerUnit),
unitLabel,
);
} else if (lowThreshold != null) {
leftLabel = t.log.alertBelow(
formatMeasurement(lowThreshold, integerUnit),
unitLabel,
);
} else {
leftLabel = t.log.zoneTypeToLog;
}
leftColor = "rgba(60,60,67,0.5)";
rightLabel = null;
} else if (zone === "low") {
leftLabel = t.log.zoneBelowNormal;
leftColor = COLOR_ALERT;
rightLabel = t.log.alertBelow(
formatMeasurement(lowThreshold as number, integerUnit),
unitLabel,
);
} else if (zone === "high") {
leftLabel = t.log.zoneAboveAlert;
leftColor = COLOR_ALERT;
rightLabel = t.log.alertAbove(
formatMeasurement(highThreshold as number, integerUnit),
unitLabel,
);
} else if (lowThreshold != null && highThreshold != null) {
leftLabel = t.log.zoneInRange;
leftColor = COLOR_NORMAL;
rightLabel = t.log.meterNormalRange(
formatMeasurement(lowThreshold, integerUnit),
formatMeasurement(highThreshold, integerUnit),
unitLabel,
);
} else if (highThreshold != null) {
leftLabel = t.log.zoneInRange;
leftColor = COLOR_NORMAL;
rightLabel = t.log.alertAbove(
formatMeasurement(highThreshold, integerUnit),
unitLabel,
);
} else if (lowThreshold != null) {
leftLabel = t.log.zoneAboveMin;
leftColor = COLOR_NORMAL;
rightLabel = t.log.alertBelow(
formatMeasurement(lowThreshold, integerUnit),
unitLabel,
);
} else {
leftLabel = t.log.zoneReading;
leftColor = "rgba(60,60,67,0.85)";
rightLabel = null;
}
// Append "· {value} {unit}" to the left label only when the
// zone label is meaningful on its own. That keeps the empty /
// no-threshold states readable (no trailing "· undefined CFS").
const formattedReading = hasValue
? `${displayValue} ${unitLabel}`
: null;
return (
<div className="mt-4 border-t border-[rgba(60,60,67,0.08)] pt-3">
{/* ── Track + zones + thumb ──────────────────────────── */}
<div className="relative mx-1.5 h-[6px]">
{/* 1. Neutral track */}
<div
aria-hidden
className="absolute inset-0 rounded-full"
style={{ background: COLOR_TRACK }}
/>
{/* 2a. Below-low zone */}
{hasThresholds && (
<div
aria-hidden
className="absolute top-0 bottom-0 rounded-full"
style={{
left: 0,
width: `${lowPct * 100}%`,
background: COLOR_ALERT,
opacity: 0.85,
}}
/>
)}
{/* 2b. Normal zone */}
{hasThresholds && (
<div
aria-hidden
className="absolute top-0 bottom-0 rounded-full"
style={{
left: `${lowPct * 100}%`,
width: `${Math.max(0, (highPct - lowPct) * 100)}%`,
background: COLOR_NORMAL,
opacity: 0.85,
}}
/>
)}
{/* 2c. Above-high zone */}
{hasThresholds && highThreshold != null && (
<div
aria-hidden
className="absolute top-0 bottom-0 rounded-full"
style={{
left: `${highPct * 100}%`,
width: `${Math.max(0, (1 - highPct) * 100)}%`,
background: COLOR_ALERT,
opacity: 0.85,
}}
/>
)}
{/* 3. Thumb */}
{hasValue && (
<div
aria-hidden
className="absolute"
style={{
top: "50%",
left: `${markerPct * 100}%`,
transform: "translate(-50%, -50%)",
width: 18,
height: 18,
borderRadius: 9999,
background: "white",
border:
zone === "low" || zone === "high"
? `3px solid ${COLOR_ALERT}`
: zone === "normal"
? `3px solid ${COLOR_NORMAL}`
: "3px solid rgba(60,60,67,0.6)",
boxShadow:
"0 2px 5px rgba(0,0,0,0.12), 0 0 0 0.5px rgba(0,0,0,0.06)",
transition:
"left 240ms cubic-bezier(0.32, 0.72, 0, 1), border-color 200ms ease-out",
}}
/>
)}
</div>
{/* ── Caption (zone label + scale range) ─────────────── */}
<div className="mt-2.5 flex items-baseline justify-between gap-3">
<span
className="truncate text-[12px] font-semibold tabular-nums"
style={{
color: leftColor,
transition: "color 220ms ease-out",
}}
>
{leftLabel}
{formattedReading && (
<>
<span className="mx-1.5 font-normal opacity-50">·</span>
<span className="tabular-nums">{formattedReading}</span>
</>
)}
</span>
{rightLabel && zone === "normal" && (
<span className="shrink-0 text-[11px] tabular-nums text-[rgba(60,60,67,0.5)]">
{rightLabel}
</span>
)}
</div>
</div>
);
}
export default ThresholdMeter;

Some files were not shown because too many files have changed in this diff Show More