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