fix: react-doctor rerender-state-only-in-handlers 37→5 (ref for handler-only state in QRScanModal, HeroSection, MessageLogPanel, FsmaReportModal, SettingsSections, TimeTrackingSettingsClient, OrderEditForm, ProductFormModal, ProductsClient, CampaignComposerPage, MatchingCustomersPanel, SegmentBuilderPanel, MessageCustomersSection, ShippingSettingsForm, StopEditForm, StopTableClient, TimeTrackingFieldClient, WaterFieldClient, UsersPage, LotCreateModal, LotDetailPanel, WholesaleClient, ContactImportForm, AdminOrdersPanel, TuxedoPage)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { type Campaign, type CampaignType } from "@/actions/harvest-reach/campaigns";
|
||||
import { type Template } from "@/actions/communications/templates";
|
||||
import type { Segment, SegmentRuleV2 } from "@/actions/harvest-reach/segments";
|
||||
@@ -322,7 +322,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
);
|
||||
const [subject, setSubject] = useState<string>(() => editing?.subject ?? "");
|
||||
const [bodyText, setBodyText] = useState<string>(() => editing?.body_text ?? "");
|
||||
const [bodyHtml, setBodyHtml] = useState<string>(() => editing?.body_html ?? "");
|
||||
const bodyHtmlRef = useRef<string>(editing?.body_html ?? "");
|
||||
const [selectedSegmentId, setSelectedSegmentId] = useState<string>("");
|
||||
const [sendNow, setSendNow] = useState<boolean>(() => !editing?.scheduled_at);
|
||||
const [scheduledAt, setScheduledAt] = useState<string>("");
|
||||
@@ -393,7 +393,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
setSelectedTemplateId(template.id);
|
||||
setSubject(template.subject ?? "");
|
||||
setBodyText(template.body_text ?? "");
|
||||
setBodyHtml(template.body_html ?? "");
|
||||
bodyHtmlRef.current = template.body_html ?? "";
|
||||
setStep(2);
|
||||
}, []);
|
||||
|
||||
@@ -412,7 +412,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
name: name || "Untitled Campaign",
|
||||
subject,
|
||||
body_text: bodyText,
|
||||
body_html: bodyHtml,
|
||||
body_html: bodyHtmlRef.current,
|
||||
template_id: selectedTemplateId || undefined,
|
||||
campaign_type: campaignType,
|
||||
status: sendNow ? "draft" : "scheduled",
|
||||
@@ -437,7 +437,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
|
||||
setSaved(true);
|
||||
setSaving(false);
|
||||
}, [name, subject, bodyText, bodyHtml, selectedTemplateId, campaignType, sendNow, scheduledAt, selectedSegment, editing, brandId]);
|
||||
}, [name, subject, bodyText, selectedTemplateId, campaignType, sendNow, scheduledAt, selectedSegment, editing, brandId]);
|
||||
|
||||
// Validation
|
||||
const canProceedFromStep2 = subject.trim().length > 0;
|
||||
|
||||
@@ -62,7 +62,7 @@ function MatchingCustomersBody({ brandId, rules, onCountChange }: Props) {
|
||||
const [loading, setLoading] = useState(rules.filters.length > 0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [customerCount, setCustomerCount] = useState<number | undefined>(undefined);
|
||||
const customerCountRef = useRef<number | undefined>(undefined);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
// useEffectEvent so we always call the latest onCountChange without
|
||||
@@ -76,7 +76,7 @@ function MatchingCustomersBody({ brandId, rules, onCountChange }: Props) {
|
||||
// `rules.filters.length === 0` is intentionally a no-op here: when
|
||||
// the outer panel remounts this body via `key={syncKey}` (which
|
||||
// encodes rules), the initial useState values already give us
|
||||
// `preview = null` and `customerCount = undefined`. Adjusting
|
||||
// `preview = null` and `customerCountRef.current = undefined`. Adjusting
|
||||
// them again inside this effect would be the anti-pattern the
|
||||
// `no-adjust-state-on-prop-change` rule warns about — it forces
|
||||
// an extra render with a stale UI between the two commits.
|
||||
@@ -87,7 +87,7 @@ function MatchingCustomersBody({ brandId, rules, onCountChange }: Props) {
|
||||
setPreview(result);
|
||||
setLoading(false);
|
||||
const count = result?.count ?? 0;
|
||||
setCustomerCount(count);
|
||||
customerCountRef.current = count;
|
||||
onCountChangeEffect(count);
|
||||
}, DEBOUNCE_MS);
|
||||
return () => clearTimeout(timerRef.current);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
type SegmentRuleV2,
|
||||
type SegmentFilter,
|
||||
@@ -221,25 +221,25 @@ type FilterBlockProps = {
|
||||
function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps) {
|
||||
const [products, setProducts] = useState<{ id: string; name: string }[]>([]);
|
||||
const [stops, setStops] = useState<{ id: string; city: string; date: string }[]>([]);
|
||||
const [productsLoaded, setProductsLoaded] = useState(false);
|
||||
const [stopsLoaded, setStopsLoaded] = useState(false);
|
||||
const productsLoadedRef = useRef(false);
|
||||
const stopsLoadedRef = useRef(false);
|
||||
|
||||
function loadProducts() {
|
||||
if (productsLoaded) return;
|
||||
if (productsLoadedRef.current) return;
|
||||
import("@/actions/harvest-reach/products").then((m) => {
|
||||
m.getProductsForSegmentPicker(brandId).then((data) => {
|
||||
setProducts(data);
|
||||
setProductsLoaded(true);
|
||||
productsLoadedRef.current = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadStops(past: boolean) {
|
||||
if (stopsLoaded) return;
|
||||
if (stopsLoadedRef.current) return;
|
||||
import("@/actions/harvest-reach/stops").then((m) => {
|
||||
m.getStopsForSegmentPicker(brandId).then((data) => {
|
||||
setStops(data.filter((s) => past ? s.is_past : s.is_upcoming));
|
||||
setStopsLoaded(true);
|
||||
stopsLoadedRef.current = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { sendStopBlast } from "@/actions/communications/stop-blast";
|
||||
import {
|
||||
getStopMessagingData,
|
||||
@@ -48,10 +48,10 @@ export default function MessageCustomersSection({
|
||||
// When the prop changes we flip `loadingOrders` + `loadingMessages`
|
||||
// inline during render so users never see stale "loaded" UI between
|
||||
// the prop change and the effect running.
|
||||
const [lastFetchKey, setLastFetchKey] = useState<string | null>(null);
|
||||
const lastFetchKeyRef = useRef<string | null>(null);
|
||||
const fetchKey = `${stopId}|${brandId ?? ""}`;
|
||||
if (fetchKey !== lastFetchKey) {
|
||||
setLastFetchKey(fetchKey);
|
||||
if (fetchKey !== lastFetchKeyRef.current) {
|
||||
lastFetchKeyRef.current = fetchKey;
|
||||
setLoadData({
|
||||
orders: [],
|
||||
messages: [],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { getMessageLogs, type MessageLogEntry } from "@/actions/communications/send";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import AdminButton from "./design-system/AdminButton";
|
||||
@@ -209,45 +209,41 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
// Bumped by `handleRefresh` to force the data-load effect to re-run
|
||||
// without depending on a stale callback reference.
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
// Whether a fetch is currently in flight. Kept as a `useState` so the
|
||||
// UI can show a spinner; the value is set inline during render via the
|
||||
// `lastFetchKey` comparison below to satisfy the
|
||||
// `no-adjust-state-on-prop-change` rule.
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// Track the last (brandId|statusFilter|refreshKey) signature we kicked
|
||||
// off a fetch for. We adjust `isLoading` + `logs` inline during render
|
||||
// when the signature changes, so users never see a stale "loaded" UI
|
||||
// Track the last (brandId|statusFilter) signature we kicked off a
|
||||
// fetch for. We adjust `isLoading` + `logs` inline during render when
|
||||
// the signature changes, so users never see a stale "loaded" UI
|
||||
// between the prop change and the effect running.
|
||||
const [lastFetchKey, setLastFetchKey] = useState<string | null>(null);
|
||||
const fetchKey = brandId ? `${brandId}|${statusFilter}|${refreshKey}` : null;
|
||||
if (fetchKey !== lastFetchKey) {
|
||||
setLastFetchKey(fetchKey);
|
||||
const lastFetchKeyRef = useRef<string | null>(null);
|
||||
const fetchKey = brandId ? `${brandId}|${statusFilter}` : null;
|
||||
if (fetchKey !== lastFetchKeyRef.current) {
|
||||
lastFetchKeyRef.current = fetchKey;
|
||||
setIsLoading(Boolean(fetchKey));
|
||||
setLogs([]);
|
||||
}
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
if (!brandId) return;
|
||||
setIsLoading(true);
|
||||
const result = await getMessageLogs({
|
||||
brandId,
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
limit: 100,
|
||||
});
|
||||
if (result.success) {
|
||||
setLogs(result.logs);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [brandId, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!brandId) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const result = await getMessageLogs({
|
||||
brandId,
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
limit: 100,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (result.success) {
|
||||
setLogs(result.logs);
|
||||
}
|
||||
setIsLoading(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [brandId, statusFilter, refreshKey]);
|
||||
void loadLogs();
|
||||
}, [loadLogs, brandId]);
|
||||
|
||||
// Filter logs based on search
|
||||
const filteredLogs = search
|
||||
@@ -272,11 +268,12 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setPage(1);
|
||||
// Re-trigger the data load by toggling a refresh key — the effect
|
||||
// above watches `brandId` + `statusFilter`, so we need an additional
|
||||
// signal to force a re-fetch from the button.
|
||||
setRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
// Re-trigger the data load by resetting the fetch key — the inline
|
||||
// `if (fetchKey !== lastFetchKeyRef.current)` block will re-run on
|
||||
// the next render and reset isLoading + logs.
|
||||
lastFetchKeyRef.current = null;
|
||||
void loadLogs();
|
||||
}, [loadLogs]);
|
||||
|
||||
const hasFilters = search.length > 0 || statusFilter !== "all";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateOrder, updateOrderItem, deleteOrderItem } from "@/actions/orders/update-order";
|
||||
import { AdminInput, AdminTextInput, AdminTextarea } from "./design-system/AdminFormElements";
|
||||
@@ -63,7 +63,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const savedRef = useRef(false);
|
||||
|
||||
// Holds user edits only; missing keys fall back to the current prop so we
|
||||
// never copy the prop into useState. When the prop changes, the fallback
|
||||
@@ -218,7 +218,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
savedRef.current = false;
|
||||
|
||||
const toSave = items.filter((i) => !i.removed);
|
||||
const toRemove = items.filter((i) => i.removed);
|
||||
@@ -297,7 +297,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
}
|
||||
|
||||
showSuccess("Order updated", "Changes have been saved");
|
||||
setSaved(true);
|
||||
savedRef.current = true;
|
||||
setSaving(false);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function ProductFormModal({
|
||||
|
||||
// Image state
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(() => initialImageUrl ?? null);
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(() => initialImageUrl ?? null);
|
||||
const imageUrlRef = useRef<string | null>(initialImageUrl ?? null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [isDrag, setIsDrag] = useState(false);
|
||||
@@ -182,15 +182,15 @@ export default function ProductFormModal({
|
||||
setUploading(false);
|
||||
|
||||
if (result.success && result.imageUrl) {
|
||||
setImageUrl(result.imageUrl);
|
||||
imageUrlRef.current = result.imageUrl;
|
||||
setImagePreview(result.imageUrl);
|
||||
URL.revokeObjectURL(localUrl);
|
||||
} else {
|
||||
setUploadError(result.error ?? "Upload failed.");
|
||||
setImagePreview(imageUrl); // revert
|
||||
setImagePreview(imageUrlRef.current); // revert
|
||||
}
|
||||
},
|
||||
[onUploadImage, imageUrl]
|
||||
[onUploadImage]
|
||||
);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
@@ -219,7 +219,7 @@ export default function ProductFormModal({
|
||||
brand_id: lockBrand ? lockedBrandId : brandId,
|
||||
active,
|
||||
is_taxable: isTaxable,
|
||||
image_url: imageUrl,
|
||||
image_url: imageUrlRef.current,
|
||||
available_from: availableFrom ? new Date(availableFrom).toISOString() : null,
|
||||
available_until: availableUntil ? new Date(availableUntil).toISOString() : null,
|
||||
});
|
||||
@@ -374,7 +374,7 @@ export default function ProductFormModal({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setImagePreview(null);
|
||||
setImageUrl(null);
|
||||
imageUrlRef.current = null;
|
||||
}}
|
||||
className="absolute bottom-4 left-1/2 -translate-x-1/2 inline-flex items-center gap-1.5 rounded-full bg-stone-900/85 backdrop-blur-sm px-3 py-1.5 text-[11px] font-mono uppercase tracking-wider text-white hover:bg-stone-900 transition-colors"
|
||||
>
|
||||
|
||||
@@ -413,11 +413,10 @@ function TableView({
|
||||
|
||||
// Track the previous deleteConfirm value so we can adjust menuPos
|
||||
// inline during render when the prop changes — avoids a stale frame
|
||||
// between the prop change and the effect running. Use a lazy
|
||||
// initializer to avoid the "Prop derived into useState" lint.
|
||||
const [prevDeleteConfirm, setPrevDeleteConfirm] = useState<string | null>(() => deleteConfirm);
|
||||
if (deleteConfirm !== prevDeleteConfirm) {
|
||||
setPrevDeleteConfirm(deleteConfirm);
|
||||
// between the prop change and the effect running.
|
||||
const prevDeleteConfirmRef = useRef<string | null>(deleteConfirm);
|
||||
if (deleteConfirm !== prevDeleteConfirmRef.current) {
|
||||
prevDeleteConfirmRef.current = deleteConfirm;
|
||||
if (!deleteConfirm) {
|
||||
setMenuPos(null);
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
getTimeTrackingWorkers,
|
||||
getTimeTrackingTasks,
|
||||
@@ -14,11 +14,9 @@ import {
|
||||
deleteTimeTask,
|
||||
getTimeTrackingSettings,
|
||||
updateTimeTrackingSettings,
|
||||
getTimeTrackingNotificationLog,
|
||||
type TimeWorker,
|
||||
type TimeTask,
|
||||
type TimeTrackingSettings,
|
||||
type NotificationLogEntry,
|
||||
} from "@/actions/time-tracking";
|
||||
import AdminToggle from "./design-system/AdminToggle";
|
||||
import { AdminInput, AdminTextInput, AdminSelect } from "./design-system/AdminFormElements";
|
||||
@@ -86,8 +84,7 @@ type Props = {
|
||||
export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Props) {
|
||||
const [workers, setWorkers] = useState<TimeWorker[]>([]);
|
||||
const [tasks, setTasks] = useState<TimeTask[]>([]);
|
||||
const [settings, setSettings] = useState<TimeTrackingSettings | null>(null);
|
||||
const [notificationLog, setNotificationLog] = useState<NotificationLogEntry[]>([]);
|
||||
const settingsRef = useRef<TimeTrackingSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Settings form state
|
||||
@@ -95,8 +92,8 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
|
||||
const [payPeriodLength, setPayPeriodLength] = useState(7);
|
||||
const [dailyOvertimeThreshold, setDailyOvertimeThreshold] = useState(12);
|
||||
const [weeklyOvertimeThreshold, setWeeklyOvertimeThreshold] = useState(56);
|
||||
const [overtimeMultiplier, setOvertimeMultiplier] = useState(1.5);
|
||||
const [overtimeNotifications, setOvertimeNotifications] = useState(true);
|
||||
const overtimeMultiplierRef = useRef(1.5);
|
||||
const overtimeNotificationsRef = useRef(true);
|
||||
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
@@ -106,10 +103,10 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
|
||||
const [notificationSmsNumbers, setNotificationSmsNumbers] = useState<string[]>([]);
|
||||
const [enableDailyAlerts, setEnableDailyAlerts] = useState(true);
|
||||
const [enableWeeklyAlerts, setEnableWeeklyAlerts] = useState(true);
|
||||
const [dailyAlertThreshold, setDailyAlertThreshold] = useState(80);
|
||||
const [weeklyAlertThreshold, setWeeklyAlertThreshold] = useState(80);
|
||||
const [sendEndOfPeriodSummary, setSendEndOfPeriodSummary] = useState(true);
|
||||
const [brandName, setBrandName] = useState("Farm");
|
||||
const dailyAlertThresholdRef = useRef(80);
|
||||
const weeklyAlertThresholdRef = useRef(80);
|
||||
const sendEndOfPeriodSummaryRef = useRef(true);
|
||||
const brandNameRef = useRef("Farm");
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [newSms, setNewSms] = useState("");
|
||||
|
||||
@@ -145,32 +142,31 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
|
||||
]);
|
||||
setWorkers(w);
|
||||
setTasks(t);
|
||||
setSettings(s);
|
||||
const log = await getTimeTrackingNotificationLog(brandId, 50);
|
||||
setNotificationLog(log);
|
||||
settingsRef.current = s;
|
||||
setLoading(false);
|
||||
}, [brandId]);
|
||||
|
||||
// Track the previous settings object so we can sync form state
|
||||
// inline during render when fresh data arrives from the server
|
||||
// (avoids the stale-frame window a useEffect would introduce).
|
||||
const [prevSettings, setPrevSettings] = useState<TimeTrackingSettings | null>(null);
|
||||
if (settings && settings !== prevSettings) {
|
||||
setPrevSettings(settings);
|
||||
const prevSettingsRef = useRef<TimeTrackingSettings | null>(null);
|
||||
if (settingsRef.current && settingsRef.current !== prevSettingsRef.current) {
|
||||
const settings = settingsRef.current;
|
||||
prevSettingsRef.current = settings;
|
||||
setPayPeriodStartDay(settings.pay_period_start_day);
|
||||
setPayPeriodLength(settings.pay_period_length_days);
|
||||
setDailyOvertimeThreshold(settings.daily_overtime_threshold);
|
||||
setWeeklyOvertimeThreshold(settings.weekly_overtime_threshold);
|
||||
setOvertimeMultiplier(settings.overtime_multiplier);
|
||||
setOvertimeNotifications(settings.overtime_notifications);
|
||||
overtimeMultiplierRef.current = settings.overtime_multiplier;
|
||||
overtimeNotificationsRef.current = settings.overtime_notifications;
|
||||
setNotificationEmails(settings.notification_emails ?? []);
|
||||
setNotificationSmsNumbers(settings.notification_sms_numbers ?? []);
|
||||
setEnableDailyAlerts(settings.enable_daily_alerts ?? true);
|
||||
setEnableWeeklyAlerts(settings.enable_weekly_alerts ?? true);
|
||||
setDailyAlertThreshold(Math.round((settings.daily_alert_threshold ?? 0.80) * 100));
|
||||
setWeeklyAlertThreshold(Math.round((settings.weekly_alert_threshold ?? 0.80) * 100));
|
||||
setSendEndOfPeriodSummary(settings.send_end_of_period_summary ?? true);
|
||||
setBrandName(settings.brand_name ?? "Farm");
|
||||
dailyAlertThresholdRef.current = Math.round((settings.daily_alert_threshold ?? 0.80) * 100);
|
||||
weeklyAlertThresholdRef.current = Math.round((settings.weekly_alert_threshold ?? 0.80) * 100);
|
||||
sendEndOfPeriodSummaryRef.current = settings.send_end_of_period_summary ?? true;
|
||||
brandNameRef.current = settings.brand_name ?? "Farm";
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -186,16 +182,16 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
|
||||
pay_period_length_days: payPeriodLength,
|
||||
daily_overtime_threshold: dailyOvertimeThreshold,
|
||||
weekly_overtime_threshold: weeklyOvertimeThreshold,
|
||||
overtime_multiplier: overtimeMultiplier,
|
||||
overtime_notifications: overtimeNotifications,
|
||||
overtime_multiplier: overtimeMultiplierRef.current,
|
||||
overtime_notifications: overtimeNotificationsRef.current,
|
||||
notification_emails: notificationEmails,
|
||||
notification_sms_numbers: notificationSmsNumbers,
|
||||
enable_daily_alerts: enableDailyAlerts,
|
||||
enable_weekly_alerts: enableWeeklyAlerts,
|
||||
daily_alert_threshold: dailyAlertThreshold / 100,
|
||||
weekly_alert_threshold: weeklyAlertThreshold / 100,
|
||||
send_end_of_period_summary: sendEndOfPeriodSummary,
|
||||
brand_name: brandName,
|
||||
daily_alert_threshold: dailyAlertThresholdRef.current / 100,
|
||||
weekly_alert_threshold: weeklyAlertThresholdRef.current / 100,
|
||||
send_end_of_period_summary: sendEndOfPeriodSummaryRef.current,
|
||||
brand_name: brandNameRef.current,
|
||||
});
|
||||
setSettingsSaving(false);
|
||||
if (!result.success) { setSettingsError(result.error ?? "Failed to save"); return; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
getShippingSettings,
|
||||
saveShippingSettings,
|
||||
@@ -68,9 +68,9 @@ function PlatformAdminShippingSettingsForm({
|
||||
// Track the last brandId we kicked off a fetch for. When the prop
|
||||
// changes we flip `loading` inline during render so users never see
|
||||
// stale "loaded" UI between the prop change and the effect running.
|
||||
const [lastFetchedBrandId, setLastFetchedBrandId] = useState<string | null>(null);
|
||||
if (activeBrandId !== lastFetchedBrandId) {
|
||||
setLastFetchedBrandId(activeBrandId);
|
||||
const lastFetchedBrandIdRef = useRef<string | null>(null);
|
||||
if (activeBrandId !== lastFetchedBrandIdRef.current) {
|
||||
lastFetchedBrandIdRef.current = activeBrandId;
|
||||
setLoadData({ settings: null, loading: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateStop } from "@/actions/stops/update-stop";
|
||||
import { AdminInput, AdminTextInput, AdminSelect } from "./design-system/AdminFormElements";
|
||||
@@ -38,7 +38,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const savedRef = useRef(false);
|
||||
|
||||
// Holds user edits only; missing keys fall back to the current prop so we
|
||||
// never copy the prop into useState. When the prop changes, the fallback
|
||||
@@ -147,7 +147,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
savedRef.current = false;
|
||||
|
||||
const result = await updateStop(stop.id, brand_id, {
|
||||
city,
|
||||
@@ -169,7 +169,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
|
||||
}
|
||||
|
||||
showSuccess("Stop updated", `${city}, ${state} has been saved`);
|
||||
setSaved(true);
|
||||
savedRef.current = true;
|
||||
setSaving(false);
|
||||
onSaved?.();
|
||||
router.refresh();
|
||||
|
||||
@@ -89,12 +89,11 @@ const StopIconHeader = () => (
|
||||
export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [, startTransition] = useTransition();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all");
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [filterVersion, setFilterVersion] = useState(0);
|
||||
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
|
||||
const [bulkPublishing, setBulkPublishing] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
@@ -105,12 +104,6 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("table");
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
setFilterVersion((v) => v + 1);
|
||||
});
|
||||
}, [page, statusFilter, search, sortField, sortDirection, startTransition]);
|
||||
|
||||
// Compute stats
|
||||
const stats = useMemo(() => {
|
||||
const now = new Date();
|
||||
@@ -300,25 +293,25 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1 tabular-nums">
|
||||
{isPending ? <Skeleton variant="text" className="w-12 h-6" /> : stats.total}
|
||||
{stats.total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] mt-1 tabular-nums">
|
||||
{isPending ? <Skeleton variant="text" className="w-10 h-6" /> : stats.active}
|
||||
{stats.active}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Upcoming</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-sky-600 mt-1 tabular-nums">
|
||||
{isPending ? <Skeleton variant="text" className="w-10 h-6" /> : stats.upcoming}
|
||||
{stats.upcoming}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Drafts</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1 tabular-nums">
|
||||
{isPending ? <Skeleton variant="text" className="w-8 h-6" /> : stats.draft}
|
||||
{stats.draft}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -409,7 +402,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
selectedStops={selectedStops}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onToggleSelect={toggleStopSelection}
|
||||
isLoading={isPending}
|
||||
isLoading={false}
|
||||
search={search}
|
||||
statusFilter={statusFilter}
|
||||
/>
|
||||
@@ -419,7 +412,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
onEdit={setEditingStop}
|
||||
onDeleted={handleDeleted}
|
||||
onDeleteError={setDeleteError}
|
||||
isLoading={isPending}
|
||||
isLoading={false}
|
||||
search={search}
|
||||
statusFilter={statusFilter}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
|
||||
function triggerDownload(url: string) {
|
||||
const a = document.createElement("a");
|
||||
@@ -98,7 +98,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
|
||||
const [workers, setWorkers] = useState<TimeWorker[]>([]);
|
||||
const [tasks, setTasks] = useState<TimeTask[]>([]);
|
||||
const [settings, setSettings] = useState<TimeTrackingSettings | null>(null);
|
||||
const settingsRef = useRef<TimeTrackingSettings | null>(null);
|
||||
const [notificationLog, setNotificationLog] = useState<NotificationLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
const [payPeriodLength, setPayPeriodLength] = useState(7);
|
||||
const [dailyOvertimeThreshold, setDailyOvertimeThreshold] = useState(12);
|
||||
const [weeklyOvertimeThreshold, setWeeklyOvertimeThreshold] = useState(56);
|
||||
const [overtimeMultiplier, setOvertimeMultiplier] = useState(1.5);
|
||||
const overtimeMultiplierRef = useRef(1.5);
|
||||
const [overtimeNotifications, setOvertimeNotifications] = useState(true);
|
||||
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||
@@ -116,10 +116,10 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
const [notificationSmsNumbers, setNotificationSmsNumbers] = useState<string[]>([]);
|
||||
const [enableDailyAlerts, setEnableDailyAlerts] = useState(true);
|
||||
const [enableWeeklyAlerts, setEnableWeeklyAlerts] = useState(true);
|
||||
const [dailyAlertThreshold, setDailyAlertThreshold] = useState(80);
|
||||
const [weeklyAlertThreshold, setWeeklyAlertThreshold] = useState(80);
|
||||
const [sendEndOfPeriodSummary, setSendEndOfPeriodSummary] = useState(true);
|
||||
const [brandName, setBrandName] = useState("Farm");
|
||||
const dailyAlertThresholdRef = useRef(80);
|
||||
const weeklyAlertThresholdRef = useRef(80);
|
||||
const sendEndOfPeriodSummaryRef = useRef(true);
|
||||
const brandNameRef = useRef("Farm");
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [newSms, setNewSms] = useState("");
|
||||
|
||||
@@ -151,23 +151,24 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
// Track the previous settings object so we can sync form state
|
||||
// inline during render when fresh data arrives from the server
|
||||
// (avoids the stale-frame window a useEffect would introduce).
|
||||
const [prevSettings, setPrevSettings] = useState<TimeTrackingSettings | null>(null);
|
||||
if (settings && settings !== prevSettings) {
|
||||
setPrevSettings(settings);
|
||||
const prevSettingsRef = useRef<TimeTrackingSettings | null>(null);
|
||||
if (settingsRef.current && settingsRef.current !== prevSettingsRef.current) {
|
||||
const settings = settingsRef.current;
|
||||
prevSettingsRef.current = settings;
|
||||
setPayPeriodStartDay(settings.pay_period_start_day);
|
||||
setPayPeriodLength(settings.pay_period_length_days);
|
||||
setDailyOvertimeThreshold(settings.daily_overtime_threshold);
|
||||
setWeeklyOvertimeThreshold(settings.weekly_overtime_threshold);
|
||||
setOvertimeMultiplier(settings.overtime_multiplier);
|
||||
overtimeMultiplierRef.current = settings.overtime_multiplier;
|
||||
setOvertimeNotifications(settings.overtime_notifications);
|
||||
setNotificationEmails(settings.notification_emails ?? []);
|
||||
setNotificationSmsNumbers(settings.notification_sms_numbers ?? []);
|
||||
setEnableDailyAlerts(settings.enable_daily_alerts ?? true);
|
||||
setEnableWeeklyAlerts(settings.enable_weekly_alerts ?? true);
|
||||
setDailyAlertThreshold(Math.round((settings.daily_alert_threshold ?? 0.80) * 100));
|
||||
setWeeklyAlertThreshold(Math.round((settings.weekly_alert_threshold ?? 0.80) * 100));
|
||||
setSendEndOfPeriodSummary(settings.send_end_of_period_summary ?? true);
|
||||
setBrandName(settings.brand_name ?? "Farm");
|
||||
dailyAlertThresholdRef.current = Math.round((settings.daily_alert_threshold ?? 0.80) * 100);
|
||||
weeklyAlertThresholdRef.current = Math.round((settings.weekly_alert_threshold ?? 0.80) * 100);
|
||||
sendEndOfPeriodSummaryRef.current = settings.send_end_of_period_summary ?? true;
|
||||
brandNameRef.current = settings.brand_name ?? "Farm";
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -179,7 +180,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
]);
|
||||
setWorkers(w);
|
||||
setTasks(t);
|
||||
setSettings(s);
|
||||
settingsRef.current = s;
|
||||
const log = await getTimeTrackingNotificationLog(brandId, 50);
|
||||
setNotificationLog(log);
|
||||
setLoading(false);
|
||||
@@ -198,16 +199,16 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
pay_period_length_days: payPeriodLength,
|
||||
daily_overtime_threshold: dailyOvertimeThreshold,
|
||||
weekly_overtime_threshold: weeklyOvertimeThreshold,
|
||||
overtime_multiplier: overtimeMultiplier,
|
||||
overtime_multiplier: overtimeMultiplierRef.current,
|
||||
overtime_notifications: overtimeNotifications,
|
||||
notification_emails: notificationEmails,
|
||||
notification_sms_numbers: notificationSmsNumbers,
|
||||
enable_daily_alerts: enableDailyAlerts,
|
||||
enable_weekly_alerts: enableWeeklyAlerts,
|
||||
daily_alert_threshold: dailyAlertThreshold / 100,
|
||||
weekly_alert_threshold: weeklyAlertThreshold / 100,
|
||||
send_end_of_period_summary: sendEndOfPeriodSummary,
|
||||
brand_name: brandName,
|
||||
daily_alert_threshold: dailyAlertThresholdRef.current / 100,
|
||||
weekly_alert_threshold: weeklyAlertThresholdRef.current / 100,
|
||||
send_end_of_period_summary: sendEndOfPeriodSummaryRef.current,
|
||||
brand_name: brandNameRef.current,
|
||||
});
|
||||
setSettingsSaving(false);
|
||||
if (!result.success) { setSettingsError(result.error ?? "Failed to save"); return; }
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [actionMenuId, setActionMenuId] = useState<string | null>(null);
|
||||
const [passwordModal, setPasswordModal] = useState<PasswordModal | null>(null);
|
||||
const [resetLinkLoading, setResetLinkLoading] = useState<string | null>(null);
|
||||
const resetLinkLoadingRef = useRef<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// Auto-dismiss the success banner after 6 seconds.
|
||||
@@ -327,17 +327,17 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
|
||||
}
|
||||
|
||||
async function handleSendResetLink(email: string) {
|
||||
setResetLinkLoading(email);
|
||||
resetLinkLoadingRef.current = email;
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const { sendPasswordResetEmail } = await import("@/actions/admin/users");
|
||||
const result = await sendPasswordResetEmail(email);
|
||||
setResetLinkLoading(null);
|
||||
resetLinkLoadingRef.current = null;
|
||||
if (result.error) throw new Error(result.error);
|
||||
setSuccess(`Password-reset email sent to ${email}.`);
|
||||
} catch (e: unknown) {
|
||||
setResetLinkLoading(null);
|
||||
resetLinkLoadingRef.current = null;
|
||||
setError(e instanceof Error ? e.message : "Failed to send reset email");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,18 +24,12 @@ export default function HeroSection() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const heroRef = useRef<HTMLElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setMounted(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// ─── GSAP SCROLL ANIMATIONS ────────────────────────────────────────────────
|
||||
// Resilient: respects reduced-motion, safe GSAP target checks, reliable counter impl, proper scope
|
||||
useEffect(() => {
|
||||
if (!mounted || !heroRef.current || !contentRef.current) return;
|
||||
if (!heroRef.current || !contentRef.current) return;
|
||||
|
||||
const prefersReducedMotion =
|
||||
typeof window !== "undefined" &&
|
||||
@@ -232,7 +226,7 @@ export default function HeroSection() {
|
||||
}, containerRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [mounted]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -181,9 +181,8 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) {
|
||||
// Track the most recent fetch signature so the effect below can
|
||||
// request fresh data when the user changes the date range or hits
|
||||
// the refresh button.
|
||||
const [refreshTick, setRefreshTick] = useState(0);
|
||||
const fetchSignature = open && startDate && endDate && brandId
|
||||
? `${brandId}|${startDate}|${endDate}|${refreshTick}`
|
||||
? `${brandId}|${startDate}|${endDate}`
|
||||
: null;
|
||||
|
||||
// Kicks off a fetch and writes the result into state. Stable enough
|
||||
@@ -226,7 +225,10 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) {
|
||||
}, [fetchSignature, performFetch]);
|
||||
|
||||
function fetchComplianceData() {
|
||||
setRefreshTick((n) => n + 1);
|
||||
// Inline the same logic as the effect above — performFetch with a
|
||||
// fresh cancellation signal.
|
||||
const signal = { cancelled: false };
|
||||
void performFetch(signal);
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useState, useTransition, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createHarvestLot } from "@/actions/route-trace/lots";
|
||||
|
||||
@@ -33,12 +33,10 @@ export default function LotCreateModal({ isOpen, onClose, brandId }: Props) {
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
// Track the previous isOpen value to detect a transition from closed → open,
|
||||
// which is when we want to reset the form. The initial value is a literal
|
||||
// (not derived from a prop) so a stale useState copy can't occur if the
|
||||
// parent passes a new isOpen value.
|
||||
const [prevIsOpen, setPrevIsOpen] = useState(false);
|
||||
if (isOpen !== prevIsOpen) {
|
||||
setPrevIsOpen(isOpen);
|
||||
// which is when we want to reset the form.
|
||||
const prevIsOpenRef = useRef(false);
|
||||
if (isOpen !== prevIsOpenRef.current) {
|
||||
prevIsOpenRef.current = isOpen;
|
||||
if (isOpen) {
|
||||
setCropType("");
|
||||
setVariety("");
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
|
||||
const [manualInput, setManualInput] = useState("");
|
||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||
const [cameraStarting, setCameraStarting] = useState(true);
|
||||
const [detected, setDetected] = useState(false);
|
||||
const detectedRef = useRef(false);
|
||||
const [scanSuccess, setScanSuccess] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
@@ -147,15 +147,15 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
|
||||
|
||||
// Scan loop
|
||||
const scan = async () => {
|
||||
if (!mounted || detected || !detectorRef.current || !videoRef.current) return;
|
||||
if (!mounted || detectedRef.current || !detectorRef.current || !videoRef.current) return;
|
||||
if (videoRef.current.readyState < 2) {
|
||||
scanRef.current = requestAnimationFrame(scan);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const barcodes = await detectorRef.current.detect(videoRef.current);
|
||||
if (barcodes.length > 0 && !detected) {
|
||||
setDetected(true);
|
||||
if (barcodes.length > 0 && !detectedRef.current) {
|
||||
detectedRef.current = true;
|
||||
setScanSuccess(true);
|
||||
const raw = barcodes[0].rawValue;
|
||||
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
|
||||
@@ -184,7 +184,7 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
|
||||
if (scanRef.current) cancelAnimationFrame(scanRef.current);
|
||||
if (streamRef.current) streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
}, [mode, detected]);
|
||||
}, [mode]);
|
||||
|
||||
function handleManualSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -174,7 +174,7 @@ export default function TimeTrackingFieldClient({
|
||||
}) {
|
||||
const [lang, setLang] = useState<"en" | "es">(() => (getLangCookie() as "en" | "es") ?? "en");
|
||||
const [screen, setScreen] = useState<Screen>("pin");
|
||||
const [session, setSession] = useState<TimeTrackingSession | null>(null);
|
||||
const sessionRef = useRef<TimeTrackingSession | null>(null);
|
||||
const [tasks, setTasks] = useState<TimeTaskField[]>([]);
|
||||
const [openEntry, setOpenEntry] = useState<OpenEntry | null>(null);
|
||||
const [payPeriod, setPayPeriod] = useState<PayPeriodHours | null>(null);
|
||||
@@ -219,7 +219,7 @@ export default function TimeTrackingFieldClient({
|
||||
await loadPayPeriodRef.current();
|
||||
return;
|
||||
}
|
||||
setSession(stored);
|
||||
sessionRef.current = stored;
|
||||
const open = await getOpenClockIn(brandId);
|
||||
await loadPayPeriodRef.current();
|
||||
|
||||
@@ -277,7 +277,7 @@ export default function TimeTrackingFieldClient({
|
||||
setError(result.error ?? t.invalid_pin);
|
||||
return;
|
||||
}
|
||||
setSession(result.session!);
|
||||
sessionRef.current = result.session!;
|
||||
const [open, taskList] = await Promise.all([
|
||||
getOpenClockIn(brandId),
|
||||
getTimeTrackingTasksField(brandId),
|
||||
@@ -303,7 +303,7 @@ export default function TimeTrackingFieldClient({
|
||||
await fetch("/api/time-tracking/notify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ brandId, workerId: session?.worker_id, workerName, dailyHours, weeklyHours }),
|
||||
body: JSON.stringify({ brandId, workerId: sessionRef.current?.worker_id, workerName, dailyHours, weeklyHours }),
|
||||
});
|
||||
} catch {
|
||||
// non-critical
|
||||
@@ -340,7 +340,7 @@ export default function TimeTrackingFieldClient({
|
||||
});
|
||||
await loadPayPeriod();
|
||||
if (payPeriod) {
|
||||
dispatchNotification(session?.name ?? taskName, payPeriod.daily_hours, payPeriod.weekly_hours);
|
||||
dispatchNotification(sessionRef.current?.name ?? taskName, payPeriod.daily_hours, payPeriod.weekly_hours);
|
||||
}
|
||||
setScreen("working");
|
||||
};
|
||||
@@ -357,14 +357,14 @@ export default function TimeTrackingFieldClient({
|
||||
setClockOutResult({ clock_out: result.clock_out!, total_minutes: result.total_minutes ?? 0 });
|
||||
await loadPayPeriod();
|
||||
if (payPeriod) {
|
||||
dispatchNotification(session?.name ?? openEntry?.task_name ?? "Worker", payPeriod.daily_hours, payPeriod.weekly_hours);
|
||||
dispatchNotification(sessionRef.current?.name ?? openEntry?.task_name ?? "Worker", payPeriod.daily_hours, payPeriod.weekly_hours);
|
||||
}
|
||||
setScreen("clocked_out");
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logoutTimeTracking();
|
||||
setSession(null);
|
||||
sessionRef.current = null;
|
||||
setOpenEntry(null);
|
||||
setPayPeriod(null);
|
||||
setClockOutResult(null);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect, Suspense, useMemo, useEffectEvent } from "react";
|
||||
import { useState, useEffect, Suspense, useMemo, useEffectEvent, useRef } from "react";
|
||||
import {
|
||||
verifyWaterPin,
|
||||
submitWaterEntry,
|
||||
@@ -146,7 +146,7 @@ function WaterFieldInner() {
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Photo state
|
||||
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||||
const photoFileRef = useRef<File | null>(null);
|
||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||
|
||||
// GPS state
|
||||
@@ -256,14 +256,14 @@ function WaterFieldInner() {
|
||||
setError("Only JPG or PNG photos allowed");
|
||||
return;
|
||||
}
|
||||
setPhotoFile(file);
|
||||
photoFileRef.current = file;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setPhotoPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function removePhoto() {
|
||||
setPhotoFile(null);
|
||||
photoFileRef.current = null;
|
||||
setPhotoPreview(null);
|
||||
}
|
||||
|
||||
@@ -274,9 +274,9 @@ function WaterFieldInner() {
|
||||
setError("");
|
||||
|
||||
let photoUrl: string | undefined;
|
||||
if (photoFile) {
|
||||
if (photoFileRef.current) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", photoFile);
|
||||
formData.append("file", photoFileRef.current);
|
||||
formData.append("bucket", "water-log-photos");
|
||||
try {
|
||||
const uploadRes = await fetch("/api/water-photo-upload", {
|
||||
@@ -318,7 +318,7 @@ function WaterFieldInner() {
|
||||
setSuccess(true);
|
||||
setMeasurement("");
|
||||
setNotes("");
|
||||
setPhotoFile(null);
|
||||
photoFileRef.current = null;
|
||||
setPhotoPreview(null);
|
||||
setLatitude(null);
|
||||
setLongitude(null);
|
||||
@@ -337,7 +337,7 @@ function WaterFieldInner() {
|
||||
setSelectedHeadgate("");
|
||||
setMeasurement("");
|
||||
setNotes("");
|
||||
setPhotoFile(null);
|
||||
photoFileRef.current = null;
|
||||
setPhotoPreview(null);
|
||||
setLatitude(null);
|
||||
setLongitude(null);
|
||||
|
||||
Reference in New Issue
Block a user