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.
This commit is contained in:
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* TodayTab — the "Today" surface for the Water Log admin.
|
||||
*
|
||||
* Combines the hero gauge + summary stats with the recent entries
|
||||
* table + filter bar. Holds the entry CRUD, CSV export, and
|
||||
* "Generate Report" trigger.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AdminButton from "@/components/admin/design-system/AdminButton";
|
||||
import { WaterGauge, SeasonMark } from "@/components/water/icons";
|
||||
import {
|
||||
getWaterEntries,
|
||||
type AdminEntry,
|
||||
type AdminHeadgate,
|
||||
type AdminIrrigator,
|
||||
} from "@/actions/water-log/admin";
|
||||
import {
|
||||
downloadWaterLogCSV,
|
||||
filterWaterLogEntries,
|
||||
shapeWaterLogEntry,
|
||||
type IrrigationSeasonSettings,
|
||||
type WaterLogReportRow,
|
||||
} from "@/lib/water-log-reporting";
|
||||
import { formatDateTime } from "@/lib/format-date";
|
||||
import {
|
||||
DayInput,
|
||||
EmptyState,
|
||||
FilterChip,
|
||||
MonthSelect,
|
||||
SectionHeader,
|
||||
StatTile,
|
||||
} from "../shared/Primitives";
|
||||
import { DownloadIcon, ReportIcon, TableIcon } from "../shared/icons";
|
||||
import type { Action, NotifyFn, State } from "../types";
|
||||
|
||||
export type TodayTabProps = {
|
||||
brandId: string;
|
||||
state: State;
|
||||
todayLabel: string;
|
||||
todayEntries: AdminEntry[];
|
||||
todayTotal: number;
|
||||
inSeason: boolean;
|
||||
notify: NotifyFn;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
onPreviewReport: () => void;
|
||||
onSeasonChange: (s: IrrigationSeasonSettings) => void;
|
||||
onRecipientNameChange: (v: string) => void;
|
||||
};
|
||||
|
||||
export function TodayTab({
|
||||
brandId,
|
||||
state,
|
||||
todayLabel,
|
||||
todayEntries,
|
||||
todayTotal,
|
||||
inSeason,
|
||||
notify,
|
||||
dispatch,
|
||||
onPreviewReport,
|
||||
onSeasonChange,
|
||||
onRecipientNameChange,
|
||||
}: TodayTabProps) {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleExportCSV() {
|
||||
dispatch({ type: "SET_CSV_LOADING", value: true });
|
||||
try {
|
||||
const all = await getWaterEntries(brandId, 5000);
|
||||
const shaped: WaterLogReportRow[] = all.map(shapeWaterLogEntry);
|
||||
const exportFilters = {
|
||||
dateFrom: state.filters.dateFrom || undefined,
|
||||
dateTo: state.filters.dateTo || undefined,
|
||||
headgateId: state.filters.headgate || undefined,
|
||||
userId: state.filters.user || undefined,
|
||||
submittedVia: state.filters.via || undefined,
|
||||
};
|
||||
const filtered = filterWaterLogEntries(shaped, exportFilters);
|
||||
downloadWaterLogCSV(`water-log-${state.today}.csv`, filtered);
|
||||
notify(`Exported ${filtered.length} entries`);
|
||||
} finally {
|
||||
dispatch({ type: "SET_CSV_LOADING", value: false });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TodaySummary
|
||||
todayLabel={todayLabel}
|
||||
todayEntries={todayEntries}
|
||||
todayTotal={todayTotal}
|
||||
headgates={state.headgates}
|
||||
users={state.users}
|
||||
inSeason={inSeason}
|
||||
seasonStart={state.seasonStart}
|
||||
recipientName={state.recipientName}
|
||||
onToggleReportSettings={() => dispatch({ type: "TOGGLE_REPORT_SETTINGS" })}
|
||||
onSeasonChange={onSeasonChange}
|
||||
onRecipientNameChange={onRecipientNameChange}
|
||||
onPreviewReport={onPreviewReport}
|
||||
showReportSettings={state.showReportSettings}
|
||||
/>
|
||||
|
||||
<RecentEntriesSection
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
onEditEntry={(e) => router.push(`/admin/water-log/entries/${e.id}`)}
|
||||
onExportCSV={handleExportCSV}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TodaySummary (hero gauge + stats + report settings toggle) ──────────────
|
||||
function TodaySummary({
|
||||
todayLabel,
|
||||
todayEntries,
|
||||
todayTotal,
|
||||
headgates,
|
||||
users,
|
||||
inSeason,
|
||||
showReportSettings,
|
||||
seasonStart,
|
||||
recipientName,
|
||||
onToggleReportSettings,
|
||||
onSeasonChange,
|
||||
onRecipientNameChange,
|
||||
onPreviewReport,
|
||||
}: {
|
||||
todayLabel: string;
|
||||
todayEntries: AdminEntry[];
|
||||
todayTotal: number;
|
||||
headgates: AdminHeadgate[];
|
||||
users: AdminIrrigator[];
|
||||
inSeason: boolean;
|
||||
showReportSettings: boolean;
|
||||
seasonStart: IrrigationSeasonSettings;
|
||||
recipientName: string;
|
||||
onToggleReportSettings: () => void;
|
||||
onSeasonChange: (s: IrrigationSeasonSettings) => void;
|
||||
onRecipientNameChange: (v: string) => void;
|
||||
onPreviewReport: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="relative overflow-hidden rounded-2xl border border-[#d4d9d3] bg-[#fdfaf2] shadow-[0_1px_0_rgba(0,0,0,0.04)]">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.18]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(45deg, transparent 0 14px, rgba(26,77,46,0.06) 14px 15px), repeating-linear-gradient(-45deg, transparent 0 14px, rgba(202,138,4,0.04) 14px 15px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative grid grid-cols-1 gap-6 p-6 md:grid-cols-[200px_1fr] md:gap-8">
|
||||
{/* Hero gauge */}
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
|
||||
Tuxedo Ditch Co.
|
||||
</div>
|
||||
<WaterGauge
|
||||
size={120}
|
||||
level={
|
||||
todayEntries.length === 0
|
||||
? null
|
||||
: Math.min(1, todayTotal / Math.max(todayTotal, 100))
|
||||
}
|
||||
status="open"
|
||||
ariaLabel="Today's water level"
|
||||
/>
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
|
||||
Vol. {todayTotal.toFixed(2)} {headgates[0]?.unit ?? "CFS"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2
|
||||
className="text-2xl font-semibold tracking-tight text-[#1a4d2e]"
|
||||
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||
>
|
||||
Today's Summary
|
||||
</h2>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-[#57694e]">
|
||||
<SeasonMark inSeason={inSeason} size={14} />
|
||||
{inSeason ? (
|
||||
<span className="font-medium text-[#1a4d2e]">
|
||||
In irrigation season
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium text-[#a16207]">
|
||||
Outside irrigation season
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[#57694e]/60">·</span>
|
||||
<span className="font-mono text-[#1d1d1f]">
|
||||
{todayEntries.length}{" "}
|
||||
{todayEntries.length === 1 ? "entry" : "entries"}
|
||||
</span>
|
||||
<span className="text-[#57694e]/60">·</span>
|
||||
<span className="text-[#57694e]">{todayLabel}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatTile
|
||||
label="Total volume"
|
||||
value={todayTotal.toFixed(2)}
|
||||
unit={headgates[0]?.unit ?? "CFS"}
|
||||
accent
|
||||
/>
|
||||
<StatTile
|
||||
label="Headgates"
|
||||
value={headgates.filter((h) => h.active).length.toString()}
|
||||
unit={`of ${headgates.length}`}
|
||||
/>
|
||||
<StatTile
|
||||
label="Active irrigators"
|
||||
value={users.filter((u) => u.active).length.toString()}
|
||||
unit={`of ${users.length}`}
|
||||
/>
|
||||
<StatTile
|
||||
label="Last entry"
|
||||
value={
|
||||
todayEntries.length
|
||||
? formatDateTime(todayEntries[0].logged_at)
|
||||
.split(",")
|
||||
.pop()
|
||||
?.trim() ?? "—"
|
||||
: "—"
|
||||
}
|
||||
unit=""
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<ReportIcon />}
|
||||
onClick={onPreviewReport}
|
||||
>
|
||||
Preview Report
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant={showReportSettings ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
icon={
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
>
|
||||
<circle cx="8" cy="8" r="2.2" />
|
||||
<path d="M8 1.5 V3.5 M8 12.5 V14.5 M1.5 8 H3.5 M12.5 8 H14.5 M3.3 3.3 L4.7 4.7 M11.3 11.3 L12.7 12.7 M3.3 12.7 L4.7 11.3 M11.3 4.7 L12.7 3.3" />
|
||||
</svg>
|
||||
}
|
||||
onClick={onToggleReportSettings}
|
||||
>
|
||||
Report Settings
|
||||
</AdminButton>
|
||||
</div>
|
||||
|
||||
{showReportSettings && (
|
||||
<ReportSettingsInline
|
||||
season={seasonStart}
|
||||
onSeasonChange={onSeasonChange}
|
||||
recipientName={recipientName}
|
||||
onRecipientNameChange={onRecipientNameChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inline Report Settings (kept under the toggle button) ───────────────────
|
||||
function ReportSettingsInline({
|
||||
season,
|
||||
onSeasonChange,
|
||||
recipientName,
|
||||
onRecipientNameChange,
|
||||
}: {
|
||||
season: IrrigationSeasonSettings;
|
||||
onSeasonChange: (s: IrrigationSeasonSettings) => void;
|
||||
recipientName: string;
|
||||
onRecipientNameChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#d4d9d3] bg-white p-4">
|
||||
<p className="mb-3 rounded-lg bg-[#fef9c3] px-3 py-2 text-xs text-[#a16207]">
|
||||
<strong>Preview only.</strong> Daily-report delivery is wired through
|
||||
the scheduled <code>api/cron/water-report</code> endpoint and respects
|
||||
these settings.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<ReportSeasonRow
|
||||
label="Season start"
|
||||
month={season.seasonStartMonth}
|
||||
day={season.seasonStartDay}
|
||||
onMonthChange={(m) =>
|
||||
onSeasonChange({ ...season, seasonStartMonth: m })
|
||||
}
|
||||
onDayChange={(d) => onSeasonChange({ ...season, seasonStartDay: d })}
|
||||
/>
|
||||
<ReportSeasonRow
|
||||
label="Season end"
|
||||
month={season.seasonEndMonth}
|
||||
day={season.seasonEndDay}
|
||||
onMonthChange={(m) =>
|
||||
onSeasonChange({ ...season, seasonEndMonth: m })
|
||||
}
|
||||
onDayChange={(d) => onSeasonChange({ ...season, seasonEndDay: d })}
|
||||
/>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
|
||||
Recipient name (used in report salutation)
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={recipientName}
|
||||
onChange={(e) => onRecipientNameChange(e.target.value)}
|
||||
placeholder="David"
|
||||
aria-label="Recipient name"
|
||||
className="w-full rounded-lg border border-[#d4d9d3] px-3 py-2 text-sm outline-none focus:border-[#1a4d2e]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportSeasonRow({
|
||||
label,
|
||||
month,
|
||||
day,
|
||||
onMonthChange,
|
||||
onDayChange,
|
||||
}: {
|
||||
label: string;
|
||||
month: number;
|
||||
day: number;
|
||||
onMonthChange: (m: number) => void;
|
||||
onDayChange: (d: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<MonthSelect value={month} onChange={onMonthChange} />
|
||||
<DayInput value={day} onChange={onDayChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── RecentEntriesSection (table + filters) ──────────────────────────────────
|
||||
function RecentEntriesSection({
|
||||
state,
|
||||
dispatch,
|
||||
onEditEntry,
|
||||
onExportCSV,
|
||||
}: {
|
||||
state: State;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
onEditEntry: (e: AdminEntry) => void;
|
||||
onExportCSV: () => void;
|
||||
}) {
|
||||
const filteredEntries = state.entries.filter((e) => {
|
||||
if (state.filters.dateFrom && e.logged_at < state.filters.dateFrom)
|
||||
return false;
|
||||
if (
|
||||
state.filters.dateTo &&
|
||||
e.logged_at > state.filters.dateTo + "T23:59:59.999Z"
|
||||
)
|
||||
return false;
|
||||
if (state.filters.headgate && e.headgate_id !== state.filters.headgate)
|
||||
return false;
|
||||
if (state.filters.user && e.user_id !== state.filters.user) return false;
|
||||
if (state.filters.via && e.submitted_via !== state.filters.via)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
index="§ 03"
|
||||
title="Recent Entries"
|
||||
subtitle="The latest readings logged from the field or the admin panel. Use the filters to narrow, then export to CSV."
|
||||
action={
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onClick={onExportCSV}
|
||||
isLoading={state.csvLoading}
|
||||
icon={<DownloadIcon />}
|
||||
>
|
||||
Export CSV
|
||||
</AdminButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-xl border border-[#d4d9d3] bg-white p-2.5">
|
||||
<FilterChip label="From">
|
||||
<input
|
||||
type="date"
|
||||
value={state.filters.dateFrom}
|
||||
onChange={(e) =>
|
||||
dispatch({ type: "SET_FILTER_DATE_FROM", value: e.target.value })
|
||||
}
|
||||
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
|
||||
aria-label="Filter: from date"
|
||||
/>
|
||||
</FilterChip>
|
||||
<FilterChip label="To">
|
||||
<input
|
||||
type="date"
|
||||
value={state.filters.dateTo}
|
||||
onChange={(e) =>
|
||||
dispatch({ type: "SET_FILTER_DATE_TO", value: e.target.value })
|
||||
}
|
||||
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
|
||||
aria-label="Filter: to date"
|
||||
/>
|
||||
</FilterChip>
|
||||
<FilterChip label="Headgate">
|
||||
<select
|
||||
value={state.filters.headgate}
|
||||
onChange={(e) =>
|
||||
dispatch({ type: "SET_FILTER_HEADGATE", value: e.target.value })
|
||||
}
|
||||
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
|
||||
aria-label="Filter: headgate"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{state.headgates.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FilterChip>
|
||||
<FilterChip label="User">
|
||||
<select
|
||||
value={state.filters.user}
|
||||
onChange={(e) =>
|
||||
dispatch({ type: "SET_FILTER_USER", value: e.target.value })
|
||||
}
|
||||
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
|
||||
aria-label="Filter: user"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{state.users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FilterChip>
|
||||
<FilterChip label="Via">
|
||||
<select
|
||||
value={state.filters.via}
|
||||
onChange={(e) =>
|
||||
dispatch({ type: "SET_FILTER_VIA", value: e.target.value })
|
||||
}
|
||||
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
|
||||
aria-label="Filter: submission source"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="field">Field</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="app">App</option>
|
||||
</select>
|
||||
</FilterChip>
|
||||
{(state.filters.dateFrom ||
|
||||
state.filters.dateTo ||
|
||||
state.filters.headgate ||
|
||||
state.filters.user ||
|
||||
state.filters.via) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "CLEAR_FILTERS" })}
|
||||
className="text-xs font-medium text-[#57694e] hover:text-[#1a4d2e] hover:underline"
|
||||
aria-label="Clear filters"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
<span className="ml-auto font-mono text-[11px] text-[#86868b]">
|
||||
{filteredEntries.length} of {state.entries.length} entries
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{filteredEntries.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<TableIcon />}
|
||||
title="No entries match"
|
||||
body={
|
||||
state.entries.length === 0
|
||||
? "No readings have been logged yet."
|
||||
: "Try clearing the filters above."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-[#d4d9d3] bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#d4d9d3] bg-[#f5f3ef] text-left font-mono text-[10px] uppercase tracking-[0.15em] text-[#57694e]">
|
||||
<th className="px-4 py-2.5">When</th>
|
||||
<th className="px-4 py-2.5">User</th>
|
||||
<th className="px-4 py-2.5">Headgate</th>
|
||||
<th className="px-4 py-2.5 text-right">Measurement</th>
|
||||
<th className="px-4 py-2.5 text-right">Gallons</th>
|
||||
<th className="px-4 py-2.5">Method</th>
|
||||
<th className="px-4 py-2.5">Via</th>
|
||||
<th className="px-4 py-2.5" aria-label="Actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredEntries.map((e, i) => (
|
||||
<tr
|
||||
key={e.id}
|
||||
className={`group border-b border-[#e8ebe8] last:border-0 transition-colors hover:bg-[#fdfaf2] ${
|
||||
i % 2 === 0 ? "" : "bg-[#fafaf7]"
|
||||
}`}
|
||||
>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-[#57694e]">
|
||||
{formatDateTime(e.logged_at)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-[#1d1d1f]">
|
||||
{e.user_name}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-[#1d1d1f]">
|
||||
{e.headgate_name}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-right font-mono font-semibold text-[#1d1d1f]">
|
||||
{e.measurement}{" "}
|
||||
<span className="text-[10px] text-[#86868b]">{e.unit}</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-right font-mono text-[#57694e]">
|
||||
{e.total_gallons != null
|
||||
? e.total_gallons.toFixed(1)
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-[11px] uppercase text-[#57694e]">
|
||||
{e.method}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5">
|
||||
<span
|
||||
className={`inline-block rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider ${
|
||||
e.submitted_via === "field"
|
||||
? "bg-[#dcfce7] text-[#15803d]"
|
||||
: "bg-[#e8ebe8] text-[#57694e]"
|
||||
}`}
|
||||
>
|
||||
{e.submitted_via}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditEntry(e)}
|
||||
className="font-medium text-[#1a4d2e] opacity-0 transition-opacity group-hover:opacity-100 hover:underline"
|
||||
aria-label="Edit →"
|
||||
>
|
||||
Edit →
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user