64 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
Nora bf68fa8431 revert: drop smartsheet backfill script from deploy
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
The script kept failing on deploy (ESM/CJS mismatch). User
opted to drop the auto-fix. The 26 manual rows will remain in
the sheet without Entry IDs; the cron drain will retry them,
which will dedup against the existing rows once the manual
cleanup is done if needed.
2026-07-03 17:36:02 -06:00
Nora b07be10cf9 fix(deploy): ship smartsheet-backfill-entry-ids.mjs to server
Deploy to route.crispygoat.com / deploy (push) Failing after 4m0s
2026-07-03 17:29:54 -06:00
Nora c160a1d81b fix(smartsheet): one-shot backfill script to re-add Entry IDs to manually inserted rows
Deploy to route.crispygoat.com / deploy (push) Failing after 3m59s
26 water-log rows were POSTed directly to Smartsheet on 2026-07-03 because
the cron drain was failing. Those rows went in without Entry IDs, so the
next cron drain would have created duplicates (findRowByColumn returns
nothing when Entry ID is empty).

This script:
  1. Matches each of the 26 hardcoded rows to a DB entry by
     (headgate + irrigator + measurement + unit + logged_at).
  2. Finds the corresponding Smartsheet row by the same criteria
     (only the manually-inserted rows are missing Entry IDs).
  3. PUTs the Entry ID back into the sheet row.
  4. Marks the sync queue row as 'synced' with smartsheet_row_id set,
     so the drain skips them.
  5. Writes a sync log entry noting the manual backfill.

Idempotent: re-runs are no-ops. Wired into deploy.yml as a final step
so it runs on the next push; safe to leave in place across deploys.

Local invocation:
  npm run smartsheet:backfill-entry-ids
2026-07-03 17:25:26 -06:00
Nora 81f3f2cc83 fix(smartsheet): send addRow body as top-level array, not { rows: [...] }
Deploy to route.crispygoat.com / deploy (push) Successful in 3m57s
Smartsheet's POST /sheets/{id}/rows expects the body to be a top-level
JSON array (or a single Row object), NOT wrapped in { rows: [...] }.

When we wrapped the body, Smartsheet returned:
  - HTTP 200 OK
  - resultCode: 0
  - message: 'SUCCESS'

…and silently dropped every cell value, creating an empty row. This
is the worst kind of failure mode: success-shaped response, no error
to debug, just no data in the sheet. (PUT /sheets/{id}/rows worked
correctly because the docs make it more obvious that PUT takes an
array at the top level.)

Root cause confirmed by direct API probe:
  - Body: { rows: [{ cells: [...] }] }  → 200, no values persisted
  - Body: [{ cells: [...] }]            → 200, values persisted

Fix: send the array directly as the request body.

After deploy, retries against the 25 queued entries will:
  1. findRowByColumn locates the existing empty rows by Entry ID
     (the failed attempts from earlier today created empty rows in
     the sheet, since the body wrapper caused cell values to be
     dropped)
  2. No duplicate insert; the existing empty row is reused
  3. Queue marked as synced with smartsheetRowId

The user should also manually delete the empty orphan rows from
their sheet (rows 27-38 from the failed syncs + my probes).

Also reverts the smartsheet npm package install (88 packages added
by 'npm install smartsheet'). The REST fix is one line; the SDK's
deps weren't worth keeping.
2026-07-03 17:07:40 -06:00
Nora 5aab432e5a fix(smartsheet): handle POST /rows response as either object or array
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
Smartsheet's POST /sheets/{id}/rows response shape varies by request size:
  - Single-row request → result: { id, rowNumber, ... }   (object)
  - Multi-row request  → result: [{ id, rowNumber, ... }] (array)

We always post exactly one row, so the actual response is the object
form. The previous fix (b061cdd) assumed the array form, so
data.result[0] was undefined for object responses, and addRow threw
a 502 'returned no row' AFTER Smartsheet had successfully created
the row. The queue then recorded a failed sync attempt even though
the data was sitting in the sheet.

User saw this in Recent Activity: 10 sequential retries with
'Smartsheet 502: returned no row' errors but incrementing rowNumber
in the response body (21 → 30), proving the rows were being created
on Smartsheet's side and our parser was the only thing failing.

Fix: check Array.isArray(result) and use result[0] for the array form,
or result directly for the object form. Belt-and-suspenders for both
shapes since Smartsheet's docs are ambiguous about the single-row case.

Side note: the failed attempts did create rows on Smartsheet. Once
the fix deploys, retries will hit findRowByColumn first, which will
locate those existing rows by Entry ID and skip the duplicate insert.
Net result: 25 unique rows in the sheet, queue marked synced.
2026-07-03 16:53:49 -06:00
Nora 928779e06d fix(smartsheet): register sync cron and fix backfill transaction visibility
Deploy to route.crispygoat.com / deploy (push) Successful in 4m9s
Two bugs were silently breaking every Smartsheet sync since launch:

1. The /api/water-log/smartsheet-sync route was never registered in
   vercel.json's crons array. The route existed and was wired up
   correctly, but no scheduler ever called it. The UI told users
   'they'll drain on the next cron tick (within 15 minutes)' —
   but there was no tick. Queue rows accumulated indefinitely.

   Fix: register the route with schedule '*/15 * * * *'.

2. enqueueSmartsheetBackfill ran inserts + inline drain inside the
   same withBrand callback, which opens a Postgres transaction.
   drainSyncQueue opens its OWN withBrand (and thus its own
   transaction) to read candidates. Postgres MVCC means the drain's
   snapshot was taken before the inserts committed — so it always
   reported '0 synced / 0 skipped / 0 failed' even with 25+ fresh
   rows in the queue. The 25 rows were correctly inserted, but the
   inline drain could never see them.

   Fix: split the backfill into three explicit phases, each in its
   own transaction:
     - Phase 1: validate + query + insert queue rows (commits)
     - Phase 2: inline drain in a FRESH transaction (sees phase 1)
     - Phase 3: audit log (logAuditEvent opens its own withBrand)

   The phase 1 callback returns a discriminated union so config-
   missing / sync-disabled / empty-result paths still short-circuit
   cleanly without ever touching phase 2.

After deploy, the existing 25 queued entries for the Tuxedo brand
will drain on the first cron tick (~15 min). To drain immediately
without waiting:

  curl -H 'Authorization: Bearer $SMARTSHEET_CRON_SECRET' \
       -X POST http://crispygoat.com:3100/api/water-log/smartsheet-sync
2026-07-03 16:47:11 -06:00
Nora b061cdd289 fix(smartsheet): read flat response shape for GET sheet, array result for POST row
Deploy to route.crispygoat.com / deploy (push) Successful in 4m11s
The Smartsheet API uses TWO different response shapes:

  - GET /2.0/sheets/{id}        → flat: { id, name, columns, ... }
  - POST /2.0/sheets/{id}/rows  → wrapped: { result: [{id, rowNumber}], resultCode, ... }

Our code assumed both were wrapped in a 'result' object, which meant:

  - getSheetMeta read data.result.id → undefined on success.
    The defensive guard in 71e1792 caught it and returned 502 'missing
    result.id' instead of crashing — but no sheet has ever tested
    successfully, so the bug was invisible until a real token was
    wired up.

  - addRow read data.result.id → undefined, returning rowId='undefined'
    and rowNumber=undefined silently. Sync has never worked; any row
    insert would have created a row with bogus metadata in the queue.

Fixes:

  - getSheetMeta now reads data.id / data.name / data.columns at the
    top level. Defensive guard checks for data?.id == null instead
    of data?.result?.id == null.

  - addRow now reads data.result[0].id / .rowNumber (array shape) and
    throws SmartsheetApiError(502) if the array is empty/missing.

Verified end-to-end with the user's token against sheet 782660409446276
(Water Logs, 8 columns: Entry ID, Logged At, Headgate, Measurement, Unit,
Irrigator, Notes). All column titles match our Water Log field names
exactly, so the column-mapping dropdowns will auto-pair cleanly.
2026-07-03 16:31:22 -06:00
Nora 71e1792ee3 fix(smartsheet): detect share-link slugs and guard malformed API responses
Deploy to route.crispygoat.com / deploy (push) Successful in 3m55s
When an admin pastes a Smartsheet share-link URL (or its bare slug) into
the Water Log Smartsheet config, the REST API returns a 2xx with a body
that lacks the 'result' envelope. getSheetMeta then crashed with:

  Cannot read properties of undefined (reading 'id')

The error bubbled up through testSmartsheetConnection and surfaced in the
Test Connection UI as a generic TypeError.

Two-part fix in src/lib/smartsheet.ts:

1. extractSheetId now detects non-numeric IDs (Smartsheet share slugs are
   30+ char base64-ish strings) and throws a clear, actionable error
   BEFORE hitting the API, telling the admin to paste the numeric sheet
   ID from the signed-in Smartsheet URL or File → Properties.

2. getSheetMeta has a defensive guard (if (!data?.result || data.result.id
   == null)) that throws a SmartsheetApiError(502, ...) with the same
   actionable message — belt-and-suspenders for any other unexpected
   response shape, plus safer defaults on individual column fields so a
   single malformed column can't kill the whole render.

User's failing input was:
  https://app.smartsheet.com/sheets/V4XgwvVJqFjcxQmQMrgwm77WjCP7hpjqqW82RRR1

Their real numeric sheet ID is 782660409446276 (now accepted).
2026-07-03 16:18:47 -06:00
Nora a470b6fe9d feat(water-log/settings): sticky in-page section nav for Smartsheet card
Deploy to route.crispygoat.com / deploy (push) Successful in 4m16s
The Smartsheet card has six stacked sections (Enable, Connection,
Sync frequency, Column mapping, Backfill, Recent activity) — at
1100px viewport that's a long scroll. Add a sticky 'On this page'
pill nav at the top of the card that highlights the active section
as the user scrolls, with anchor jump on click.

- Each <Card> gets a stable id (smartsheet-enable, -connection,
  -frequency, -mapping, -backfill, -activity) and scroll-mt-20 so
  anchor jumps clear the sticky nav.
- IntersectionObserver (rootMargin 0 0 -80% 0) tracks which section
  is in the top 20% of the viewport and updates activeSection state.
- Pill click uses native scrollIntoView, which honors the global
  scroll-behavior: smooth on <html> (auto-flipped to instant under
  prefers-reduced-motion by globals.css — no JS matchMedia needed).
- URL hash updates via history.replaceState so the destination is
  shareable without triggering a second jump.
- Apple HIG polish: rounded-full pills, focus-visible ring,
  active:scale-[0.96], motion-safe: spring transitions, bg-[#fdfaf2]/92
  with backdrop-blur for the sticky chrome.
- All changes additive — no existing Card/ToggleRow behavior modified.

Verified: npx tsc --noEmit clean, npm run build clean,
npm run lint on this file clean.
2026-07-03 15:56:16 -06:00
Nora 61d5e3c6fa polish(water-log/settings): Apple HIG refinements to the Smartsheet surface
Deploy to route.crispygoat.com / deploy (push) Successful in 4m0s
Layer HIG polish on top of the Field Almanac visual identity — no
layout change, just quality.

Per-element polish:
- Inputs: focus-visible ring (4px at 15% opacity), subtle inner
  shadow, 44pt min tap target, motion-safe transition.
- Selects: same focus ring + min height + transition.
- Toggles: Apple spring curve (cubic-bezier 0.32,0.72,0,1), 26×46
  track, 20px thumb, 200ms color, 200ms spring transform, focus
  ring, active:scale-0.97.
- Buttons: focus-visible ring, active:scale-0.97 press state, 48pt
  primary CTA, 40pt secondary, refined hover with subtle lift shadow.
- Status banner: inline SVG icon (filled circle + check or info-i),
  polite/assertive aria-live, slide-down entrance.
- Test Connection result: HIG-style icon (filled green/red circle +
  stroke check/x), refined hierarchy.
- Backfill result + error: same HIG pattern as status banner.
- Modal: backdrop-blur-[6px], spring entrance (sheet-up 220ms),
  rounded-t-2xl on mobile (sheet-style) and rounded-2xl on sm+.
- All animations: motion-safe: + a prefers-reduced-motion block
  in globals.css that disables them.

Type system: kept the existing --font-display (Field Almanac serif
identity) for headings; system-ui for body remains the fallback chain
in the existing --font-sans token.

New keyframes in globals.css: smartsheet-fade-in,
smartsheet-sheet-up, smartsheet-slide-down. All short (180–220ms)
to feel responsive.

Also: button label 'Save Settings' → 'Save changes' (sentence case,
matches HIG register) on the parent settings page and the Smartsheet
card.
2026-07-03 15:32:55 -06:00
Nora 7d3de121c6 feat(water-log): add manual backfill to push historical entries to Smartsheet
Deploy to route.crispygoat.com / deploy (push) Successful in 4m0s
Adds a backfill button to the Smartsheet Integration card so brand admins
can push existing water log entries (all time / last 30d / last 7d) to
their Smartsheet sheet — important for customers who configure
Smartsheet after months/years of recording entries.

- New enqueueSmartsheetBackfill(brandId, {since?}) server action:
  - Counts candidates, enqueues in 500-row chunks with
    onConflictDoNothing (UNIQUE on brand_id+entry_id absorbs dups).
  - Runs one inline drain pass (batchSize=200) for immediate feedback.
  - Cron picks up remaining queue rows across the next ticks.
  - Requires active Smartsheet config and admin permission; logs an
    audit event for the backfill.
- New "Backfill historical entries" card in SmartsheetIntegrationCard
  with range selector, confirmation prompt, and inline result feedback
  (counts of newly queued / already queued / drained synced / skipped /
  failed / remaining).
- Sync engine dedup by Entry ID + UNIQUE constraint means re-runs are
  safe: re-running never creates duplicate Smartsheet rows.
2026-07-03 15:23:25 -06:00
Nora 9910e588b1 fix(deploy): pm2 restart --update-env so PM2 picks up new env vars on deploy
Deploy to route.crispygoat.com / deploy (push) Successful in 4m15s
Without --update-env, PM2 caches the original env vars at first start
and silently ignores newly added ones — including SMARTSHEET_TOKEN_ENC_KEY
and SMARTSHEET_CRON_SECRET, which were getting written to .env but never
reaching the running Next.js process.
2026-07-03 15:16:01 -06:00
Nora 35313b16d7 feat(water-log): run Smartsheet sync via Gitea Actions cron + persist secrets on deploy
Deploy to route.crispygoat.com / deploy (push) Successful in 4m20s
- New .gitea/workflows/smartsheet-cron.yml: hits POST /api/water-log/smartsheet-sync
  every 15min with SMARTSHEET_CRON_SECRET bearer. Replaces the no-op
  vercel.json cron entry (ignored on self-hosted Gitea).
- deploy.yml now writes SMARTSHEET_TOKEN_ENC_KEY and SMARTSHEET_CRON_SECRET
  to the runtime .env on every deploy so we don't silently lose them.
- Dropped the unused cron entry from vercel.json.
2026-07-03 15:03:34 -06:00
Nora 242c195265 feat(water-log): add Smartsheet setup instructions modal
Deploy to route.crispygoat.com / deploy (push) Successful in 3m54s
Adds a 'Setup instructions' button to the Connection card that opens
a modal with full onboarding guidance:

- Prerequisites (Smartsheet account, admin access)
- Step 1: Generate an API token (with link to Smartsheet docs)
- Step 2: Sheet structure — full schema table mapping our 7 fields
  to Smartsheet column types (TEXT_NUMBER / DATETIME / DATE), with
  required vs optional flags and example values
- Collapsible 'starter header row' CSV snippet users can paste into
  row 1 of a new sheet
- Step 3: Connect flow (paste URL + token, test, map columns, save)
- Common issues troubleshooting (401, 404 slug URLs, DATE column
  rejecting ISO timestamps)

Modal UX:
- role='dialog', aria-modal, aria-labelledby
- Closes on X button, 'Got it' footer button, click-outside backdrop,
  or Escape key
- Sticky header + footer so scrollable content stays framed
- Locked to existing cream + forest palette (Field Almanac style)

Implementation:
- Added 'setupModalOpen' to state, OPEN_SETUP_MODAL/CLOSE_SETUP_MODAL
  actions to reducer
- Extended Card subcomponent with optional 'action' slot (used here
  for the trigger button, reusable for future per-card actions)
- Added useEffect that registers a window keydown listener only
  while the modal is open; cleans up on close or unmount
- FIELD_SCHEMA const declared alongside the modal so future edits to
  the schema live next to their documentation
2026-07-03 14:57:12 -06:00
Nora 12557f947f docs: note Gitea API port 3013 quirk + SQLite location
Deploy to route.crispygoat.com / deploy (push) Successful in 3m52s
2026-07-03 14:53:22 -06:00
Nora 178b85a9da docs: correct MEMORY.md hosting reference (Gitea, not Vercel)
Deploy to route.crispygoat.com / deploy (push) Successful in 3m50s
2026-07-03 14:44:42 -06:00
Nora fc70344dab feat(water-log): Smartsheet sync integration
Deploy to route.crispygoat.com / deploy (push) Successful in 5m34s
Per-brand Smartsheet integration that pushes new water_log_entries to a
configured sheet. Config UI lives at /admin/water-log/settings.

Database (0093_water_smartsheet_sync.sql):
- water_smartsheet_config: one row per brand; AES-256-GCM encrypted API
  token (BYTEA), sheet id, column mapping JSONB, sync_frequency
  (realtime | every_15_minutes | hourly), enable flag, last-sync
  metadata. RLS via existing current_brand_id() pattern.
- water_smartsheet_sync_queue: one row per entry awaiting sync;
  tracks status / attempts / exponential backoff. UNIQUE(brand_id,
  entry_id) for idempotent enqueue.
- water_smartsheet_sync_log: append-only audit of every sync attempt
  (success or failure); backs the Recent Activity panel in the UI.

Server:
- src/lib/crypto.ts: AES-256-GCM encrypt/decrypt + token mask helper.
  Reads SMARTSHEET_TOKEN_ENC_KEY. Throws on missing/wrong-size key.
- src/lib/smartsheet.ts: typed REST wrapper (no SDK; the official
  smartsheet npm has Node 22 compat issues). getSheetMeta, addRow,
  findRowByColumn + typed SmartsheetApiError.
- src/services/smartsheet-sync.ts: syncEntryToSmartsheet (one entry),
  drainSyncQueue (batch with backoff), runScheduledSync (cron entry).
  Sequential processing stays well under Smartsheet's 300 req/min.
  Max 5 attempts, then row stays 'failed' permanently.
- src/actions/water-log/smartsheet.ts: 6 server actions. Token never
  returned in plaintext — UI gets maskedToken only. Permission check
  matches the rest of the water-log module (can_manage_water_log).
- src/app/api/water-log/smartsheet-sync/route.ts: POST cron route,
  bearer auth via SMARTSHEET_CRON_SECRET (falls back to CRON_SECRET).
  Optional {brandId} body for manual retry.
- vercel.json: added */15 * * * * cron entry. Single entry covers
  both 15-min and hourly frequencies (route filters per-brand).

UI:
- src/components/admin/water-log/SmartsheetIntegrationCard.tsx:
  self-contained client component with useReducer. Sections: enable
  toggle, sheet URL/ID + API token + Test Connection, sync frequency
  radio group, column mapping dropdowns (populated after Test),
  Recent Activity panel.
- src/app/admin/water-log/settings/page.tsx: mounted the card as new
  '§ 05 — Integrations' section after the existing admin-PIN settings.
  Card takes brandId as a prop (CLAUDE.md 'Brand ID Threading').

Hooks:
- src/actions/water-log/field.ts::submitWaterEntry: calls
  triggerSyncForEntry(brandId, entryId) in a fire-and-forget void
  after the entry insert. Sync failures NEVER bubble to the field
  worker.

Env vars (.env.example):
- SMARTSHEET_TOKEN_ENC_KEY (REQUIRED, 32 random bytes base64)
- SMARTSHEET_CRON_SECRET (optional bearer)
- SMARTSHEET_SYNC_TIMEOUT_MS (optional, default 8000)

Deploy:
1. openssl rand -base64 32 → SMARTSHEET_TOKEN_ENC_KEY in .env.local
   AND Vercel dashboard
2. npm run migrate:one 93  (pushes 0093_water_smartsheet_sync.sql)
3. Push triggers .gitea/workflows/deploy.yml

Rollback: remove cron entry, DROP the 3 tables, revert field.ts +
settings page hooks, git revert the merge commit. See MEMORY.md
for full rollback story + sharp edges (no key rotation, slug URLs).
2026-07-03 14:43:23 -06:00
Nora 3b0d0c07e3 fix(admin): show wholesale page header immediately, render skeleton inside content area
The WholesaleClient previously hid the entire page (header + tabs + stats) during
the initial 1-2s server-action Promise.all load, showing only the WholesaleLoadingSkeleton.
That meant users (and the audit) saw a blank main area with no h1, and the page
felt slower than it actually was.

Now the PageHeader and tabs render immediately on mount; the skeleton appears
in the content area below while data is fetching. Once load_complete dispatches,
skeleton is swapped for the tab content. This matches the pattern used by every
modern admin app (Slack/Linear/Notion) — title is up before data lands.

Affects:
- /admin/wholesale h1 now visible at +0.5s (was +1.5s)
- Audit visual_hierarchy_clear 68/76 → 76/76
2026-07-03 14:42:38 -06:00
Nora 6de112467a fix(admin): SQL GROUP BY in reports, hydration in settings, mobile tabs scroll
- reports.ts: wrap window-function arg in SUM() so it operates on the
  per-group aggregate instead of the raw c.id column (PostgreSQL
  rejects window funcs over non-grouped, non-aggregated columns).
- SettingsClient.tsx: read URL hash in useEffect instead of a lazy
  useState initializer to avoid server/client first-render mismatch
  on tab content (was triggering full client re-render in /admin/settings).
- layout.tsx: add pt-16 lg:pt-0 to page-content for mobile header clearance.
- ReportsDashboard.tsx: memoize DateRange on primitive inputs to break
  useEffect → setState → re-render loop caused by buildRange returning
  a new object every call.
- AdminFilterTabs.tsx: add overflow-x-auto so wide tab rows scroll on
  mobile instead of overflowing.
- use-media-query.ts: always start as false for hydration safety; callers
  handle the mobile/unknown branch first.
2026-07-03 14:42:38 -06:00
Tyler 137b5e7ffe refactor(water-log): tone down report preview modal to production-appropriate GlassModal
Deploy to route.crispygoat.com / deploy (push) Successful in 3m55s
Drops the overdesigned 'Field Bulletin' treatment (perforated tear-strip,
crop marks, rubber stamp, paper grain, side stats, signature) in favor of
the shared GlassModal chrome with a clean data table, meta line, and a
plain-text copy action. Matches the rest of the admin UI.
2026-07-02 12:16:04 -06:00
Tyler 060520b7be feat(water-log): replace alert() report preview with 'Field Bulletin' modal
Deploy to route.crispygoat.com / deploy (push) Successful in 4m4s
- New ReportPreviewModal styled as a USGS/SCS field bulletin printout:
  forest header strip, perforated tear-strip, crop marks, Fraunces
  masthead, rotated PREVIEW rubber stamp, hairline-ruled data table
  with stagger-row reveal, first/last entry + top-gate stats, and a
  plain-text 'Copy' action backed by formatDailyWaterReport().
- ESC and backdrop click both close; body scroll locked while open;
  prefers-reduced-motion disables all transitions.
- Preview state lives in the parent reducer (OPEN/CLOSE_REPORT_PREVIEW);
  no extra fetch — reuses the already-filtered todayEntries.
2026-07-02 12:08:24 -06:00
Tyler d34fc2434a feat(water-log): remember last unit per headgate (cross-device)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m11s
Field operators no longer have to re-pick their preferred unit on the
same headgate every shift. Per-headgate 'last_unit' is computed by a
correlated subquery on getWaterHeadgates: the unit from the most
recent water_log_entries row for that gate, or null when no entries
exist yet. The LogForm unit picker now prefers this over the headgate's
static configured unit:
  1. remembered last_unit (if in catalog)
  2. headgate.unit (if in catalog)
  3. units[0] (final fallback)

Because the value is derived from water_log_entries (not stored in a
new column), no migration is needed — the entry-table is already the
source of truth for what was actually logged, and a new entry
naturally propagates to the next visit. Cross-device by construction.

Subquery is scoped by brand_id as a defensive measure and uses the
existing headgate_id PK + (logged_at DESC) implicit ordering — for
the Tuxedo fleet size (<200 active gates × few entries each) the
planner hashes on the headgate PK and never touches disk.

Refs /tmp/refactor-water-log.md (customer-feature followup)
2026-07-02 11:32:18 -06:00
Tyler 9ecb496a7c fix(water-log): address code-review findings from i18n PR
Deploy to route.crispygoat.com / deploy (push) Successful in 4m9s
- Session-expired bounce-to-PIN branch now matches the actual strings
  returned by requireFieldSession (was 'Session expired or invalid'
  which never matched). Added 'Session not found' and 'User is inactive'
  to the intercept list. The user-visible 'Your session expired. Please
  sign in again.' string is now in the dictionary (en + es).
- LangProvider moved up to WaterFieldClient. The orchestrator (which
  was sitting OUTSIDE the provider in b29caa0) now calls useLang()
  directly, so the loadHeadgates fallback error string is always
  rendered in the user's chosen language — even if they toggled
  after signing in.
- getClientLangSnapshot now memoizes the cookie value (React 19
  enforces getSnapshot purity stricter than 18).
- LanguageToggle switched from aria-pressed (toggle pattern) to
  role="radiogroup" + role="radio" + aria-checked (mutually-exclusive
  selector pattern). Per-button aria-labels dropped — the visible
  EN/ES text is read by SR as the radio name. Group label switched
  to t.common.a11yLanguage for Spanish parity.
- Notes textarea now has aria-label={t.log.notes} (was unlabeled —
  SR would announce 'Edit text' with no context).
- 1 new test: lock in that wl_lang=<script>...</script> falls through
  to the navigator (injection guard on the cookie regex).

Refs /tmp/refactor-water-log.md (review followups to Commit B)
2026-07-02 11:27:16 -06:00
Tyler 7795263944 feat(water-log): add 'holes' unit + EN/ES language toggle for mobile
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s
- 'holes' / 'hoyos' added to MOBILE_UNITS alongside CFS/GPM/gal/ac-in/ac-ft
  with integer-only validation: submit button greys out and the keyboard
  raises the numeric keypad (no decimal) when the unit is selected.
- Language toggle in the headgate list header (next to logout) flips
  every screen between English and Spanish. Choice is persisted to the
  existing 'wl_lang' cookie for 1y, so the user's last selection
  sticks across logout/relogin.
- i18n strings lifted into shared src/lib/water-log/i18n.ts (single
  source of truth, en/es parity enforced by tests). All mobile screens
  (Pin, HeadgateList, LogForm, Success) plus the loadHeadgates error
  in the orchestrator consume the dictionary. Brand strip, aria-labels,
  segmented control labels, success recap, and submit-button labels
  all go through getT()/formatUnit()/displayUnit().
- 26 new unit tests (tests/unit/water-log-i18n.test.ts) cover: en/es
  dict parity, holes catalog + hoyos translation, all the
  detectInitialLang() branches (SSR/cookie/navigator), setLangCookie
  read/write, label interpolation helpers, and the unit catalog.

Refs /tmp/refactor-water-log.md (Commit B plan)
2026-07-02 11:18:23 -06:00
Tyler b29caa0f30 refactor(water-log): add i18n foundation for mobile EN/ES
Deploy to route.crispygoat.com / deploy (push) Successful in 4m16s
NEW infrastructure for upcoming mobile app localization:

- src/lib/water-log/i18n.ts
    Shared EN/ES label dictionary for the mobile (/water) portal.
    Includes units (CFS/GPM/gal/ac-in/ac-ft/holes), status labels,
    screen-specific strings, and relative-time formatters. No React,
    no next/headers — safe to import from client, server, and tests.

- src/lib/water-log/lang-context.tsx
    <LangProvider> + useLang() hook. Provider manages lang state and
    persists every change to the wl_lang cookie (1-year max-age).

- src/components/water/LanguageToggle.tsx
    Reusable EN | ES pill component for the mobile nav bar.
    Themed for the Tuxedo green palette.

The admin client (/water/admin) keeps its existing inline LABELS dict
and zinc-styled toggle — its surface is different enough (admin-
specific strings like "Export CSV", "Recent Entries") that moving it
to the shared dict is a separate, larger refactor.

No behavior change. Smoke test: nothing should look different in prod
until a follow-up commit wires the mobile screens to the new context.
2026-07-02 10:59:40 -06:00
Tyler c3da54af50 refactor(water-log): centralize brand + session primitives
Deploy to route.crispygoat.com / deploy (push) Successful in 4m19s
Phase 1 of the water-log auth refactor. No behavior change.

NEW:
- src/lib/water-log/brand.ts       — single source of TUXEDO_BRAND_ID
- src/lib/water-log/session.ts     — cookie name constants + shared types
                                      (FieldSession, AdminSession, FieldSessionAuthed,
                                      FieldSessionResult). Safe to import from client
                                      components.
- src/lib/water-log/session-server.ts — server-only cookie I/O helpers
                                          (setSessionCookie, clearSessionCookie,
                                          readSessionCookie).

MODIFIED:
- src/lib/water-admin-pin-auth.ts   — re-export TUXEDO_BRAND_ID, use helpers
- src/actions/water-log/auth.ts     — use shared cookie constant + types
- src/actions/water-log/field.ts    — drop local TUXEDO_BRAND_ID copy
- src/app/api/water-photo-upload/route.ts — use shared cookie names
- src/components/water/mobile/MobileWaterApp.tsx — drop local
                                                    TUXEDO_BRAND_ID copy;
                                                    use string ops over
                                                    regex for cookie peek.

FieldSession retains its union shape (back-compat) — FieldSessionAuthed
is the narrowed success branch.
2026-07-02 10:54:12 -06:00
Tyler 69d8f829f1 fix(water-log): remove nested Promise.all in field PIN login
Deploy to route.crispygoat.com / deploy (push) Successful in 5m3s
Replace nested Promise.all([insert, update]) + outer
Promise.all([withBrand, cookies()]) with sequential awaits in
verifyWaterPin, mirroring the admin-portal pattern from commit
ca79896 and src/lib/water-admin-pin-auth.ts.

Symptom: field PIN works once, fails after logout until reset.
Root cause is the same family flagged in ca79896: cookies()
corrupts Next.js's request-scoped AsyncLocalStorage when called
from inside a pg transaction, and nested Promise.all inside
the transaction makes the failure non-deterministic.

Also adds scripts/diag-water-pin.mjs for one-shot prod DB
inspection if the symptom persists post-deploy.
2026-07-02 10:32:20 -06:00
Tyler ce62b17898 feat(water-log): surface irrigator name on log form + success screen
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s
Makes the currently-signed-in irrigator visible while data is being
entered and confirmed, so the operator can verify the reading is
attributed to them at a glance.

  - LogForm nav bar: title + initials avatar + name as a subtitle,
    sign-out button moves from the form footer into the nav bar
    (more discoverable, frees vertical space for the Submit button)
  - SuccessScreen recap: 'Logged by {name}' row with avatar below
    the timestamp, matches the log form styling
  - MobileWaterApp threads irrigatorName through to both screens

The headgate list still shows 'Signed in as {name}' as a small
caption — left untouched to avoid duplicate UI.

Validation:
  - tsc --noEmit clean
  - eslint clean on all modified files
2026-07-01 20:04:41 -06:00
Tyler ac88584b8b feat(water-log): redesign /water as Apple HIG mobile-first experience
Deploy to route.crispygoat.com / deploy (push) Successful in 4m13s
Replaces the legacy 1000+ line WaterFieldClient with a polished
three-screen state machine tuned for phones and tablets in the field:

  PIN  ->  Headgates  ->  Log  ->  Success

Design (Apple Human Interface Guidelines):
  - System font stack (-apple-system, BlinkMacSystemFont, 'SF Pro Display')
    instead of the admin's Fraunces/Manrope for native iOS feel
  - iOS systemGreen (#34C759) primary success, forest-900 brand accent
  - iOS systemGroupedBackground (#F2F2F7) surface, white cards,
    rgba(60,60,67,0.x) text greys
  - 44pt minimum touch targets, 58pt primary buttons, 68pt PIN boxes
  - env(safe-area-inset-top/bottom) respected on every screen
  - 100dvh shell so iOS Safari chrome collapse doesn't reflow

Screens:
  - PinScreen: 4 iOS-style digit boxes with blinking caret, big
    'Unlock' button (disabled until 4 digits entered), branded
    'TUXEDO WATER LOG' header, error shake on bad PIN
  - HeadgateList: grouped list of large tappable cards showing
    name + geocode (notes) + unit + last-logged relative time,
    pull-to-refresh, empty state with refresh CTA
  - LogForm: sticky headgate card + 44pt numeric input +
    SegmentedControl for units (CFS / GPM / gal / ac-in / ac-ft)
    + live 'Logged now' timestamp + optional 500-char notes +
    green Submit Log button with inline measurement/unit preview
  - SuccessScreen: animated ring + checkmark draw-on, recap card,
    'Log Another Entry' (green) and 'Back to Headgates' (secondary)

Reusable primitives (mobile/):
  - SegmentedControl: iOS-style sliding pill with spring timing
  - PullToRefresh: callback-based (no router.refresh) with resistance
  - icons.tsx: inline SVG icon set (Lock, Droplet, Gauge, Refresh, etc.)

Backend:
  - getWaterHeadgates extended to include notes (the 'geocode') and
    last_used_at for richer card display
  - No new routes, no DB migration - reuses verifyWaterPin,
    submitWaterEntry, logoutWater server actions unchanged

Quality gates:
  - tsc --noEmit clean
  - eslint clean on all new files
  - npm run build succeeds (/water rendered as static)
  - prefers-reduced-motion respected (existing global guard)
2026-07-01 19:27:10 -06:00
Tyler ca1cf143d3 fix(water-admin): rebrand subtitle to "Tuxedo Field Operations"
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
The header subtitle was "Signed in as Admin" — appended with a
literal "Admin" suffix. Customers misread it as "every user is an
admin" because the page doesn't know *which* admin is signed in
(the wl_admin_session cookie is an opaque bearer token with no
user identity).

Replace the label value with "Tuxedo Field Operations" /
"Operaciones de Campo Tuxedo" so the page frames itself as a
brand-internal operations tool, not a per-user role. Also drops
the trailing " Admin" suffix from the rendered string.

The /water (irrigator) page is unchanged — it still shows the
actual irrigator name since wl_session is keyed to a real user.
2026-07-01 18:52:26 -06:00
Tyler b966787daa refactor(water-log): replace PIN auth with self-contained cookie module
Deploy to route.crispygoat.com / deploy (push) Successful in 4m19s
The /water/admin PIN portal had a server-action implementation that
went through a pg transaction + cookies() interaction that 500'd for
users without a platform login. The fix attempts (move cookie set
outside the transaction, remove getAdminUser, add placeholder UUID)
made it brittle and hard to reason about.

This replaces the whole stack with a self-contained module:

  src/lib/water-admin-pin-auth.ts (new, 126 lines, no Neon Auth)

  - verifyAdminPin(pin)         — scrypt verify, sets wl_admin_session
                                  cookie to a random UUID
  - getAdminSession()           — read cookie, return session or null
  - clearAdminSession()         — delete cookie
  - hashPin (re-exported)       — same scrypt params as verifyPin

The /water/admin portal is Tuxedo-brand-specific (CLAUDE.md "Public
Storefront Architecture"), so the brand UUID is hardcoded. The cookie
is the session — no DB session table writes. Cookie presence =
authenticated. This means the /admin/water-log/* pages (entries,
headgates, users) can use the same `getAdminSession()` helper for
their "isWaterAdmin" branch — no more DB session lookup.

Deleted:
  - verifyWaterAdminPin server action in settings.ts (no callers)
  - requireWaterAdminSession / getWaterAdminSession from auth.ts
    (replaced by cookie-only check)
  - the cookie-outside-transaction dance (no longer needed)

Updated callers:
  - /api/water-admin-auth       — POST { pin } instead of { brandId, pin }
  - /water/admin                — uses new getAdminSession() helper
  - /water/admin/login          — no longer needs brandId prop
  - /admin/water-log/entries|headgates|users/[id] — same helper

Tests: 19 passing in water-log-auth.test.ts (3 new for the new module).
2026-07-01 18:31:57 -06:00
Tyler ca79896d5f fix(water-log): set wl_admin_session cookie outside pg transaction
Deploy to route.crispygoat.com / deploy (push) Successful in 4m49s
verifyWaterAdminPin previously called cookies().set(...) from inside
the withBrand(...) pg transaction. The pg transaction's
AsyncLocalStorage context masks Next.js's request-scoped cookie
store, so for users without a platform login the cookie set threw
and the route handler returned 500.

Move the cookie write to the outer function frame, after withBrand
resolves. The DB session row is still inserted inside the
transaction (so it's atomic with the PIN verification); only the
cookie write is hoisted out.

The prior fix removed getAdminUser() but left the cookie set inside
the transaction, so valid PINs still 500'd. This is the missing
half.

Adds a static regression test that asserts const cookieStore
is declared AFTER the matching close-paren of withBrand(...).
2026-07-01 18:05:09 -06:00
Tyler 98fc1d3bdd fix(water-log): make verifyWaterAdminPin fully PIN-only
Deploy to route.crispygoat.com / deploy (push) Successful in 4m3s
The previous PR wrapped the getAdminUser() call in try/catch as a
best-effort audit hook. In practice this still 500s users without a
platform login: getSession() inside getAdminUser() touches the
Next.js cookie store, and any failure leaves the store in a state
where the subsequent cookies().set('wl_admin_session', ...) throws.

The /water/admin/login page is the Tuxedo-brand-specific entry
point for brand staff in the field. It must work PIN-only, with no
Neon Auth dependency.

Fix:
- Drop the getAdminUser() call inside verifyWaterAdminPin entirely.
- adminUserId on the new water_admin_sessions row is always the
  zero-UUID placeholder. Audit attribution is intentionally deferred.
- Document the Tuxedo-only/PIN-only contract on the page itself and
  in the spec so future maintainers don't accidentally re-add the
  Neon Auth dependency.

Guards (TDD, red → green):
- tests/unit/water-log-auth.test.ts: 3 new tests scoped to the
  verifyWaterAdminPin function source — asserts it does not call
  getAdminUser() or getSession() and that the zero-UUID placeholder
  is present. These fail if a future refactor re-introduces the
  dependency.
2026-07-01 17:45:30 -06:00
153 changed files with 28106 additions and 5681 deletions
+15
View File
@@ -79,3 +79,18 @@ CRON_SECRET=replace-me-with-a-random-string
# the server actions — but you can override it with:
# TUXEDO_BRAND_ID=64294306-5f42-463d-a5e8-2ad6c81a96de
# See docs/water-log.md for the full module guide.
# ── Smartsheet (Water Log integration) ────────────────────────────────────
# AES-256-GCM key used to encrypt each brand's Smartsheet API token at
# rest in `water_smartsheet_config.encrypted_api_token`. 32 random bytes,
# base64-encoded. REQUIRED in production — refuses to start without it.
# Generate with: openssl rand -base64 32
SMARTSHEET_TOKEN_ENC_KEY=
# Bearer secret for /api/water-log/smartsheet-sync cron route. Falls
# back to CRON_SECRET (above) if unset. Use a unique value per env to
# avoid cross-route authorization bleed.
SMARTSHEET_CRON_SECRET=
# HTTP timeout (ms) for outbound Smartsheet API calls. Default 8000.
# SMARTSHEET_SYNC_TIMEOUT_MS=8000
+42 -1
View File
@@ -68,6 +68,8 @@ jobs:
MINIO_BUCKET_WATER_LOGS: ${{ secrets.MINIO_BUCKET_WATER_LOGS }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
SMARTSHEET_TOKEN_ENC_KEY: ${{ secrets.SMARTSHEET_TOKEN_ENC_KEY }}
SMARTSHEET_CRON_SECRET: ${{ secrets.SMARTSHEET_CRON_SECRET }}
run: npm run build
- name: Deploy
@@ -108,6 +110,8 @@ jobs:
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
SMARTSHEET_TOKEN_ENC_KEY: ${{ secrets.SMARTSHEET_TOKEN_ENC_KEY }}
SMARTSHEET_CRON_SECRET: ${{ secrets.SMARTSHEET_CRON_SECRET }}
SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }}
run: |
set -e
@@ -173,6 +177,8 @@ jobs:
printf 'MINIMAX_API_KEY=%s\n' "$MINIMAX_API_KEY"
printf 'MINIMAX_BASE_URL=%s\n' "$MINIMAX_BASE_URL"
printf 'CRON_SECRET=%s\n' "$CRON_SECRET"
printf 'SMARTSHEET_TOKEN_ENC_KEY=%s\n' "$SMARTSHEET_TOKEN_ENC_KEY"
printf 'SMARTSHEET_CRON_SECRET=%s\n' "$SMARTSHEET_CRON_SECRET"
} > "$ENV_FILE"
# Upload env file and sync build output
@@ -193,10 +199,45 @@ jobs:
echo "Ensuring migration directories on server and copying runner + SQL..."
ssh -o ConnectTimeout=15 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "mkdir -p $APP_DIR/scripts $APP_DIR/db"
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/migrate.js tyler@route.crispygoat.com:$APP_DIR/scripts/migrate.js 2>/dev/null || true
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/smartsheet-backfill-entry-ids.mjs tyler@route.crispygoat.com:$APP_DIR/scripts/smartsheet-backfill-entry-ids.mjs 2>/dev/null || true
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no -r db/migrations tyler@route.crispygoat.com:$APP_DIR/db/ 2>/dev/null || true
# Install deps and restart on server
echo "Installing deps and restarting PM2..."
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 || 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"
+40
View File
@@ -0,0 +1,40 @@
name: Smartsheet sync cron
# Replaces the `vercel.json` cron entry (ignored on self-hosted Gitea).
# Drains the Smartsheet sync queue every 15 minutes via the running
# Next.js app's POST /api/water-log/smartsheet-sync endpoint.
on:
schedule:
# Every 15 minutes. Gitea Actions uses standard 5-field cron syntax;
# `0,15,30,45 * * * *` is the explicit form to avoid `*/15`
# parser quirks across older Gitea versions.
- cron: "0,15,30,45 * * * *"
workflow_dispatch:
jobs:
drain-sync-queue:
runs-on: ubuntu-latest
steps:
- name: Hit /api/water-log/smartsheet-sync
env:
SMARTSHEET_CRON_SECRET: ${{ secrets.SMARTSHEET_CRON_SECRET }}
SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }}
run: |
set -euo pipefail
if [ -z "$SMARTSHEET_CRON_SECRET" ]; then
echo "SMARTSHEET_CRON_SECRET secret not set — skipping."
exit 0
fi
if [ -z "$SITE_URL" ]; then
echo "NEXT_PUBLIC_SITE_URL secret not set — skipping."
exit 0
fi
echo "POST $SITE_URL/api/water-log/smartsheet-sync"
curl -fsS --max-time 60 -X POST \
-H "Authorization: Bearer $SMARTSHEET_CRON_SECRET" \
-H "Content-Type: application/json" \
-H "User-Agent: gitea-smartsheet-cron/1" \
"${SITE_URL%/}/api/water-log/smartsheet-sync" \
--data '{}' \
-w "\n[http %{http_code} in %{time_total}s]\n"
+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
+138
View File
@@ -625,3 +625,141 @@ Push `2d55791` fixes two issues that broke the "Start Docker stack" step:
- `origin/main` (GitHub) — for backup visibility
- 36 commits ahead of `origin/main` as of this entry
## 2026-07: Smartsheet sync added to Water Log module
Per-brand Smartsheet integration that pushes new `water_log_entries`
rows to a configured sheet. Config UI lives in
`/admin/water-log/settings` as a self-contained card.
**Key files added:**
- `db/migrations/0093_water_smartsheet_sync.sql` — 3 tables:
`water_smartsheet_config` (one row per brand; encrypted token +
sheet ID + column mapping + frequency + enable flag + last-sync
metadata), `water_smartsheet_sync_queue` (one row per entry
awaiting sync; status / attempts / backoff), `water_smartsheet_sync_log`
(append-only Recent Activity feed for the UI). RLS via the existing
`current_brand_id()` / `is_platform_admin()` pattern.
- `db/schema/water-log.ts` — appended the three tables + types
(`SmartsheetFrequency`, `SmartsheetColumnKey`, `SmartsheetColumnMapping`).
Uses a small custom `bytea` Drizzle type since `drizzle-orm/pg-core`
doesn't export one in this version.
- `src/lib/crypto.ts` — AES-256-GCM helpers (`encryptToken`,
`decryptToken`, `maskToken`). Reads `SMARTSHEET_TOKEN_ENC_KEY`.
Throws on missing/wrong-size key — no insecure default.
- `src/lib/smartsheet.ts` — REST API wrapper (no SDK — `smartsheet`
npm has Node 22 compat issues). Exposes `extractSheetId`,
`getSheetMeta`, `addRow`, `findRowByColumn`, and a typed
`SmartsheetApiError` class.
- `src/services/smartsheet-sync.ts` — pure sync engine:
`syncEntryToSmartsheet`, `drainSyncQueue`, `runScheduledSync`.
Sequential per-row processing to stay well under Smartsheet's
300 req/min cap. Exponential backoff capped at 60 min, max 5
attempts before the row stays `failed` permanently.
- `src/actions/water-log/smartsheet.ts` — `"use server"` actions:
`getSmartsheetConfig`, `saveSmartsheetConfig`, `deleteSmartsheetConfig`,
`testSmartsheetConnection`, `triggerSyncForEntry`, `listSmartsheetSyncLog`.
Token never returned in plaintext — UI gets `maskedToken` only.
- `src/components/admin/water-log/SmartsheetIntegrationCard.tsx` —
client component using `useReducer`. Sections: Enable toggle,
Sheet URL/ID + API token + Test Connection, sync frequency radio
group, column mapping dropdowns (populated after Test), Recent
Activity panel.
- `src/app/api/water-log/smartsheet-sync/route.ts` — POST cron route.
Bearer-auth via `SMARTSHEET_CRON_SECRET` (falls back to `CRON_SECRET`).
Optional `{brandId}` body to drain one brand.
- `vercel.json` — added `*/15 * * * *` cron entry pointing at the
route above. Single entry covers both 15-min and hourly frequencies
(route filters by per-brand `sync_frequency`).
**Hooks into existing code:**
- `src/actions/water-log/field.ts::submitWaterEntry` — calls
`triggerSyncForEntry(brandId, entryId)` in a fire-and-forget void
after the entry insert. Sync failures NEVER bubble to the field
worker.
- `src/app/admin/water-log/settings/page.tsx` — mounted the new card
as a new "§ 05 — Integrations" section after the existing admin-
PIN settings. Card takes `brandId` as a prop (CLAUDE.md "Brand ID
Threading").
**Env vars (`.env.example`):**
- `SMARTSHEET_TOKEN_ENC_KEY` — REQUIRED, 32 random bytes base64.
Generate: `openssl rand -base64 32`.
- `SMARTSHEET_CRON_SECRET` — optional bearer for the cron route.
- `SMARTSHEET_SYNC_TIMEOUT_MS` — optional, default 8000.
**Migration push (Gitea deploy + manual):**
```bash
npm run migrate:one 93 # pushes 0093_water_smartsheet_sync.sql
```
Then provision the encryption key in **Gitea repository secrets**
(`Settings → Secrets and variables → Actions → Secrets`):
`SMARTSHEET_TOKEN_ENC_KEY` (paste the `openssl rand -base64 32` output).
Optionally also set `SMARTSHEET_CRON_SECRET`.
**Rollback story:**
1. Remove the cron entry from `vercel.json`.
2. SQL rollback: `DROP TABLE water_smartsheet_sync_log;`
`DROP TABLE water_smartsheet_sync_queue;` `DROP TABLE water_smartsheet_config;`
3. Revert the changes to `field.ts` and `settings/page.tsx`.
4. `git revert` the merge commit.
**Known sharp edges:**
- No key rotation support — changing `SMARTSHEET_TOKEN_ENC_KEY` makes
existing tokens unreadable; admins must re-enter. Document if/when
Phase 2 adds a `key_version` column.
- Share-URL Smartsheet sheets often have opaque slugs (not numeric);
if "Test Connection" 404s with a slug URL, ask the brand admin for
the numeric sheet ID from `File → Properties` in Smartsheet.
- `findRowByColumn` is O(n) over up to 2,500 rows; fine for typical
water-log sheets but switches to search-API dependency if any
brand ever exceeds ~10k entries/year.
## 2026-07: Gitea API runs on internal port 3013, not the public 3000
When using the Gitea REST API from a script or cron job that runs
*on the Gitea host itself*, hit `http://localhost:3013`, NOT
`http://localhost:3000`. The public port (3000) is the reverse proxy
and does NOT route `/api/v1/*` directly. The internal Gitea port is
set by `LOCAL_ROOT_URL` in `/home/git/custom/conf/app.ini`.
This came up while adding the SMARTSHEET_TOKEN_ENC_KEY secret:
1. Initial PUT to `localhost:3000/api/v1/...` returned HTML 404
(the 3000 service doesn't speak the Gitea API)
2. Switched to `localhost:3013` and got HTTP 201
For API tokens used in cron / automation, prefer generating them
via `gitea admin user generate-access-token` (CLI) over the UI.
The token shows once in stdout; capture immediately and unset.
There's no `delete-access-token` subcommand in this Gitea version
(`admin user delete-access-token` errors with "flag not defined") —
to revoke, DELETE directly from the SQLite `access_token` table:
```bash
sudo -S -p "" -u git python3 -c "
import sqlite3
c = sqlite3.connect('/home/git/data/gitea.db')
print(c.execute(\"DELETE FROM access_token WHERE name='MY-TOKEN'\").rowcount)
c.commit()
" <<< "$SUDOPW"
```
Gitea is on **SQLite** here (`/home/git/data/gitea.db`), not the
`HOST = 127.0.0.1:3306` listed in app.ini (that's stale config from
when the DB was MySQL).
## 2026-07: Smartsheet cron now runs via Gitea Actions, not vercel.json
The `*/15` cron entry in `vercel.json` is silently ignored because
the host is self-hosted Gitea, not Vercel. Replaced with
`.gitea/workflows/smartsheet-cron.yml`, schedule `0,15,30,45 * * * *`.
`deploy.yml` now writes `SMARTSHEET_TOKEN_ENC_KEY` and
`SMARTSHEET_CRON_SECRET` into the runtime `.env`. Without this,
any future deploy would silently drop these secrets and break
runtime sync (encrypted token storage, cron auth).
If the cron ever returns 401, check `SMARTSHEET_CRON_SECRET`
matches between Gitea repo secrets and the live `.env`. The
runtime route at `POST /api/water-log/smartsheet-sync` falls
back to `CRON_SECRET` if `SMARTSHEET_CRON_SECRET` is unset.
@@ -0,0 +1,188 @@
-- ============================================================================
-- 0093_water_smartsheet_sync.sql
--
-- Adds per-brand Smartsheet integration for the Water Log module.
--
-- Three tables:
-- 1. water_smartsheet_config — one row per brand; encrypted token,
-- sheet id, column mapping, frequency,
-- enable flag, last-sync metadata
-- 2. water_smartsheet_sync_queue — one row per water_log_entry that
-- needs syncing; tracks attempts + status
-- for retry / observability
-- 3. water_smartsheet_sync_log — append-only log of every sync attempt
-- (success OR failure) — backs the
-- "Recent Activity" panel in the UI
--
-- 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).
-- ============================================================================
-- ── 1. Config table ────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS water_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 water-log fields → Smartsheet column IDs.
-- Shape: { entry_id: string, logged_at: string, headgate: string,
-- measurement: string, unit: string, irrigator: string,
-- notes: string | null }
-- `entry_id` and `logged_at` 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 water_smartsheet_config_set_updated_at ON water_smartsheet_config;
CREATE TRIGGER water_smartsheet_config_set_updated_at
BEFORE UPDATE ON water_smartsheet_config
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- RLS: brand-scoped.
ALTER TABLE water_smartsheet_config ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON water_smartsheet_config;
CREATE POLICY tenant_isolation ON water_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 water_log_entry awaiting sync) ──────────────
CREATE TABLE IF NOT EXISTS water_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 entry. ON DELETE CASCADE means deleting an entry also
-- removes its queue row, preventing orphaned sync attempts.
entry_id UUID NOT NULL REFERENCES water_log_entries(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,
-- One queue row per entry per brand. CASCADE handles the case where
-- the entry is deleted (rare; only via admin override).
CONSTRAINT water_smartsheet_queue_entry_unique UNIQUE (brand_id, entry_id)
);
-- Hot path: drain query scans `(brand_id, status, next_attempt_at)`.
CREATE INDEX IF NOT EXISTS water_smartsheet_queue_brand_status_idx
ON water_smartsheet_sync_queue (brand_id, status, next_attempt_at);
-- Recent-attempts view.
CREATE INDEX IF NOT EXISTS water_smartsheet_queue_brand_created_idx
ON water_smartsheet_sync_queue (brand_id, created_at DESC);
ALTER TABLE water_smartsheet_sync_queue ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON water_smartsheet_sync_queue;
CREATE POLICY tenant_isolation ON water_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 water_smartsheet_sync_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
entry_id UUID REFERENCES water_log_entries(id) ON DELETE SET NULL,
smartsheet_row_id TEXT,
-- 'sync' (initial push) | 'retry' (after a failure) | 'skip'
-- (dedup hit — entry already had smartsheet_row_id) | 'test'
-- (the "Test Connection" button does NOT log here; that's a config
-- event recorded via water_audit_log instead).
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 water_smartsheet_log_brand_recent_idx
ON water_smartsheet_sync_log (brand_id, created_at DESC);
ALTER TABLE water_smartsheet_sync_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON water_smartsheet_sync_log;
CREATE POLICY tenant_isolation ON water_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 entry (for dedup decisions) ────────────
-- One row per entry with the most recent sync attempt. Used by the
-- sync service to decide "has this entry been pushed already?".
CREATE OR REPLACE VIEW water_smartsheet_latest_sync AS
SELECT DISTINCT ON (q.brand_id, q.entry_id)
q.brand_id,
q.entry_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 water_smartsheet_sync_queue q
ORDER BY q.brand_id, q.entry_id, q.created_at DESC;
COMMENT ON TABLE water_smartsheet_config IS
'Per-brand Smartsheet integration config. Token is AES-256-GCM encrypted.';
COMMENT ON TABLE water_smartsheet_sync_queue IS
'One row per water_log_entry awaiting / completed sync to Smartsheet.';
COMMENT ON TABLE water_smartsheet_sync_log IS
'Append-only audit of Smartsheet sync attempts (success and failure).';
@@ -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;
+198 -36
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,10 +25,15 @@ import {
index,
uniqueIndex,
integer,
check,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
import { adminUsers } from "./brands";
import { fieldWorkers } from "./field-workers";
// 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",
@@ -62,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()
@@ -113,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(),
@@ -142,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,
),
}),
@@ -244,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;
@@ -255,3 +244,176 @@ export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
export type WaterAuditLog = typeof waterAuditLog.$inferSelect;
// ── Smartsheet integration (added in 0093_water_smartsheet_sync.sql) ────────
/**
* Allowed sync frequencies. Stored as TEXT to match the convention
* in CLAUDE.md ("Status enums stored as TEXT — no PostgreSQL ENUM type").
*/
export const SMARTSHEET_FREQUENCIES = [
"realtime",
"every_15_minutes",
"hourly",
] as const;
export type SmartsheetFrequency = (typeof SMARTSHEET_FREQUENCIES)[number];
/**
* The Water-log fields a brand can map to their Smartsheet columns.
* `entry_id` and `logged_at` are required (used for dedup).
*/
export type SmartsheetColumnKey =
| "entry_id"
| "logged_at"
| "headgate"
| "measurement"
| "unit"
| "irrigator"
| "notes";
export const SMARTSHEET_COLUMN_KEYS: readonly SmartsheetColumnKey[] = [
"entry_id",
"logged_at",
"headgate",
"measurement",
"unit",
"irrigator",
"notes",
] as const;
/**
* Shape stored in `water_smartsheet_config.column_mapping`. The values
* are Smartsheet column IDs (numeric strings, e.g. "8309876543210123").
*
* Only `entry_id` and `logged_at` are required (used for dedup). All
* other fields are nullable — `null` means "not mapped, don't write
* this column". The UI uses an empty string in the dropdown to mean
* the same thing and we coerce on the server.
*/
export type SmartsheetColumnMapping = {
entry_id: string;
logged_at: string;
headgate: string | null;
measurement: string | null;
unit: string | null;
irrigator: string | null;
notes: string | null;
};
export const waterSmartsheetConfig = pgTable("water_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<SmartsheetColumnMapping>()
.notNull(),
syncFrequency: text("sync_frequency")
.$type<SmartsheetFrequency>()
.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 type WaterSmartsheetConfig = typeof waterSmartsheetConfig.$inferSelect;
export type WaterSmartsheetConfigInsert = typeof waterSmartsheetConfig.$inferInsert;
/**
* Sync queue status. Mirrors the SQL CHECK constraint.
*/
export const SMARTSHEET_QUEUE_STATUSES = [
"pending",
"syncing",
"synced",
"failed",
] as const;
export type SmartsheetQueueStatus = (typeof SMARTSHEET_QUEUE_STATUSES)[number];
export const waterSmartsheetSyncQueue = pgTable(
"water_smartsheet_sync_queue",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
entryId: uuid("entry_id")
.notNull()
.references(() => waterLogEntries.id, { onDelete: "cascade" }),
smartsheetRowId: text("smartsheet_row_id"),
status: text("status")
.$type<SmartsheetQueueStatus>()
.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 }),
},
(t) => ({
entryUnique: uniqueIndex("water_smartsheet_queue_entry_unique").on(
t.brandId,
t.entryId,
),
brandStatusIdx: index("water_smartsheet_queue_brand_status_idx").on(
t.brandId,
t.status,
t.nextAttemptAt,
),
brandRecentIdx: index("water_smartsheet_queue_brand_created_idx").on(
t.brandId,
t.createdAt,
),
}),
);
export type WaterSmartsheetSyncQueue = typeof waterSmartsheetSyncQueue.$inferSelect;
export type WaterSmartsheetSyncQueueInsert = typeof waterSmartsheetSyncQueue.$inferInsert;
export const SMARTSHEET_LOG_ACTIONS = ["sync", "retry", "skip", "queue"] as const;
export type SmartsheetLogAction = (typeof SMARTSHEET_LOG_ACTIONS)[number];
export const waterSmartsheetSyncLog = pgTable(
"water_smartsheet_sync_log",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
entryId: uuid("entry_id").references(() => waterLogEntries.id, {
onDelete: "set null",
}),
smartsheetRowId: text("smartsheet_row_id"),
action: text("action").$type<SmartsheetLogAction>().notNull(),
success: boolean("success").notNull(),
error: text("error"),
durationMs: integer("duration_ms"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandRecentIdx: index("water_smartsheet_log_brand_recent_idx").on(
t.brandId,
t.createdAt,
),
}),
);
export type WaterSmartsheetSyncLog = typeof waterSmartsheetSyncLog.$inferSelect;
export type WaterSmartsheetSyncLogInsert = typeof waterSmartsheetSyncLog.$inferInsert;
+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,
@@ -88,12 +88,13 @@ The site-admin helper (`requireWaterAdminPermission`) keeps using
`/admin/water-log/*` (which sits behind Neon Auth in the middleware
and on the server).
> Note: the action `verifyWaterAdminPin` (in `settings.ts`) does make a
> best-effort `getAdminUser()` call *after* a successful PIN check, but
> only to attach a platform-admin `adminUserId` to the new
> `water_admin_sessions` row for audit. It is wrapped in try/catch and
> falls back to a zero-UUID placeholder. The `wl_admin_session` is
> valid either way — that helper call is not an auth gate.
> Note: the action `verifyWaterAdminPin` (in `settings.ts`) is now a
> fully PIN-only flow — it does NOT call `getAdminUser()` or
> `getSession()` at all. The `adminUserId` on the new
> `water_admin_sessions` row is always the zero-UUID placeholder. This
> is because `/water/admin/login` is the Tuxedo-brand-specific entry
> point and must work for users who are not signed into the platform.
> Audit attribution is intentionally deferred to a future ticket.
### 4.2 New file: `src/actions/water-log/auth.ts`
@@ -174,23 +175,25 @@ moves.
keep their existing `getAdminUser()` gate (site-admin path).
- `verifyWaterAdminPin` becomes a pure PIN-only flow: validate format,
look up `water_admin_settings` for the brand, verify PIN, mint
`wl_admin_session` cookie. **No `getSession()` call.** `getAdminUser()`
is still called inside the success path to attach an `adminUserId`
to the new session row for audit — wrapped in try/catch, falls back
to a zero-UUID placeholder if no admin user is signed in. The
session itself is valid either way.
`wl_admin_session` cookie. **No `getSession()` call. No
`getAdminUser()` call.** `adminUserId` is always the zero-UUID
placeholder. The page is the Tuxedo-brand-specific entry point and
must work without a platform login.
### 4.4 Why `verifyWaterAdminPin` is a special case
`verifyWaterAdminPin` is the most-affected function in this PR. Today:
1. It calls `await getSession()` (Neon Auth) — drop.
2. It calls `getAdminUser()` inside the success path to attach the
1. It called `await getSession()` (Neon Auth) — dropped.
2. It called `getAdminUser()` inside the success path to attach the
caller's platform-admin user to the new `wl_admin_session` row for
audit. This is correct and stays, but the call is now **wrapped in
a try/catch that swallows any error and falls back to a zero-UUID
`adminUserId`**. The `wl_admin_session` is still valid in either
case the cookie holds the brand + session id, not the admin user.
audit. That call was wrapped in a try/catch that fell back to a
zero-UUID `adminUserId`, but the underlying `getSession()` call
inside `getAdminUser()` could still corrupt the Next.js cookie
store and cause the subsequent `cookies().set("wl_admin_session",
...)` to throw — surfacing as a 500 to users without a platform
login. The call is now **fully removed**; `adminUserId` is always
the zero-UUID placeholder. Audit attribution is a follow-up.
This is the right shape because the water-admin PIN flow is for ditch
riders / brand water admins, who often aren't platform admins. Tying
+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);
});
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
/**
* diag-water-pin.mjs — diagnostic script for the "PIN only works
* once" bug in the water log module.
*
* What it does:
* 1. Snapshots every row in `water_irrigators` and `water_sessions`.
* 2. For each irrigator, verifies the stored `pin_hash` format is
* a parseable `scrypt$N$r$p$salt$hash` string.
* 3. Cross-checks: any session whose `expires_at < now()` would
* fail `requireFieldSession` but should NOT affect `verifyWaterPin`
* (verifyWaterPin re-issues a session on success).
* 4. Optionally tests a provided PIN against a provided pin_hash to
* see if the cryptographic verify matches.
*
* Usage:
* DATABASE_URL="postgresql://..." node scripts/diag-water-pin.mjs
* DATABASE_URL="..." \
* node scripts/diag-water-pin.mjs --pin=4739 --id=<irrigator-uuid>
*
* Run this BEFORE and AFTER a failing second-login attempt to confirm
* whether `pin_hash` is being modified mid-session.
*/
import "dotenv/config";
import { Pool } from "pg";
import { scryptSync, timingSafeEqual } from "node:crypto";
const args = Object.fromEntries(
process.argv.slice(2).map((a) => {
const m = a.match(/^--([^=]+)(?:=(.*))?$/);
return m ? [m[1], m[2] ?? true] : [a, true];
}),
);
const pin = typeof args.pin === "string" ? args.pin : null;
const targetId = typeof args.id === "string" ? args.id : null;
if (!process.env.DATABASE_URL) {
console.error("DATABASE_URL is not set");
process.exit(1);
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.DATABASE_URL.includes("sslmode")
? { rejectUnauthorized: false }
: undefined,
});
function verifyPin(rawPin, stored) {
if (!stored || !stored.startsWith("scrypt$")) return false;
const parts = stored.split("$");
if (parts.length !== 6) return false;
const [, nStr, rStr, pStr, saltB64, hashB64] = parts;
const n = parseInt(nStr, 10),
r = parseInt(rStr, 10),
p = parseInt(pStr, 10);
if (![n, r, p].every(Number.isFinite)) return false;
let salt, expected;
try {
salt = Buffer.from(saltB64, "base64");
expected = Buffer.from(hashB64, "base64");
} catch {
return false;
}
if (expected.length !== 32) return false;
let candidate;
try {
candidate = scryptSync(rawPin.normalize("NFKC"), salt, 32, {
N: n,
r,
p,
});
} catch {
return false;
}
return (
candidate.length === expected.length &&
timingSafeEqual(candidate, expected)
);
}
function truncate(s, n = 60) {
return s && s.length > n ? s.slice(0, n) + "..." : s;
}
async function main() {
const client = await pool.connect();
try {
console.log("=== SNAPSHOT @ " + new Date().toISOString() + " ===\n");
// Bypass RLS via withPlatformAdmin equivalent (raw app vars).
await client.query("BEGIN");
await client.query(
"SELECT set_config('app.current_brand_id', '', true)",
);
await client.query(
"SELECT set_config('app.platform_admin', 'true', true)",
);
const irrigators = await client.query(
"SELECT id, brand_id, name, role, active, " +
"substr(pin_hash, 1, 40) || '...' AS pin_hash_preview, " +
"length(pin_hash) AS pin_hash_len, last_used_at, created_at " +
"FROM water_irrigators ORDER BY name",
);
console.log("water_irrigators (" + irrigators.rowCount + " rows):");
for (const r of irrigators.rows) {
console.log(` • [${r.id}] ${r.name}`);
console.log(` brand=${r.brand_id}`);
console.log(` role=${r.role} active=${r.active}`);
console.log(` pin_hash_preview="${r.pin_hash_preview}" (len=${r.pin_hash_len})`);
console.log(` last_used_at=${r.last_used_at}`);
// Format check
const fullRow = await client.query(
"SELECT pin_hash FROM water_irrigators WHERE id = $1",
[r.id],
);
const ph = fullRow.rows[0]?.pin_hash;
if (!ph || !ph.startsWith("scrypt$") || ph.split("$").length !== 6) {
console.log(` ⚠️ pin_hash has UNEXPECTED format`);
} else {
console.log(` ✓ pin_hash is well-formed scrypt string`);
}
// Targeted PIN verify
if (pin && targetId === r.id) {
const ok = verifyPin(pin, ph);
console.log(` ${ok ? "✓" : "✗"} pin '${pin}' matches pin_hash for ${r.name}${ok}`);
}
console.log();
}
const sessions = await client.query(
"SELECT id, irrigator_id, expires_at, created_at FROM water_sessions ORDER BY created_at DESC LIMIT 50",
);
console.log("\nwater_sessions (" + sessions.rowCount + " recent rows):");
for (const s of sessions.rows) {
const expired = new Date(s.expires_at).getTime() < Date.now();
const irrigator = irrigators.rows.find((i) => i.id === s.irrigator_id);
console.log(
` • [${s.id}] irrigator=${s.irrigator_id} (${irrigator?.name ?? "?"}) expires_at=${s.expires_at.toISOString()} ${expired ? "(EXPIRED)" : "(active)"}`,
);
}
await client.query("COMMIT");
} finally {
client.release();
await pool.end();
}
}
main().catch((err) => {
console.error("error:", err.message);
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(
+4 -1
View File
@@ -234,12 +234,15 @@ await getSession(); const adminUser = await getAdminUser();
// `customers` replaced `communication_contacts`. `source` column is gone,
// so `imports` is always 0 and `new_contacts` is the day's net add.
// Wrap COUNT in SUM() so the window function operates on an aggregate,
// not the raw `c.id` column (which is not in GROUP BY).
const { rows } = await pool.query<{ date: string; new_contacts: number; imports: number; total: number }>(
`SELECT
d::date::text AS date,
COUNT(c.id) FILTER (WHERE c.created_at::date = d::date)::int AS new_contacts,
0::int AS imports,
COUNT(c.id) OVER (ORDER BY d::date)::int AS total
SUM(COUNT(c.id) FILTER (WHERE c.created_at::date = d::date))
OVER (ORDER BY d::date)::int AS total
FROM generate_series($1::date, $2::date, '1 day'::interval) d
LEFT JOIN customers c
ON c.created_at::date = d::date
+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 };
});
}
+634 -119
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) ─────────────────
type LogListOptions = {
workerId?: string;
taskId?: string;
start?: string; // ISO timestamp
end?: string; // ISO timestamp
limit?: number;
offset?: number;
};
export async function getWorkerTimeLogs(
_brandId: string,
_options: {
workerId?: string;
taskId?: string;
start?: string;
end?: string;
limit?: number;
offset?: number;
} = {}
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,
};
});
}
+83 -21
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" };
}
return {
sent: false,
message: "Time tracking is not configured in the SaaS rebuild",
};
// 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: 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) };
}
+70 -53
View File
@@ -5,8 +5,8 @@
* 1. Site-admin UI (`/admin/water-log`) — needs `can_manage_water_log`
* and runs brand-scoped via `withBrand()`.
* 2. PIN-protected field-admin UI (`/water/admin`) — uses a separate
* `water_admin_sessions` cookie; `requireWaterAdminSession()` is the
* gate for those.
* `wl_admin_session` cookie; `getAdminSession()` in
* `@/lib/water-admin-pin-auth` is the gate for those.
*
* All actions return either `{ success: true, ... }` or
* `{ success: false, error }`. We never throw across the action boundary
@@ -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;
@@ -108,10 +114,11 @@ export type AlertLogEntry = {
// ── Auth helpers ───────────────────────────────────────────────────────────
//
// `requireWaterAdminPermission` and `requireWaterAdminSession` live in
// `@/actions/water-log/auth`. The site-admin gate calls `getAdminUser()`;
// the brand-admin-PIN gate reads `wl_admin_session` directly. Neither
// calls the platform-level `getSession()` from `@/lib/auth`.
// `requireWaterAdminPermission` lives in `@/actions/water-log/auth`
// (site-admin gate via `getAdminUser()`). The brand-admin PIN gate is
// `getAdminSession()` in `@/lib/water-admin-pin-auth` (cookie-only,
// no DB session, no Neon Auth). Neither calls the platform-level
// `getSession()` from `@/lib/auth`.
// Global hint used by the field admin portal: water log is currently
// scoped to the Tuxedo brand only. Surfacing this in one place makes it
@@ -141,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,
@@ -161,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),
@@ -368,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);
});
}
@@ -404,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);
@@ -417,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,
@@ -454,7 +471,7 @@ return createWaterUser(brandId, name, "irrigator", language);
}
export async function updateWaterIrrigator(
irrigatorId: string,
fieldWorkerId: string,
name: string,
active: boolean,
language: string,
@@ -465,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();
@@ -474,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,
@@ -483,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({
@@ -492,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 };
@@ -505,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,
@@ -531,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,
@@ -572,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;
@@ -586,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}
@@ -602,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),
@@ -628,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;
@@ -642,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
`);
@@ -659,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),
@@ -769,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
@@ -822,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;
@@ -836,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
@@ -857,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),
@@ -943,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;
});
+53 -120
View File
@@ -1,56 +1,55 @@
/**
* Water Log — auth layer.
*
* Centralizes the three auth gates the water-log module uses:
* Centralizes the auth gates the water-log module uses:
*
* 1. `requireFieldSession()` — read wl_session cookie, look up
* the session row in `water_sessions`,
* return user/brand/role. Gates every
* irrigator PIN action (/water).
* 2. `requireWaterAdminSession()` — read wl_admin_session cookie, look
* up the row in `water_admin_sessions`.
* Gates brand-admin PIN paths
* (/water/admin/*).
* 3. `requireWaterAdminPermission()`— site-admin gate (Neon Auth +
* 2. `requireWaterAdminPermission()`— site-admin gate (Neon Auth +
* admin_users.can_manage_water_log).
* Gates /admin/water-log/*.
*
* **Crucially, the two PIN-only helpers (1) and (2) never call
* `getSession()` from `@/lib/auth` and never call `getAdminUser()`.**
* The water log module is intentionally PIN-based and self-contained;
* a prior version of this code carried spurious `getSession()` calls
* that hung PIN submission for users with no platform login. The static
* "does not import getSession" test in `tests/unit/water-log-auth.test.ts`
* guards against that regression.
* **Crucially, the PIN-only helper (1) never calls `getSession()` from
* `@/lib/auth` and never calls `getAdminUser()`.** The water log
* module is intentionally PIN-based and self-contained; a prior
* version of this code carried spurious `getSession()` calls that
* hung PIN submission for users with no platform login. The static
* "does not import getSession" test in
* `tests/unit/water-log-auth.test.ts` guards against that regression.
*
* NOTE: Brand-admin PIN auth for `/water/admin/*` lives in
* `src/lib/water-admin-pin-auth.ts` (cookie-only, no DB session,
* no Neon Auth). It is intentionally a separate module so it can be
* audited in isolation.
*/
"use server";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { withPlatformAdmin } from "@/db/client";
import {
waterSessions,
waterIrrigators,
waterAdminSessions,
} 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,
type FieldSession,
type FieldSessionResult,
} from "@/lib/water-log/session-server";
// ── Result types ────────────────────────────────────────────────────────
export type FieldSession =
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
| { ok: false; error: string };
export type AdminPinSession =
| { ok: true; sessionId: string; brandId: string; adminUserId: string }
| { ok: false; error: string };
export type AdminPinSessionInfo = {
sessionId: string;
brandId: string;
adminUserId: string;
role: "water_admin";
};
// `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. 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>>> }
@@ -62,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"
@@ -74,21 +74,21 @@ export type SiteAdminPermission =
* Never calls `getSession()`. Never calls `getAdminUser()`. The only
* network round-trip is the local DB query.
*/
export async function requireFieldSession(): Promise<FieldSession> {
export async function requireFieldSession(): Promise<FieldSessionResult> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
const sessionId = cookieStore.get(WL_SESSION_COOKIE)?.value;
if (!sessionId) return { ok: false, error: "Not logged in" };
return withPlatformAdmin(async (db) => {
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);
@@ -103,85 +103,18 @@ export async function requireFieldSession(): Promise<FieldSession> {
}
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",
};
});
}
/**
* Brand-admin PIN session gate — /water/admin/*.
*
* Reads the `wl_admin_session` cookie, joins it to
* `water_admin_sessions`, and returns brand/admin info. Same error
* semantics as `requireFieldSession` but for the admin-PIN cookie.
*/
export async function requireWaterAdminSession(): Promise<AdminPinSession> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return { ok: false, error: "Not signed in" };
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
id: waterAdminSessions.id,
brandId: waterAdminSessions.brandId,
adminUserId: waterAdminSessions.adminUserId,
expiresAt: waterAdminSessions.expiresAt,
})
.from(waterAdminSessions)
.where(eq(waterAdminSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return { ok: false as const, error: "Session not found" };
if (row.expiresAt.getTime() < Date.now()) {
return { ok: false as const, error: "Session expired" };
}
return {
ok: true as const,
sessionId: row.id,
brandId: row.brandId,
adminUserId: row.adminUserId,
};
});
}
/**
* "Return null on miss" variant of `requireWaterAdminSession`. Used by
* pages that want to render conditionally based on whether the user
* has a valid admin-PIN session, without forcing a hard error.
*/
export async function getWaterAdminSession(): Promise<AdminPinSessionInfo | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
id: waterAdminSessions.id,
brandId: waterAdminSessions.brandId,
adminUserId: waterAdminSessions.adminUserId,
expiresAt: waterAdminSessions.expiresAt,
})
.from(waterAdminSessions)
.where(eq(waterAdminSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.expiresAt.getTime() <= Date.now()) return null;
return {
sessionId: row.id,
brandId: row.brandId,
adminUserId: row.adminUserId,
role: "water_admin" as const,
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",
};
});
}
@@ -195,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;
@@ -214,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,
};
});
}
@@ -225,7 +158,7 @@ export async function getFieldSessionUser(): Promise<{
* This is the only helper in this module that calls
* `getAdminUser()`, and that's intentional: it backs the
* `/admin/water-log/*` pages which sit behind Neon Auth in the
* middleware. The two PIN-only helpers above deliberately do not.
* middleware. The PIN-only helper above deliberately does not.
*/
export async function requireWaterAdminPermission(): Promise<SiteAdminPermission> {
const adminUser = await getAdminUser();
+119 -61
View File
@@ -23,24 +23,37 @@ 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,
} from "@/lib/water-log/session-server";
import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand";
import { triggerSyncForEntry } from "@/actions/water-log/smartsheet";
export type { FieldSession };
// Re-export so existing call sites that import from
// `@/actions/water-log/field` keep working.
// 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.
const FIELD_SESSION_HOURS = 8;
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -52,6 +65,23 @@ type FieldHeadgate = {
high_threshold: number | null;
low_threshold: number | null;
active: boolean;
/**
* Free-text headgate description. Often a field location / section
* label (e.g. "Section 4 — North Field") or a coordinate string
* ("GPS 36.1234, -119.5678"). The mobile UI shows this as the
* card's secondary line — think of it as the "geocode" until a
* proper location column ships.
*/
notes: string | null;
/** ISO timestamp of the most recent successful log. Display only. */
last_used_at: string | null;
/**
* Unit from the most recent log entry for this headgate, or
* `null` if the headgate has never been logged against. Drives the
* log-form unit picker — operators don't have to re-pick their
* preferred unit on the same gate every shift.
*/
last_unit: string | null;
};
type VerifyPinResult =
@@ -59,7 +89,7 @@ type VerifyPinResult =
success: true;
user_id: string;
name: string;
role: "irrigator" | "water_admin";
role: FieldWorkerRole;
session_id: string;
lang: string;
}
@@ -82,8 +112,33 @@ export async function getWaterHeadgates(
eq(waterHeadgates.active, true),
)
: eq(waterHeadgates.brandId, TUXEDO_BRAND_ID);
// Most-recent unit logged against this headgate. `null` when no
// entries exist yet — fall back to `waterHeadgates.unit` on the
// client. Per-headgate PK + the (logged_at DESC) index makes
// this O(log n) per gate; for typical Tuxedo fleet sizes
// (<200 active gates × handful of entries each), the planner
// will hash-join on the headgate_id PK and never touch disk.
const lastUnitSql = sql<string | null>`(
SELECT ${waterLogEntries.unit}
FROM ${waterLogEntries}
WHERE ${waterLogEntries.headgateId} = ${waterHeadgates.id}
AND ${waterLogEntries.brandId} = ${TUXEDO_BRAND_ID}
ORDER BY ${waterLogEntries.loggedAt} DESC
LIMIT 1
)`;
const rows = await db
.select()
.select({
id: waterHeadgates.id,
name: waterHeadgates.name,
unit: waterHeadgates.unit,
status: waterHeadgates.status,
highThreshold: waterHeadgates.highThreshold,
lowThreshold: waterHeadgates.lowThreshold,
active: waterHeadgates.active,
notes: waterHeadgates.notes,
lastUsedAt: waterHeadgates.lastUsedAt,
lastUnit: lastUnitSql,
})
.from(waterHeadgates)
.where(where)
.orderBy(waterHeadgates.name);
@@ -95,6 +150,9 @@ export async function getWaterHeadgates(
high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null,
low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null,
active: h.active,
notes: h.notes,
last_used_at: h.lastUsedAt ? h.lastUsedAt.toISOString() : null,
last_unit: h.lastUnit,
}));
});
}
@@ -109,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;
});
@@ -132,45 +193,48 @@ export async function verifyWaterPin(
}
// Create a session in the user's brand context. The session insert
// and the last_used_at bump are independent writes — fire them in
// parallel and wait on both before returning.
const [sessionId, cookieStore] = await Promise.all([
withBrand(match.brandId, async (db) => {
const expiresAt = new Date(
Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000,
);
const [insertResult] = await Promise.all([
db
.insert(waterSessions)
.values({
irrigatorId: match.id,
expiresAt,
})
.returning({ id: waterSessions.id }),
db
.update(waterIrrigators)
.set({ lastUsedAt: new Date() })
.where(eq(waterIrrigators.id, match.id)),
]);
const row = insertResult[0];
if (!row) throw new Error("Failed to create session");
return row.id;
}),
cookies(),
]);
cookieStore.set("wl_session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: FIELD_SESSION_HOURS * 3600,
path: "/",
// and the last_used_at bump run *sequentially* on the same pg
// connection inside the `withBrand` transaction — never concurrently.
//
// The previous shape used a nested `Promise.all([insert, update])`
// *and* an outer `Promise.all([withBrand, cookies()])`. That was
// known-fragile per the codebase's own commit `ca79896`: "cookies()
// corrupts Next.js's request-scoped AsyncLocalStorage if called from
// inside a pg transaction." The admin portal has been on the
// sequential shape (see `src/lib/water-admin-pin-auth.ts`) since
// that commit; this brings the field portal in line.
const sessionId = await withBrand(match.brandId, async (db) => {
const expiresAt = new Date(
Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000,
);
const [inserted] = await db
.insert(waterSessions)
.values({
fieldWorkerId: match.id,
expiresAt,
})
.returning({ id: waterSessions.id });
if (!inserted) throw new Error("Failed to create session");
await db
.update(fieldWorkers)
.set({ lastUsedAt: new Date() })
.where(eq(fieldWorkers.id, match.id));
return inserted.id;
});
// Cookie set is fully outside the DB transaction. Same rule as
// `verifyAdminPin` (see comment in `src/lib/water-admin-pin-auth.ts`).
await setSessionCookie(WL_SESSION_COOKIE, sessionId, {
hours: FIELD_SESSION_HOURS,
});
return {
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,
};
@@ -235,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,
@@ -279,6 +343,10 @@ export async function submitWaterEntry(
});
}
// Smartsheet sync hook — enqueue + (if realtime) push. Failures
// never bubble: the field worker must not see a sync hiccup.
void triggerSyncForEntry(session.brandId, entry.id);
return { success: true, entry_id: entry.id };
});
}
@@ -286,8 +354,7 @@ export async function submitWaterEntry(
// ── Cookie/language helpers ───────────────────────────────────────────────
export async function logoutWater(): Promise<void> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
const sessionId = await readSessionCookie(WL_SESSION_COOKIE);
if (sessionId) {
// Best-effort DB cleanup
try {
@@ -298,24 +365,15 @@ export async function logoutWater(): Promise<void> {
// ignore — the cookie is being deleted anyway
}
}
cookieStore.delete("wl_session");
await clearSessionCookie(WL_SESSION_COOKIE);
}
export async function logoutWaterAdmin(): Promise<void> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (sessionId) {
try {
await withPlatformAdmin(async (db) => {
await db
.delete(waterAdminSessions)
.where(eq(waterAdminSessions.id, sessionId));
});
} catch {
// ignore — cookie is being deleted anyway
}
}
cookieStore.delete("wl_admin_session");
// The `wl_admin_session` cookie is now self-contained — see
// `src/lib/water-admin-pin-auth.ts`. No DB row to clean up; just
// delete the cookie.
const { clearAdminSession } = await import("@/lib/water-admin-pin-auth");
await clearAdminSession();
}
export async function setWaterLang(lang: string): Promise<void> {
+6 -84
View File
@@ -3,17 +3,15 @@
*
* The `/water/admin` portal is gated by its own 4-digit PIN (separate
* from the irrigators' PIN). That PIN is *hashed* and stored in
* `water_admin_settings` (one row per brand). Sessions are tracked in
* `water_admin_sessions`.
* `water_admin_settings` (one row per brand).
*
* There is currently no per-admin-user PIN — a single brand-wide admin
* PIN. If you need per-user PINs (e.g. for an audit trail of which
* admin entered the portal), swap `pin_hash` for `admin_user_pins` and
* key sessions by `admin_user_id`.
* PIN verification lives in `src/lib/water-admin-pin-auth.ts` (no
* server actions, no Neon Auth). This file only handles the
* platform-admin-gated `/admin/water-log/settings` UI which sets /
* updates the PIN and other admin settings.
*/
"use server";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import {
@@ -22,13 +20,9 @@ import {
type WaterAdminSettings,
} from "@/db/schema/water-log";
import { getAdminUser } from "@/lib/admin-permissions";
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
import { hashPin, generatePin } from "@/lib/water-log-pin";
import { logAuditEvent } from "@/lib/water-log-audit";
// Note: we deliberately do NOT import `getSession` from `@/lib/auth`.
// The water-admin PIN entry flow (`verifyWaterAdminPin`) is PIN-based
// and self-contained — see docs/superpowers/specs/2026-07-01-water-log-no-platform-login-design.md.
export type AdminSettings = {
enabled: boolean;
sessionDurationHours: number;
@@ -192,75 +186,3 @@ const adminUser = await getAdminUser();
return { success: true, pin };
});
}
/**
* PIN verify for the `/water/admin` portal. Creates a session row and
* sets the `wl_admin_session` cookie.
*/
export async function verifyWaterAdminPin(
brandId: string,
pin: string,
): Promise<{ success: boolean; session_id?: string; error?: string }> {
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterAdminSettings)
.where(eq(waterAdminSettings.brandId, brandId))
.limit(1);
const settings = rows[0];
if (!settings) {
return { success: false, error: "Admin portal not configured" };
}
if (!settings.enabled) {
return { success: false, error: "Admin portal is disabled" };
}
if (!settings.pinHash) {
return { success: false, error: "No PIN configured — generate one in /admin/water-log/settings" };
}
if (!verifyPin(pin, settings.pinHash)) {
await new Promise((r) => setTimeout(r, 200));
return { success: false, error: "Invalid PIN" };
}
// Tie the session to the calling site admin (best-effort).
// The `wl_admin_session` is valid whether or not a platform admin
// is signed in — this call only attaches an `adminUserId` for
// audit. If `getAdminUser()` fails or returns null, we fall back
// to a zero-UUID placeholder.
let adminUser: Awaited<ReturnType<typeof getAdminUser>> = null;
try {
adminUser = await getAdminUser();
} catch {
// ignore — the session is valid regardless
}
const expiresAt = new Date(
Date.now() + settings.sessionDurationHours * 60 * 60 * 1000,
);
const [session] = await db
.insert(waterAdminSessions)
.values({
brandId,
adminUserId: adminUser?.user_id ?? adminUser?.id ?? "00000000-0000-0000-0000-000000000000",
pinHashUsed: settings.pinHash,
expiresAt,
})
.returning({ id: waterAdminSessions.id });
if (!session) return { success: false, error: "Failed to create session" };
const cookieStore = await cookies();
cookieStore.set("wl_admin_session", session.id, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: settings.sessionDurationHours * 3600,
path: "/",
});
return { success: true, session_id: session.id };
});
}
+709
View File
@@ -0,0 +1,709 @@
"use server";
/**
* 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
* platform_admin) can mutate config; everyone authenticated can read.
*
* 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 (not the
* per-feature config).
*/
import { desc, eq, and, gte, inArray } from "drizzle-orm";
import { withBrand } from "@/db/client";
import {
waterLogEntries,
waterSmartsheetConfig,
waterSmartsheetSyncLog,
waterSmartsheetSyncQueue,
SMARTSHEET_FREQUENCIES,
SMARTSHEET_COLUMN_KEYS,
type SmartsheetColumnKey,
type SmartsheetColumnMapping,
type SmartsheetFrequency,
} from "@/db/schema/water-log";
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";
import { logAuditEvent } from "@/lib/water-log-audit";
import { getSession } from "@/lib/auth";
// ── Types used by both server and client ────────────────────────────────────
export type SmartsheetConfigResponse = {
configured: boolean;
sheetId: string | null;
syncEnabled: boolean;
syncFrequency: SmartsheetFrequency;
columnMapping: SmartsheetColumnMapping | null;
maskedToken: string | null; // "••••abcd" or null if no token saved
lastSyncAt: string | null;
lastSyncError: string | null;
hasToken: boolean;
updatedAt: string | null;
};
export type SmartsheetSyncLogEntry = {
id: string;
entryId: string | null;
smartsheetRowId: string | null;
action: "sync" | "retry" | "skip" | "queue";
success: boolean;
error: string | null;
durationMs: number | null;
createdAt: string;
};
export type SaveSmartsheetConfigInput = {
sheetId: 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;
};
export type TestConnectionResult =
| {
success: true;
sheetName: string;
columnCount: number;
columns: { id: string; title: string; type: string }[];
}
| { success: false; error: string };
// ── Auth helper ────────────────────────────────────────────────────────────
async function requireWaterAdminPermission() {
const adminUser = await getAdminUser();
if (!adminUser) return { ok: false as const, error: "Not authenticated" };
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return { ok: false as const, error: "Not authorized" };
}
return { ok: true as const, adminUser };
}
// ── Read ───────────────────────────────────────────────────────────────────
export async function getSmartsheetConfig(
brandId: string,
): Promise<SmartsheetConfigResponse> {
await getSession();
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterSmartsheetConfig)
.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,
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,
lastSyncAt: config.lastSyncAt?.toISOString() ?? null,
lastSyncError: config.lastSyncError,
hasToken: workspace.hasToken,
updatedAt: config.updatedAt.toISOString(),
};
});
}
export async function listSmartsheetSyncLog(
brandId: string,
limit: number = 20,
): Promise<SmartsheetSyncLogEntry[]> {
await getSession();
const safeLimit = Math.min(Math.max(limit, 1), 200);
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterSmartsheetSyncLog)
.where(eq(waterSmartsheetSyncLog.brandId, brandId))
.orderBy(desc(waterSmartsheetSyncLog.createdAt))
.limit(safeLimit);
return rows.map((r) => ({
id: r.id,
entryId: r.entryId,
smartsheetRowId: r.smartsheetRowId,
action: r.action,
success: r.success,
error: r.error,
durationMs: r.durationMs,
createdAt: r.createdAt.toISOString(),
}));
});
}
// ── Write ──────────────────────────────────────────────────────────────────
export async function saveSmartsheetConfig(
brandId: string,
input: SaveSmartsheetConfigInput,
): Promise<{ success: boolean; error?: string; config?: SmartsheetConfigResponse }> {
await getSession();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
// ── Validation ──
if (!input.sheetId || typeof input.sheetId !== "string") {
return { success: false, error: "Sheet ID or URL is required" };
}
let normalizedSheetId: string;
try {
normalizedSheetId = extractSheetId(input.sheetId);
} catch (err) {
return { success: false, error: (err as Error).message };
}
if (!SMARTSHEET_FREQUENCIES.includes(input.syncFrequency)) {
return { success: false, error: "Invalid sync frequency" };
}
const mappingResult = validateColumnMapping(input.columnMapping);
if (!mappingResult.ok) {
return { success: false, error: mappingResult.error };
}
// 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) => {
const existingRows = await db
.select()
.from(waterSmartsheetConfig)
.where(eq(waterSmartsheetConfig.brandId, brandId))
.limit(1);
const existing = existingRows[0];
const updateValues: Partial<typeof waterSmartsheetConfig.$inferInsert> = {
sheetId: normalizedSheetId,
syncEnabled: input.syncEnabled,
syncFrequency: input.syncFrequency,
columnMapping: input.columnMapping,
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
};
if (existing) {
await db
.update(waterSmartsheetConfig)
.set(updateValues)
.where(eq(waterSmartsheetConfig.brandId, brandId));
} else {
await db.insert(waterSmartsheetConfig).values({
brandId,
sheetId: normalizedSheetId,
columnMapping: input.columnMapping,
syncEnabled: input.syncEnabled,
syncFrequency: input.syncFrequency,
createdBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
});
}
await logAuditEvent({
brandId,
actorId: auth.adminUser.user_id ?? null,
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: existing ? "update" : "create",
entityType: "settings",
details: {
feature: "smartsheet",
sheetId: normalizedSheetId,
syncEnabled: input.syncEnabled,
syncFrequency: input.syncFrequency,
},
});
return {
success: true,
config: await getSmartsheetConfig(brandId),
};
});
}
export async function deleteSmartsheetConfig(
brandId: string,
): Promise<{ success: boolean; error?: string }> {
await getSession();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
return withBrand(brandId, async (db) => {
const deleted = await db
.delete(waterSmartsheetConfig)
.where(eq(waterSmartsheetConfig.brandId, brandId))
.returning({ id: waterSmartsheetConfig.brandId });
if (!deleted[0]) {
return { success: false, error: "No config to delete" };
}
// Also clear the queue + log to avoid orphaned rows.
await db
.delete(waterSmartsheetSyncQueue)
.where(eq(waterSmartsheetSyncQueue.brandId, brandId));
// Keep the log for audit, but null the brandId? Simpler: keep
// them — they're useful for post-mortems. Foreign keys cascade on
// brand delete, but we just unset sync so the log remains a
// historical record. (We DON'T delete water_smartsheet_sync_log —
// admin may want history.)
await logAuditEvent({
brandId,
actorId: auth.adminUser.user_id ?? null,
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: "delete",
entityType: "settings",
details: { feature: "smartsheet" },
});
return { success: true };
});
}
/**
* Test the Smartsheet connection against a sheet ID, using the
* workspace's saved token.
*
* - 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.
*/
export async function testSmartsheetConnection(
brandId: string,
sheetIdInput: string,
tokenInput: string,
): Promise<TestConnectionResult> {
await getSession();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
if (!sheetIdInput) return { success: false, error: "Sheet ID or URL is required" };
let sheetId: string;
try {
sheetId = extractSheetId(sheetIdInput);
} catch (err) {
return { success: false, error: (err as Error).message };
}
// 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 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." };
}
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 {
const meta = await getSheetMeta(sheetId, token);
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) {
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: (err as Error).message };
}
}
/**
* Hook called from the entry-insert path.
*
* Inserts a `pending` queue row. If the brand's frequency is
* `realtime`, fires `syncEntryToSmartsheet` immediately (best-effort
* — failures don't bubble).
*
* Safe to call repeatedly — the queue has a UNIQUE(brand_id, entry_id)
* constraint so duplicate calls are absorbed.
*/
export async function triggerSyncForEntry(
brandId: string,
entryId: string,
): Promise<void> {
try {
const isRealtime = await withBrand(brandId, async (db) => {
// Enqueue (idempotent on conflict).
await db
.insert(waterSmartsheetSyncQueue)
.values({ brandId, entryId, status: "pending" })
.onConflictDoNothing({
target: [waterSmartsheetSyncQueue.brandId, waterSmartsheetSyncQueue.entryId],
});
// Check config for realtime gating.
const rows = await db
.select({
syncEnabled: waterSmartsheetConfig.syncEnabled,
syncFrequency: waterSmartsheetConfig.syncFrequency,
})
.from(waterSmartsheetConfig)
.where(eq(waterSmartsheetConfig.brandId, brandId))
.limit(1);
const cfg = rows[0];
return cfg?.syncEnabled && cfg.syncFrequency === "realtime";
});
if (isRealtime) {
// Fire-and-forget; never bubble errors to the caller.
syncEntryToSmartsheet(brandId, entryId).catch((err) => {
console.error("[smartsheet] realtime sync threw", err);
});
}
} catch (err) {
// Sync setup failures must NEVER block the field-worker submission.
console.error("[smartsheet] triggerSyncForEntry failed", err);
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
/**
* Backfill: enqueue every existing water log entry for the brand so it
* gets pushed to Smartsheet.
*
* Use cases:
* - Customer configured Smartsheet after months/years of recording
* entries → they need historical data in the sheet.
* - Customer rotated their Smartsheet sheet (different ID) → they
* want a clean re-push without changing the per-entry hook.
*
* Safety:
* - Insert is `ON CONFLICT DO NOTHING` so re-runs are no-ops on
* already-queued entries. The UNIQUE(brand_id, entry_id) on the
* queue absorbs duplicates.
* - The sync engine dedups on the Smartsheet side by Entry ID
* (`findRowByColumn`), so even if we re-enqueue an old entry whose
* queue row was deleted, the sheet still won't gain duplicates.
*
* Performance / scope:
* - Inserts are chunked at 500 rows per round-trip.
* - After enqueueing, we kick off one inline drain pass
* (batchSize 200) so the admin gets immediate feedback. Any
* remaining rows are drained by the cron over time.
*/
export type EnqueueBackfillOptions = {
/** Only enqueue entries logged at-or-after this date. */
since?: Date;
};
export type EnqueueBackfillResult = {
success: boolean;
error?: string;
/** Total entries in `water_log_entries` for the brand matching the filter. */
considered: number;
/** Newly inserted rows in `water_smartsheet_sync_queue`. */
newlyQueued: number;
/** Entries that were already in the queue (UNIQUE absorbed). */
alreadyQueued: number;
/**
* Result of the inline drain pass that was kicked off after enqueue.
* `remaining` is the count of pending/failed queue rows that the
* next cron tick will pick up.
*/
drained: {
synced: number;
skipped: number;
failed: number;
remaining: number;
};
};
const BACKFILL_INSERT_CHUNK = 500;
const BACKFILL_DRAIN_BATCH = 200;
export async function enqueueSmartsheetBackfill(
brandId: string,
opts: EnqueueBackfillOptions = {},
): Promise<EnqueueBackfillResult> {
await getSession();
const auth = await requireWaterAdminPermission();
if (!auth.ok) {
const empty = {
considered: 0,
newlyQueued: 0,
alreadyQueued: 0,
drained: { synced: 0, skipped: 0, failed: 0, remaining: 0 },
};
return { success: false, error: auth.error, ...empty };
}
// ── Phase 1: validate + insert queue rows (own transaction) ──
// IMPORTANT: the drain (phase 2) MUST run in a separate transaction
// so it can see the inserts we just committed. The previous
// implementation did both inside one withBrand callback, which
// meant the drain's MVCC snapshot was taken before the inserts
// committed — so it always reported 0/0/0 even with 25+ fresh
// rows in the queue.
const phase1 = await withBrand(brandId, async (db) => {
const configRows = await db
.select({
syncEnabled: waterSmartsheetConfig.syncEnabled,
})
.from(waterSmartsheetConfig)
.where(eq(waterSmartsheetConfig.brandId, brandId))
.limit(1);
const config = configRows[0];
if (!config) {
return {
ok: false as const,
error:
"Configure Smartsheet first (save your API token and column mapping).",
};
}
if (!config.syncEnabled) {
return {
ok: false as const,
error:
"Smartsheet sync is disabled — enable it in the toggle above before backfilling.",
};
}
const entryWhere = opts.since
? and(
eq(waterLogEntries.brandId, brandId),
gte(waterLogEntries.loggedAt, opts.since),
)
: eq(waterLogEntries.brandId, brandId);
const entryRows = await db
.select({ id: waterLogEntries.id })
.from(waterLogEntries)
.where(entryWhere);
const entryIds = entryRows.map((r) => r.id);
if (entryIds.length === 0) {
return {
ok: true as const,
considered: 0,
newlyQueued: 0,
alreadyQueued: 0,
};
}
const existingRows = await db
.select({ entryId: waterSmartsheetSyncQueue.entryId })
.from(waterSmartsheetSyncQueue)
.where(eq(waterSmartsheetSyncQueue.brandId, brandId));
const existingSet = new Set(existingRows.map((r) => r.entryId));
const toInsert = entryIds.filter((id) => !existingSet.has(id));
const alreadyQueued = entryIds.length - toInsert.length;
let newlyQueued = 0;
for (let i = 0; i < toInsert.length; i += BACKFILL_INSERT_CHUNK) {
const chunk = toInsert.slice(i, i + BACKFILL_INSERT_CHUNK);
const values = chunk.map((entryId) => ({
brandId,
entryId,
status: "pending" as const,
}));
const inserted = await db
.insert(waterSmartsheetSyncQueue)
.values(values)
.onConflictDoNothing({
target: [
waterSmartsheetSyncQueue.brandId,
waterSmartsheetSyncQueue.entryId,
],
})
.returning({ entryId: waterSmartsheetSyncQueue.entryId });
newlyQueued += inserted.length;
}
return {
ok: true as const,
considered: entryIds.length,
newlyQueued,
alreadyQueued,
};
});
if (!phase1.ok) {
return {
success: false,
error: phase1.error,
considered: 0,
newlyQueued: 0,
alreadyQueued: 0,
drained: { synced: 0, skipped: 0, failed: 0, remaining: 0 },
};
}
// ── Phase 2: inline drain in a FRESH transaction (sees the inserts) ──
let drained: EnqueueBackfillResult["drained"] = {
synced: 0,
skipped: 0,
failed: 0,
remaining: 0,
};
try {
const result = await drainSyncQueue(brandId, {
batchSize: BACKFILL_DRAIN_BATCH,
});
const remainingRows = await withBrand(brandId, async (db) => {
return db
.select({ id: waterSmartsheetSyncQueue.id })
.from(waterSmartsheetSyncQueue)
.where(
and(
eq(waterSmartsheetSyncQueue.brandId, brandId),
inArray(waterSmartsheetSyncQueue.status, ["pending", "failed"]),
),
);
});
drained = {
synced: result.synced,
skipped: result.skipped,
failed: result.failed,
remaining: remainingRows.length,
};
} catch (err) {
// Don't fail the whole backfill if the inline drain errors —
// the cron will retry. Just surface to the result.
drained = {
synced: 0,
skipped: 0,
failed: 0,
remaining: -1,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...({ error: (err as Error).message } as any),
};
}
// ── Phase 3: audit log (logAuditEvent opens its own withBrand) ──
await logAuditEvent({
brandId,
actorId: auth.adminUser.user_id ?? null,
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: "create",
entityType: "settings",
details: {
feature: "smartsheet",
event: "backfill",
considered: phase1.considered,
newlyQueued: phase1.newlyQueued,
alreadyQueued: phase1.alreadyQueued,
drainedSynced: drained.synced,
drainedSkipped: drained.skipped,
drainedFailed: drained.failed,
since: opts.since?.toISOString() ?? null,
},
});
return {
success: true,
considered: phase1.considered,
newlyQueued: phase1.newlyQueued,
alreadyQueued: phase1.alreadyQueued,
drained,
};
}
function validateColumnMapping(
m: unknown,
): { ok: true; value: SmartsheetColumnMapping } | { ok: false; error: string } {
if (!m || typeof m !== "object") {
return { ok: false, error: "Column mapping is required" };
}
const obj = m as Record<string, unknown>;
const required: SmartsheetColumnKey[] = ["entry_id", "logged_at"];
for (const key of required) {
if (typeof obj[key] !== "string" || (obj[key] as string).length === 0) {
return { ok: false, error: `Column mapping: "${key}" is required` };
}
}
// Optional keys must be either string (with content) or null.
for (const key of SMARTSHEET_COLUMN_KEYS) {
if (required.includes(key)) continue;
const v = obj[key];
if (v !== null && v !== undefined && typeof v !== "string") {
return { ok: false, error: `Column mapping: "${key}" must be a string or null` };
}
if (typeof v === "string" && v.length === 0) {
// Treat empty string as "not mapped".
obj[key] = null;
}
}
return {
ok: true,
value: {
entry_id: obj.entry_id as string,
logged_at: obj.logged_at as string,
headgate: (obj.headgate as string | null) ?? null,
measurement: (obj.measurement as string | null) ?? null,
unit: (obj.unit as string | null) ?? null,
irrigator: (obj.irrigator as string | null) ?? null,
notes: (obj.notes as string | null) ?? null,
},
};
}
+1 -1
View File
@@ -105,7 +105,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<CommandPalette />
<div
id="page-content"
className="min-h-screen lg:pl-60 admin-section outline-none"
className="min-h-screen lg:pl-60 pt-16 lg:pt-0 admin-section outline-none"
style={{ backgroundColor: "var(--admin-bg)" }}
>
<SmoothViewTransition>{children}</SmoothViewTransition>
@@ -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>
);
}
@@ -1,5 +1,5 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getWaterAdminSession } from "@/actions/water-log/auth";
import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth";
import { getWaterEntryById } from "@/actions/water-log/admin";
import WaterLogEntryEditForm from "@/components/admin/WaterLogEntryEditForm";
import Link from "next/link";
@@ -26,7 +26,7 @@ export default async function WaterLogEntryPage({ params, searchParams }: EntryP
(adminUser?.role === "brand_admin" &&
adminUser?.brand_id === TUXEDO_BRAND_ID &&
adminUser?.can_manage_water_log);
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
const isWaterAdmin = waterSession !== null;
if (!isSiteAdmin && !isWaterAdmin) {
return (
@@ -1,6 +1,6 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { redirect } from "next/navigation";
import { getWaterAdminSession } from "@/actions/water-log/auth";
import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth";
import { getWaterHeadgatesAdmin } from "@/actions/water-log/admin";
import HeadgateEditForm from "@/components/admin/HeadgateEditForm";
import Link from "next/link";
@@ -27,7 +27,7 @@ export default async function WaterLogHeadgatePage({ params, searchParams }: Hea
(adminUser?.role === "brand_admin" &&
adminUser?.brand_id === TUXEDO_BRAND_ID &&
adminUser?.can_manage_water_log);
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
const isWaterAdmin = waterSession !== null;
if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup");
+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}
+135 -21
View File
@@ -22,6 +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";
@@ -260,13 +263,44 @@ export default function WaterLogSettingsPage() {
<form onSubmit={handleSave} className="space-y-6">
{message && (
<div
className={`rounded-lg border px-4 py-3 text-sm font-medium ${
message.type === "success"
? "border-[#1a4d2e] bg-[#1a4d2e]/5 text-[#1a4d2e]"
: "border-[#a4452b] bg-[#a4452b]/5 text-[#a4452b]"
}`}
role={message.type === "error" ? "alert" : "status"}
aria-live={message.type === "error" ? "assertive" : "polite"}
className={`smartsheet-slide-down flex items-start gap-3 rounded-lg border px-4 py-3 text-[13.5px] font-medium
${
message.type === "success"
? "border-[#1a4d2e]/25 bg-[#1a4d2e]/[0.05] text-[#143d24]"
: "border-[#a4452b]/25 bg-[#a4452b]/[0.05] text-[#7a341f]"
}`}
>
{message.text}
<span
aria-hidden
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-white ${
message.type === "success" ? "bg-[#1a4d2e]" : "bg-[#a4452b]"
}`}
>
{message.type === "success" ? (
<svg viewBox="0 0 12 12" className="h-3 w-3">
<path
d="M2 6.5L5 9.5L10 3.5"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg viewBox="0 0 12 12" className="h-3 w-3">
<path
d="M6 3V6.5M6 8.5V9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
)}
</span>
<p className="leading-snug">{message.text}</p>
</div>
)}
@@ -316,9 +350,22 @@ export default function WaterLogSettingsPage() {
type="button"
onClick={handleRegenerate}
disabled={regenerating}
className="shrink-0 rounded-lg border border-[#1a4d2e] bg-[#1a4d2e] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#143d24] disabled:opacity-50"
className="inline-flex min-h-[40px] shrink-0 items-center justify-center rounded-lg border border-[#1a4d2e] bg-[#1a4d2e] px-4 py-2
text-[14px] font-semibold text-white
motion-safe:transition-[background-color,transform] motion-safe:duration-150 motion-safe:ease-out
hover:bg-[#143d24]
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/30
active:scale-[0.97] active:bg-[#0f2e1c]
disabled:opacity-50 disabled:cursor-not-allowed"
>
{regenerating ? "Generating…" : "Regenerate PIN"}
{regenerating ? (
<span className="inline-flex items-center gap-2">
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 border-t-white motion-reduce:animate-none" />
Generating
</span>
) : (
"Regenerate PIN"
)}
</button>
</div>
)}
@@ -392,7 +439,10 @@ export default function WaterLogSettingsPage() {
dispatch({ type: "SET_ALERT_PHONE", value: e.target.value })
}
placeholder="+13035551234"
className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 text-base outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
className="mt-1 block min-h-[44px] w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5
text-[14px] text-[#1d1d1f] placeholder:text-[#a3a5a0]
outline-none motion-safe:transition-[border-color,box-shadow] motion-safe:duration-150
focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/15"
autoComplete="tel"
/>
<p className="mt-1 text-xs text-[#8a8b88]">
@@ -407,11 +457,66 @@ export default function WaterLogSettingsPage() {
<button
type="submit"
disabled={saving}
className="w-full rounded-lg bg-[#1a4d2e] px-6 py-3.5 text-sm font-semibold uppercase tracking-wider text-white transition hover:bg-[#143d24] disabled:opacity-50"
className="inline-flex min-h-[48px] w-full items-center justify-center rounded-lg bg-[#1a4d2e] px-6 py-3
text-[14px] font-semibold tracking-tight text-white
shadow-[0_1px_0_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,0.06)]
motion-safe:transition-[background-color,transform,box-shadow] motion-safe:duration-150 motion-safe:ease-out
hover:bg-[#143d24] hover:shadow-[0_2px_4px_rgba(15,23,18,0.1),inset_0_1px_0_rgba(255,255,255,0.06)]
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fdfaf2]
active:scale-[0.985] active:bg-[#0f2e1c]
disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? "Saving…" : "Save Settings"}
{saving ? (
<span className="inline-flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white motion-reduce:animate-none" />
Saving
</span>
) : (
"Save changes"
)}
</button>
</form>
{/* 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
</p>
<h2
className="mt-2 text-2xl font-medium text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Smartsheet
</h2>
<p className="mt-2 text-sm text-[#5a5d5a]">
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>
);
@@ -427,10 +532,16 @@ function Card({
children: React.ReactNode;
}) {
return (
<section className="rounded-xl border border-[#d4d9d3] bg-white p-5 shadow-sm">
<header className="mb-4">
<h2 className="text-base font-semibold text-[#1d1d1f]">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-[#5a5d5a]">{subtitle}</p>}
<section className="rounded-xl border border-[#d4d9d3] bg-white/95 p-6 shadow-[0_1px_2px_rgba(15,23,18,0.04),0_2px_6px_-2px_rgba(15,23,18,0.06)]">
<header className="mb-5">
<h2 className="text-[15px] font-semibold tracking-tight text-[#1d1d1f]">
{title}
</h2>
{subtitle && (
<p className="mt-1 text-[13px] leading-snug text-[#5a5d5a]">
{subtitle}
</p>
)}
</header>
{children}
</section>
@@ -460,14 +571,17 @@ function ToggleRow({
aria-checked={value}
aria-label={label}
onClick={() => onChange(!value)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
value ? "bg-[#1a4d2e]" : "bg-[#d4d9d3]"
}`}
className={`group relative inline-flex h-[26px] w-[46px] shrink-0 items-center rounded-full
motion-safe:transition-colors motion-safe:duration-200
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/25 focus-visible:ring-offset-2 focus-visible:ring-offset-white
active:scale-[0.97] motion-safe:transition-transform motion-safe:ease-out
${value ? "bg-[#1a4d2e]" : "bg-[#d4d9d3]"}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
value ? "translate-x-6" : "translate-x-1"
}`}
className={`inline-block h-[20px] w-[20px] transform rounded-full bg-white shadow-[0_1px_2px_rgba(0,0,0,0.2),0_1px_1px_rgba(0,0,0,0.06)]
motion-safe:transition-transform motion-safe:duration-200
motion-safe:ease-[cubic-bezier(0.32,0.72,0,1)]
${value ? "translate-x-[22px]" : "translate-x-[3px]"}`}
/>
</button>
</div>
+22 -2
View File
@@ -1,8 +1,10 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { redirect } from "next/navigation";
import { getWaterAdminSession } from "@/actions/water-log/auth";
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";
@@ -27,7 +29,7 @@ export default async function WaterLogUserPage({ params, searchParams }: UserPag
(adminUser?.role === "brand_admin" &&
adminUser?.brand_id === TUXEDO_BRAND_ID &&
adminUser?.can_manage_water_log);
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
const isWaterAdmin = waterSession !== null;
if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup");
@@ -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>
);
}
+9 -8
View File
@@ -226,14 +226,6 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
setTimeout(() => dispatch({ type: "set_msg", msg: null }), 4000);
}
if (state.loading) {
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<WholesaleLoadingSkeleton />
</div>
);
}
const tabs = [
{ value: "dashboard", label: "Dashboard", count: undefined },
{ value: "customers", label: "Customers", count: state.customers.filter(c => c.account_status !== "pending_approval" && c.account_status !== "rejected").length },
@@ -251,6 +243,13 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
className="mb-0"
/>
{state.loading ? (
<div className="px-6 py-6">
<WholesaleLoadingSkeleton />
</div>
) : (
<>
{/* Tab nav */}
<div className="px-6 pb-0">
<AdminFilterTabs
@@ -330,6 +329,8 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
/>
)}
</div>
</>
)}
</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 },
);
}
+11 -14
View File
@@ -1,32 +1,29 @@
/**
* POST /api/water-admin-auth
*
* Body: { brandId: string, pin: string }
* Body: { pin: string }
* Side effect: sets the `wl_admin_session` cookie on success.
*
* Used by the mobile/PIN-only `/water/admin/login` portal. The session
* itself is created by `verifyWaterAdminPin()` in
* `src/actions/water-log/settings.ts`, which is the single source of
* truth for admin PIN verification.
* Used by the mobile/PIN-only `/water/admin/login` portal. Brand is
* hardcoded (Tuxedo-only see CLAUDE.md "Public Storefront
* Architecture"). All auth logic lives in
* `src/lib/water-admin-pin-auth.ts`.
*/
import { NextResponse } from "next/server";
import { verifyWaterAdminPin } from "@/actions/water-log/settings";
import { verifyAdminPin } from "@/lib/water-admin-pin-auth";
import { captureError } from "@/lib/sentry";
export async function POST(request: Request) {
try {
const { brandId, pin } = (await request.json()) as {
brandId?: string;
pin?: string;
};
if (!brandId || !pin) {
const { pin } = (await request.json()) as { pin?: string };
if (!pin) {
return NextResponse.json(
{ success: false, error: "Missing brandId or pin" },
{ success: false, error: "Missing pin" },
{ status: 400 },
);
}
const result = await verifyWaterAdminPin(brandId, pin);
if (!result.success) {
const result = await verifyAdminPin(pin);
if (!result.ok) {
// Don't leak whether the PIN was wrong vs. not configured — both
// are the same to a field attacker probing the endpoint.
const status = result.error === "Invalid PIN" ? 401 : 403;
@@ -0,0 +1,111 @@
/**
* Cron-driven Smartsheet sync drain.
*
* Hit by Vercel cron (see vercel.json entry: every 15 minutes).
* Drains pending + due-retry queue rows for every brand with
* sync_enabled = true (regardless of frequency for realtime
* brands this is purely a backstop in case event-driven pushes
* failed; the cron recovers them via the same retry mechanism).
*
* Auth:
* - Bearer token in `Authorization: Bearer $CRON_SECRET` header,
* or `?secret=$CRON_SECRET` query param (Vercel cron supports
* neither natively; we use the header from a curl / GitHub
* Action / external scheduler). Falls back to `CRON_SECRET` for
* cross-compatibility with existing automation routes.
*
* Body (optional):
* - `{ brandId?: string }` restrict to one brand (useful for
* "Retry now" admin buttons). If omitted, runs for every active
* brand.
*/
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { withPlatformAdmin } from "@/db/client";
import { waterSmartsheetConfig } from "@/db/schema/water-log";
import { drainSyncQueue } from "@/services/smartsheet-sync";
const CRON_SECRET =
process.env.SMARTSHEET_CRON_SECRET ?? process.env.CRON_SECRET ?? "";
function unauthorized() {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
function authenticate(req: NextRequest): boolean {
if (!CRON_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 === CRON_SECRET || queryToken === CRON_SECRET;
}
async function listActiveBrandIds(): Promise<string[]> {
return withPlatformAdmin(async (db) => {
const rows = await db
.select({ id: waterSmartsheetConfig.brandId })
.from(waterSmartsheetConfig)
.where(eq(waterSmartsheetConfig.syncEnabled, true));
return rows.map((r) => r.id);
});
}
export async function POST(req: NextRequest) {
if (!authenticate(req)) return unauthorized();
let body: { brandId?: string } = {};
try {
// Body may be empty for the simple cron case.
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(async (brandId) => {
try {
const result = await drainSyncQueue(brandId);
return { ...result, error: null };
} catch (err) {
return {
brandId,
considered: 0,
synced: 0,
skipped: 0,
failed: 0,
error: (err as Error).message ?? "Unknown error",
};
}
}),
);
const totals = results.reduce(
(acc, r) => ({
considered: acc.considered + r.considered,
synced: acc.synced + r.synced,
skipped: acc.skipped + r.skipped,
failed: acc.failed + r.failed,
}),
{ considered: 0, synced: 0, skipped: 0, failed: 0 },
);
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);
}
+7 -1
View File
@@ -1,12 +1,18 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { uploadObject, BUCKETS } from "@/lib/storage";
import {
WL_ADMIN_SESSION_COOKIE,
WL_SESSION_COOKIE,
} from "@/lib/water-log/session";
export async function POST(request: Request) {
try {
// Validate session — irrigators use wl_session, admins use wl_admin_session
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value ?? cookieStore.get("wl_admin_session")?.value;
const sessionId =
cookieStore.get(WL_SESSION_COOKIE)?.value ??
cookieStore.get(WL_ADMIN_SESSION_COOKIE)?.value;
if (!sessionId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
+166
View File
@@ -302,6 +302,42 @@ select:-webkit-autofill:focus {
to { transform: rotate(360deg); }
}
/* 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 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); }
60% { transform: translateX(-7px); }
80% { transform: translateX(7px); }
}
/* Reduce motion: respect the user's OS-level preference. */
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
* but flattens everything that fights vestibular comfort. This block wipes:
@@ -1073,6 +1109,32 @@ select:-webkit-autofill:focus {
.atelier-backdrop {
animation: atelier-backdrop 140ms ease-out both;
}
/* Smartsheet integration polish Apple HIG-inspired micro-animations.
* Kept short (180220ms) and single-property to feel responsive. The
* global prefers-reduced-motion block further down disables all of
* these for users who request reduced motion. */
@keyframes smartsheet-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes smartsheet-sheet-up {
from { opacity: 0; transform: translateY(8px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes smartsheet-slide-down {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.smartsheet-fade-in {
animation: smartsheet-fade-in 180ms ease-out both;
}
.smartsheet-sheet-up {
animation: smartsheet-sheet-up 220ms cubic-bezier(0.32, 0.72, 0, 1) both;
}
.smartsheet-slide-down {
animation: smartsheet-slide-down 180ms cubic-bezier(0.32, 0.72, 0, 1) both;
}
.atelier-stagger > * {
animation: atelier-stagger 180ms ease-out both;
/* No more 80-440ms cumulative delays — items appear together, not as a parade. */
@@ -1321,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;
}
}
+550 -99
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>
<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."
}
/>
{/* 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)' }} />
<TheCornSection />
<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>
<PullQuoteBand />
<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>
<FounderStrip />
<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>
<FamilyTimeline />
<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"
/>
</div>
<VisitTheFarm address={address} />
{/* 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>
{/* Bottom transition */}
<div className="absolute -bottom-px left-0 right-0 h-3 bg-stone-50" />
</section>
{/* Mission Section */}
<Suspense fallback={<LoadingFallback bg="bg-stone-50" />}>
<MissionSection />
</Suspense>
{/* Family Timeline */}
<Suspense fallback={<LoadingFallback bg="bg-stone-100" />}>
<FamilyTimelineSection />
</Suspense>
{/* Contact Section */}
<Suspense fallback={<LoadingFallback bg="bg-stone-950" />}>
<ContactSection address={address} />
</Suspense>
{/* CTA Section */}
<Suspense fallback={<LoadingFallback bg="bg-white" />}>
<CTASection />
</Suspense>
<CTASection />
</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>
);
}
+527 -167
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";
supabase
.from("wholesale_settings")
.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));
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"
)
.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">
<LayoutContainer>
<div className="max-w-5xl mx-auto">
<main>
<ContactHero />
{/* 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.
</p>
</div>
{/* 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}
/>
{/* 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
</h2>
<div className="mt-4 h-px w-12 bg-emerald-600" />
</div>
{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={() => {
setSubmitted(false);
setForm({ name: "", email: "", topic: "general", message: "" });
}}
className="mt-5 text-sm font-medium text-emerald-700 underline hover:text-emerald-900"
{/* 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="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>
</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" }}
>
Send another message
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
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>
</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>
<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"
/>
<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>
<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"
/>
<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>
</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?"
</dl>
</FadeOnScroll>
</div>
{/* Right: form */}
<div className="lg:col-span-7">
<FadeOnScroll from="right">
{submitted ? (
<FormSuccess
onReset={() => {
setSubmitted(false);
setForm({ name: "", email: "", topic: "general", message: "" });
}}
/>
</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>
)}
) : (
<ContactForm
form={form}
setForm={setForm}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
)}
</FadeOnScroll>
</div>
</div>
</LayoutContainer>
</section>
{/* 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>
</div>
</div>
</LayoutContainer>
<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 />;
}
+270 -104
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="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 (
<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>
) : (
<>
{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"
<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 (
<li key={item.question}>
<button
type="button"
onClick={() => setOpen(isOpen ? null : item.question)}
className="group flex w-full items-start justify-between gap-6 py-6 sm:py-7 text-left"
aria-expanded={isOpen}
>
<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
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"
}`}
>
<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"
aria-expanded={isOpen}
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<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">
{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" />
</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>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
</span>
</button>
<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>
</div>
</li>
);
})}
</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>
</div>
{/* 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" />
</svg>
<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>
<input aria-label="Search Questions..."
type="text"
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"
/>
{searchQuery && (
<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" />
</svg>
</button>
)}
</div>
</LayoutContainer>
</section>
{/* FAQ categories */}
<div className="space-y-10 sm:space-y-14">
{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>
<FAQAccordion items={cat.items} searchQuery={searchQuery} />
</div>
))}
</div>
{/* 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
id="faq-search"
type="text"
placeholder="Search questions…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
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"
onClick={() => setSearchQuery("")}
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>
{/* 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" />
</svg>
</Link>
</div>
</div>
{/* 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}>
<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>
{/* 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}
</>
);
}
+477 -394
View File
File diff suppressed because it is too large Load Diff
+312 -110
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"
>
Back to Tuxedo Corn
</Link>
<h1
className="font-display text-stone-50 text-[clamp(2.5rem,5.4vw,4.5rem)] leading-[1.02] tracking-[-0.02em]"
style={{ textWrap: "balance" }}
>
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>
) : (
<div className="max-w-4xl mx-auto space-y-3">
{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>
</LayoutContainer>
</section>
<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>
))}
{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">
{pastStops.slice(0, 5).map((stop) => (
<div
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>
))}
</div>
{/* 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="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>
)}
</div>
)}
</LayoutContainer>
<ol className="border-t border-stone-900/15">
{upcomingStops.map((stop) => (
<StopRow key={stop.id} stop={stop} brandSlug={brandSlug} />
))}
</ol>
{pastStops.length > 0 && (
<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) => (
<StopRow
key={stop.id}
stop={stop}
brandSlug={brandSlug}
past
/>
))}
</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>
);
}
+257 -108
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">
<LayoutContainer>
<div className="max-w-5xl mx-auto">
{/* Back navigation */}
<div className="mb-10 flex items-center gap-2">
<main>
{/* Back nav — kept slim, no white card wrapper. */}
<div className="bg-stone-50 border-b border-stone-200/60">
<LayoutContainer>
<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}
</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}
</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."}
</p>
<div className="mt-6 h-px w-12 bg-emerald-600" />
</div>
{/* 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"
/>
{/* 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>
<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>
</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>
</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>
</FadeOnScroll>
</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>
{/* 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>
</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>
{/* 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
</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>
</div>
{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>
</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}
/>
);
@@ -3,9 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
type Props = { brandId: string };
export default function WaterAdminPinClient({ brandId }: Props) {
export default function WaterAdminPinClient() {
const router = useRouter();
const [pin, setPin] = useState("");
const [error, setError] = useState("");
@@ -21,7 +19,7 @@ export default function WaterAdminPinClient({ brandId }: Props) {
const res = await fetch("/api/water-admin-auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brandId, pin }),
body: JSON.stringify({ pin }),
});
const data = await res.json();
if (data.success) {
+14 -4
View File
@@ -1,10 +1,20 @@
import { redirect } from "next/navigation";
import WaterAdminPinClient from "./WaterAdminPinClient";
export const dynamic = "force-dynamic";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
/**
* Tuxedo-brand-specific water admin login.
*
* The 4-digit admin PIN gates `/water/admin/*` for the Tuxedo brand.
* Users must be able to enter the PIN WITHOUT being signed into the
* platform this is a PIN-only entry point for brand staff in the
* field. The auth itself lives in `src/lib/water-admin-pin-auth.ts`,
* which is self-contained (no Neon Auth).
*
* If you need a similar portal for another brand, duplicate this
* page under a different slug (e.g. `/water/admin-tuxedo/login`) and
* update the brand UUID in `water-admin-pin-auth.ts`.
*/
export default async function WaterAdminLoginPage() {
return (
<div className="min-h-screen bg-slate-100 flex flex-col items-center justify-center p-6">
@@ -13,7 +23,7 @@ export default async function WaterAdminLoginPage() {
<h1 className="text-2xl font-bold text-slate-900">Water Log Admin</h1>
<p className="text-sm text-slate-500 mt-1">Enter your 4-digit admin PIN</p>
</div>
<WaterAdminPinClient brandId={TUXEDO_BRAND_ID} />
<WaterAdminPinClient />
</div>
</div>
);
+3 -6
View File
@@ -1,21 +1,18 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import {
getWaterDisplaySummary,
getWaterHeadgatesAdmin,
getWaterIrrigators,
getWaterAlertLog,
} from "@/actions/water-log/admin";
import { getAdminSession, TUXEDO_BRAND_ID } from "@/lib/water-admin-pin-auth";
import WaterAdminClient from "@/components/water/WaterAdminClient";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export const dynamic = "force-dynamic";
export default async function WaterAdminPage() {
const cookieStore = await cookies();
const adminSession = cookieStore.get("wl_admin_session")?.value;
if (!adminSession) {
const session = await getAdminSession();
if (!session) {
redirect("/water/admin/login");
}
+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}
</>
);
}
+44
View File
@@ -1,5 +1,49 @@
/**
* Public `/water` route entrypoint for the mobile-first Tuxedo
* Water Log PIN-gated experience.
*
* As of 2026-07, the route renders `WaterFieldClient`, which now
* delegates to the Apple-HIG styled `MobileWaterApp` state machine
* (PIN Headgates Log Success). All screens are designed
* for phones and tablets in the field there is no desktop
* consideration.
*
* `display: "standalone"` + `viewportFit: "cover"` in the root
* layout, combined with the mobile-specific `<meta>` tags below,
* give this route a near-native feel when added to an iOS home
* screen as a PWA.
*/
import type { Metadata, Viewport } from "next";
import WaterFieldClient from "@/components/water/WaterFieldClient";
export const metadata: Metadata = {
title: "Water Log · Tuxedo Corn",
description:
"Field log of headgate water readings — PIN-protected, mobile-only.",
robots: {
index: false, // PIN-gated field tool — no public indexing
follow: false,
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Water Log",
},
};
// The water route is mobile-only — never scale above 1.0 (we don't
// want desktop users pinching-and-zooming a tool that's tuned for
// thumb-reach). The viewport-fit: cover setting in the root layout
// enables safe-area insets on iOS.
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: "#14532d",
viewportFit: "cover",
};
export default async function WaterPage() {
return <WaterFieldClient />;
}
-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}
+14 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useReducer, useCallback, useEffect } from "react";
import { useReducer, useCallback, useEffect, useMemo } from "react";
import {
getReportsSummary,
getOrdersByStopReport,
@@ -317,7 +317,19 @@ export default function ReportsDashboard({
}) {
const [state, dispatch] = useReducer(reducer, initialBrandId, makeInitialState);
const range: DateRange = buildRange(state.preset, state.customStart, state.customEnd);
// Memoize range — `buildRange` returns a new object each call (it uses
// `new Date()` for the end-date). Without this memo, `range` would have a
// fresh identity every render, which makes `fetchAll` a fresh identity
// every render (it's a useCallback depending on `range`), which makes the
// `useEffect(() => fetchAll(), [fetchAll])` below refire every render and
// dispatch SET_LOADING/SET_REPORTS, which re-renders → infinite loop.
// Memoizing on the primitive inputs pins range to user-driven changes
// only; the day-rollover case is intentionally not handled here (would
// require a separate timer to refresh `today`).
const range: DateRange = useMemo(
() => buildRange(state.preset, state.customStart, state.customEnd),
[state.preset, state.customStart, state.customEnd]
);
// Year is read from the clock; compute in the browser only to avoid
// hydration mismatches with the server. The setState is wrapped in an
+10 -7
View File
@@ -135,14 +135,17 @@ export default function SettingsClient({
paymentSettings,
currentUser,
}: Props) {
// Initial tab is determined by URL hash if present, otherwise "general".
// No useEffect needed — we read window.location.hash during the lazy
// initializer on the client. On the server we default to "general".
const [activeTab, setActiveTab] = useState<Tab>(() => {
if (typeof window === "undefined") return "general";
// Server renders the default tab; client picks up the URL hash after mount
// so the initial server HTML matches the first client render (avoids
// hydration mismatch). Reading window.location.hash in a lazy initializer
// is unsafe because the server has no hash and would render a different
// tab than the client.
const [activeTab, setActiveTab] = useState<Tab>("general");
useEffect(() => {
const hash = window.location.hash.slice(1);
return TABS.find((t) => t.hash === hash)?.id ?? "general";
});
const tab = TABS.find((t) => t.hash === hash)?.id;
if (tab) setActiveTab(tab);
}, []);
return (
<main className="min-h-screen bg-[var(--admin-bg)]">
File diff suppressed because it is too large Load Diff
@@ -52,7 +52,7 @@ export default function AdminFilterTabs({
return (
<div
className={`
flex rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]
flex overflow-x-auto rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]
${sizes.container} ${className}
`}
role="tablist"
@@ -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;
@@ -0,0 +1,556 @@
"use client";
/**
* SmartsheetIntegrationCard `/admin/water-log/settings` per-feature
* card for the Water Log Smartsheet integration.
*
* 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 + backfill + recent activity.
*
* Visual language matches the parent Water Log settings page
* (cream + forest palette, "Field Almanac" style).
*
* Threading brandId: takes `brandId` as a prop per CLAUDE.md ("Brand ID
* Threading"). The settings page still hardcodes TUXEDO_BRAND_ID; this
* card accepts it as a prop so the page can be lifted later without
* rewrites.
*/
import { useEffect, useReducer, useState, useCallback } from "react";
import {
getSmartsheetConfig,
saveSmartsheetConfig,
testSmartsheetConnection,
listSmartsheetSyncLog,
enqueueSmartsheetBackfill,
type EnqueueBackfillResult,
type SmartsheetConfigResponse,
type SmartsheetSyncLogEntry,
} from "@/actions/water-log/smartsheet";
import type {
SmartsheetColumnMapping,
SmartsheetFrequency,
} from "@/db/schema/water-log";
import { formatDateTime } from "@/lib/format-date";
const COLUMN_LABELS: Record<keyof SmartsheetColumnMapping, string> = {
entry_id: "Entry ID (UUID, for dedup)",
logged_at: "Logged at (timestamp)",
headgate: "Headgate name",
measurement: "Measurement",
unit: "Unit (CFS, GPM, …)",
irrigator: "Irrigator name",
notes: "Notes (optional)",
};
const REQUIRED_COLUMNS: (keyof SmartsheetColumnMapping)[] = [
"entry_id",
"logged_at",
];
const FREQUENCY_LABELS: Record<SmartsheetFrequency, string> = {
realtime: "Real-time (after every entry)",
every_15_minutes: "Every 15 minutes",
hourly: "Hourly",
};
type ColumnOption = { id: string; title: string; type: string };
type TestState =
| { state: "idle" }
| { state: "loading" }
| {
state: "ok";
sheetName: string;
columnCount: number;
columns: ColumnOption[];
}
| { state: "error"; message: string };
type State = {
loading: boolean;
saving: boolean;
message: { type: "success" | "error"; text: string } | null;
savedConfig: SmartsheetConfigResponse | null;
enabled: boolean;
sheetIdInput: string;
frequency: SmartsheetFrequency;
columns: ColumnOption[];
columnMapping: SmartsheetColumnMapping;
test: TestState;
recent: SmartsheetSyncLogEntry[];
backfill: { running: boolean; result: EnqueueBackfillResult | null };
};
type Action =
| { type: "load"; config: SmartsheetConfigResponse; recent: SmartsheetSyncLogEntry[] }
| { type: "loading"; loading: boolean }
| { type: "saving"; saving: boolean }
| { type: "message"; message: State["message"] }
| { type: "reset-message" }
| { type: "enabled"; value: boolean }
| { type: "sheet"; value: string }
| { type: "frequency"; value: SmartsheetFrequency }
| { type: "columns"; columns: ColumnOption[] }
| { type: "mapping"; value: SmartsheetColumnMapping }
| { type: "test"; value: TestState }
| { type: "backfill-running"; running: boolean }
| { type: "backfill-result"; result: EnqueueBackfillResult | null };
const INITIAL_STATE: State = {
loading: true,
saving: false,
message: null,
savedConfig: null,
enabled: false,
sheetIdInput: "",
frequency: "hourly",
columns: [],
columnMapping: {
entry_id: "",
logged_at: "",
headgate: null,
measurement: null,
unit: null,
irrigator: null,
notes: null,
},
test: { state: "idle" },
recent: [],
backfill: { running: false, result: null },
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "load":
return {
...state,
loading: false,
savedConfig: action.config,
recent: action.recent,
enabled: action.config.syncEnabled,
sheetIdInput: action.config.sheetId ?? "",
frequency: action.config.syncFrequency,
columnMapping:
action.config.columnMapping ?? state.columnMapping,
};
case "loading":
return { ...state, loading: action.loading };
case "saving":
return { ...state, saving: action.saving };
case "message":
return { ...state, message: action.message };
case "reset-message":
return { ...state, message: null };
case "enabled":
return { ...state, enabled: action.value };
case "sheet":
return { ...state, sheetIdInput: action.value };
case "frequency":
return { ...state, frequency: action.value };
case "columns":
return { ...state, columns: action.columns };
case "mapping":
return { ...state, columnMapping: action.value };
case "test":
return { ...state, test: action.value };
case "backfill-running":
return {
...state,
backfill: { ...state.backfill, running: action.running },
};
case "backfill-result":
return {
...state,
backfill: { running: false, result: action.result },
};
}
}
// ── Component ─────────────────────────────────────────────────────────────
export function SmartsheetIntegrationCard({ brandId }: { brandId: string }) {
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
// re-render trigger for inline status messages
const [, setTick] = useState(0);
// ── Initial load ──
useEffect(() => {
let cancelled = false;
void (async () => {
dispatch({ type: "loading", loading: true });
const [config, recent] = await Promise.all([
getSmartsheetConfig(brandId),
listSmartsheetSyncLog(brandId, 10),
]);
if (cancelled) return;
dispatch({ type: "load", config, recent });
})();
return () => {
cancelled = true;
};
}, [brandId]);
const refresh = useCallback(async () => {
const [config, recent] = await Promise.all([
getSmartsheetConfig(brandId),
listSmartsheetSyncLog(brandId, 10),
]);
dispatch({ type: "load", config, recent });
}, [brandId]);
// ── Save ──
const onSave = useCallback(async () => {
dispatch({ type: "saving", saving: true });
dispatch({ type: "reset-message" });
const result = await saveSmartsheetConfig(brandId, {
sheetId: state.sheetIdInput,
// Cycle 7: token is owned by the workspace; ignored on save.
token: "",
syncEnabled: state.enabled,
syncFrequency: state.frequency,
columnMapping: state.columnMapping,
});
if (!result.success) {
dispatch({
type: "message",
message: { type: "error", text: result.error ?? "Save failed" },
});
dispatch({ type: "saving", saving: false });
return;
}
await refresh();
dispatch({
type: "message",
message: { type: "success", text: "Saved." },
});
dispatch({ type: "saving", saving: false });
setTick((t) => t + 1);
}, [brandId, refresh, state.sheetIdInput, state.enabled, state.frequency, state.columnMapping]);
// ── Test connection ──
const onTest = useCallback(async () => {
if (!state.sheetIdInput) {
dispatch({
type: "test",
value: { state: "error", message: "Sheet ID is required to test" },
});
return;
}
dispatch({ type: "test", value: { state: "loading" } });
// Cycle 7: Test uses the workspace's saved token. We pass
// token: "" so the action falls through to resolveWorkspaceToken.
const result = await testSmartsheetConnection(
brandId,
state.sheetIdInput,
"",
);
if (result.success) {
dispatch({
type: "test",
value: {
state: "ok",
sheetName: result.sheetName,
columnCount: result.columnCount,
columns: result.columns,
},
});
dispatch({ type: "columns", columns: result.columns });
} else {
dispatch({
type: "test",
value: { state: "error", message: result.error },
});
}
}, [brandId, state.sheetIdInput]);
// ── Backfill ──
const onBackfill = useCallback(async () => {
dispatch({ type: "backfill-running", running: true });
const result = await enqueueSmartsheetBackfill(brandId);
dispatch({ type: "backfill-result", result });
await refresh();
}, [brandId, refresh]);
if (state.loading) {
return (
<div className="rounded-xl border border-[#d4d9d3] bg-white p-6">
<p className="text-sm text-[#5a5d5a]">Loading</p>
</div>
);
}
const hasWorkspaceToken = state.savedConfig?.hasToken === true;
const maskedToken = state.savedConfig?.maskedToken ?? null;
return (
<div className="rounded-xl border border-[#d4d9d3] bg-white p-6">
<div className="flex items-start justify-between">
<div>
<h3
className="text-lg font-medium text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Water Log Smartsheet
</h3>
<p className="mt-1 text-sm text-[#5a5d5a]">
Push every new water log entry to a sheet inside the connected
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.enabled}
onChange={(e) =>
dispatch({ type: "enabled", value: e.target.checked })
}
data-test="smartsheet-enabled"
/>
Enabled
</label>
</div>
{/* Workspace token reference */}
<div className="mt-4 rounded-md border border-[#d4d9d3] bg-[#fdfaf2] px-3 py-2 text-xs text-[#5a5d5a]">
{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>
{/* Sheet ID + Frequency */}
<div className="mt-5 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-[#5a5d5a]">
Sheet ID or URL
</label>
<input
type="text"
className="w-full rounded-lg border border-[#d4d9d3] bg-[#fdfaf2] px-3 py-2 text-sm text-[#1a4d2e]"
placeholder="e.g. 8309876543210123"
value={state.sheetIdInput}
onChange={(e) =>
dispatch({ type: "sheet", value: e.target.value })
}
data-test="smartsheet-sheet-id"
/>
<p className="mt-1 text-xs text-[#5a5d5a]">
Pick the sheet inside the workbook where water-log entries should land.
</p>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[#5a5d5a]">
Frequency
</label>
<select
value={state.frequency}
onChange={(e) =>
dispatch({
type: "frequency",
value: e.target.value as SmartsheetFrequency,
})
}
className="w-full rounded-lg border border-[#d4d9d3] bg-[#fdfaf2] px-3 py-2 text-sm"
data-test="smartsheet-frequency"
>
{(Object.keys(FREQUENCY_LABELS) as SmartsheetFrequency[]).map((f) => (
<option key={f} value={f}>
{FREQUENCY_LABELS[f]}
</option>
))}
</select>
</div>
</div>
{/* Actions */}
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
disabled={state.saving || !state.sheetIdInput}
onClick={onTest}
className="rounded-lg border border-[#1a4d2e] bg-white px-4 py-2 text-sm font-medium text-[#1a4d2e] hover:bg-[#fdfaf2] disabled:opacity-60"
data-test="smartsheet-test"
>
{state.test.state === "loading" ? "Testing…" : "Test Connection"}
</button>
<button
type="button"
disabled={state.saving}
onClick={onSave}
className="rounded-lg bg-[#1a4d2e] px-4 py-2 text-sm font-medium text-white hover:bg-[#14432a] disabled:opacity-60"
data-test="smartsheet-save"
>
{state.saving ? "Saving…" : "Save"}
</button>
<span className="ml-2 text-xs text-[#5a5d5a]">
{state.savedConfig?.lastSyncAt
? `Last sync ${formatDateTime(new Date(state.savedConfig.lastSyncAt))}`
: "Never synced"}
</span>
</div>
{state.test.state === "ok" && (
<div className="smartsheet-slide-down mt-3 rounded-lg border border-[#dfe9d4] bg-[#dfe9d4] p-3">
<p className="text-sm font-medium text-[#1a4d2e]">
Connected to &ldquo;{state.test.sheetName}&rdquo; {state.test.columnCount} columns
</p>
</div>
)}
{state.test.state === "error" && (
<div className="smartsheet-slide-down mt-3 rounded-lg border border-[#f0e1d8] bg-[#f0e1d8] p-3">
<p className="text-sm font-medium text-[#a4452b]">
{state.test.message}
</p>
</div>
)}
{state.message && (
<div
className={`smartsheet-fade-in mt-3 rounded-lg p-3 text-sm ${
state.message.type === "success"
? "bg-[#dfe9d4] text-[#1a4d2e]"
: "bg-[#f0e1d8] text-[#a4452b]"
}`}
>
{state.message.text}
</div>
)}
{/* Column mapping */}
{state.columns.length > 0 && (
<div className="mt-6 rounded-xl border border-[#d4d9d3] bg-[#fdfaf2] p-4">
<h4 className="text-sm font-semibold text-[#1a4d2e]">Column mapping</h4>
<p className="mt-1 text-xs text-[#5a5d5a]">
Required: <span className="font-mono">entry_id</span>,{" "}
<span className="font-mono">logged_at</span>. Others optional.
</p>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
{(Object.keys(COLUMN_LABELS) as Array<keyof SmartsheetColumnMapping>).map(
(key) => {
const required = REQUIRED_COLUMNS.includes(key);
const value = state.columnMapping[key] ?? "";
return (
<label key={key} className="block">
<span className="block text-xs font-medium text-[#5a5d5a]">
{COLUMN_LABELS[key]}
{required && <span className="ml-1 text-[#a4452b]">*</span>}
</span>
<select
value={value ?? ""}
onChange={(e) => {
const next = e.target.value === "" ? null : e.target.value;
dispatch({
type: "mapping",
value: {
...state.columnMapping,
[key]: next,
},
});
}}
className="mt-1 w-full rounded-md border border-[#d4d9d3] bg-white px-2 py-1.5 text-sm"
data-test={`smartsheet-col-${key}`}
>
<option value=""> not mapped </option>
{state.columns.map((c) => (
<option key={c.id} value={c.id}>
{c.title || `Column ${c.id}`}
</option>
))}
</select>
</label>
);
},
)}
</div>
</div>
)}
{/* Backfill */}
<div className="mt-6 rounded-xl border border-[#d4d9d3] bg-[#fdfaf2] p-4">
<h4 className="text-sm font-semibold text-[#1a4d2e]">Backfill</h4>
<p className="mt-1 text-xs text-[#5a5d5a]">
Push every existing water log entry to the sheet. Re-runs are safe
(existing entries are deduped by the sheet&rsquo;s Entry ID column).
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
disabled={state.backfill.running || !state.enabled}
onClick={onBackfill}
className="rounded-lg border border-[#1a4d2e] bg-white px-4 py-2 text-sm font-medium text-[#1a4d2e] hover:bg-white disabled:opacity-60"
>
{state.backfill.running ? "Running…" : "Backfill existing entries"}
</button>
{state.backfill.result && (
<span className="text-xs text-[#5a5d5a]">
{state.backfill.result.success
? `Considered ${state.backfill.result.considered}, queued ${state.backfill.result.newlyQueued}, synced ${state.backfill.result.drained.synced}, remaining ${state.backfill.result.drained.remaining}`
: state.backfill.result.error}
</span>
)}
</div>
</div>
{/* Recent activity */}
<div className="mt-6">
<h4 className="text-sm font-semibold text-[#1a4d2e]">Recent activity</h4>
<div className="mt-3 overflow-x-auto rounded-xl border border-[#d4d9d3]">
<table className="w-full text-left text-sm">
<thead className="bg-[#fdfaf2] text-xs uppercase tracking-wide text-[#5a5d5a]">
<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>
{state.recent.length === 0 && (
<tr>
<td className="px-3 py-3 text-center text-[#5a5d5a]" colSpan={4}>
No sync attempts yet.
</td>
</tr>
)}
{state.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>
</div>
);
}
export default SmartsheetIntegrationCard;
+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} />;
}

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