From f9dfd8826e917ad61a50bc0472b86b9aa9e59db0 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 26 Apr 2026 10:19:25 +0330 Subject: [PATCH] feat(pricing): manage workspace member rates in edit flows --- src/pages/Timesheet.tsx | 373 ++++++++++++++++++++++------------------ 1 file changed, 205 insertions(+), 168 deletions(-) diff --git a/src/pages/Timesheet.tsx b/src/pages/Timesheet.tsx index 53d4d3d..4166265 100644 --- a/src/pages/Timesheet.tsx +++ b/src/pages/Timesheet.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react"; +import { CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { getProjects, type Project } from "../api/projects"; @@ -531,10 +531,10 @@ function TagMultiSelect({ title: string; compact?: boolean; portalOwnerId?: string; -}) { - const [isOpen, setIsOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const wrapperRef = useRef(null); +}) { + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const wrapperRef = useRef(null); const buttonRef = useRef(null); const dropdownRef = useRef(null); const [dropdownStyle, setDropdownStyle] = useState({}); @@ -586,21 +586,21 @@ function TagMultiSelect({ window.removeEventListener("resize", closeOnViewportChange); window.removeEventListener("scroll", closeOnViewportChange, true); }; - }, [isOpen]); - - useEffect(() => { - if (!isOpen) { - setSearchQuery(""); - } - }, [isOpen]); - - const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name); - const joinedSelectedLabels = selectedLabels.join(" | "); - const normalizedSearch = searchQuery.trim().toLowerCase(); - const filteredTags = normalizedSearch - ? tags.filter((tag) => tag.name.toLowerCase().includes(normalizedSearch)) - : tags; - const buttonLabel = compact + }, [isOpen]); + + useEffect(() => { + if (!isOpen) { + setSearchQuery(""); + } + }, [isOpen]); + + const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name); + const joinedSelectedLabels = selectedLabels.join(" | "); + const normalizedSearch = searchQuery.trim().toLowerCase(); + const filteredTags = normalizedSearch + ? tags.filter((tag) => tag.name.toLowerCase().includes(normalizedSearch)) + : tags; + const buttonLabel = compact ? selectedTags.length > 0 ? joinedSelectedLabels : "" @@ -640,32 +640,32 @@ function TagMultiSelect({ {isOpen && ( createPortal( -
- {tags.length === 0 ? ( -

{emptyHint}

- ) : ( - <> -
-
- - setSearchQuery(event.target.value)} - placeholder="Search tags..." - className="h-8 w-full rounded-md border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800" - /> -
-
-
- {filteredTags.map((tag) => { - const selected = selectedTags.includes(tag.id); - return ( +
+ {tags.length === 0 ? ( +

{emptyHint}

+ ) : ( + <> +
+
+ + setSearchQuery(event.target.value)} + placeholder="Search tags..." + className="h-8 w-full rounded-md border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800" + /> +
+
+
+ {filteredTags.map((tag) => { + const selected = selectedTags.includes(tag.id); + return ( ); - })} - {filteredTags.length === 0 && ( -
- No tags found. -
- )} -
- - )} -
, + })} + {filteredTags.length === 0 && ( +
+ No tags found. +
+ )} +
+ + )} +
, document.body ) )} @@ -1557,7 +1557,7 @@ function MobileRecordedEntryCard({
{formatTimeOnly(entry.start_time)} - {formatTimeOnly(entry.end_time)} - {formatDateTime(entry.start_time, "en")} + {/* {formatDateTime(entry.start_time, "en")} */}
@@ -1676,8 +1676,12 @@ export default function Timesheet() { const [timerDraft, setTimerDraft] = useState(EMPTY_TIMER_DRAFT); const [isStartingTimer, setIsStartingTimer] = useState(false); + const desktopTimerRef = useRef(null); + const mobileTimerRef = useRef(null); const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT)); - const timerSaveTimeoutRef = useRef(null); + const pendingTimerSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT)); + const isTimerSavingRef = useRef(false); + const timerEditorOwnerId = "running-timer-editor"; const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({ isOpen: false, @@ -1823,62 +1827,87 @@ export default function Timesheet() { useEffect(() => { if (!runningEntry) { timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); + pendingTimerSignatureRef.current = timerDraftSignatureRef.current; setTimerDraft(EMPTY_TIMER_DRAFT); return; } const nextDraft = buildTimerDraftState(runningEntry); timerDraftSignatureRef.current = serializeTimerDraft(nextDraft); + pendingTimerSignatureRef.current = timerDraftSignatureRef.current; setTimerDraft(nextDraft); }, [runningEntry]); - useEffect(() => { + const isInsideTimerEditorContext = useCallback((target: EventTarget | null) => { + if (!(target instanceof Node)) return false; + if (desktopTimerRef.current?.contains(target) || mobileTimerRef.current?.contains(target)) return true; + return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${timerEditorOwnerId}"]`)); + }, [timerEditorOwnerId]); + + const commitTimerDraft = useCallback(async () => { const saveErrorText = extendedTimesheet.saveError || "Failed to save time entry"; const saveSuccessText = extendedTimesheet.saveSuccess || "Time entry saved"; - if (!runningEntry) return; + if (!runningEntry) return false; const currentSignature = serializeTimerDraft(timerDraft); - if (currentSignature === timerDraftSignatureRef.current) return; - - if (timerSaveTimeoutRef.current) { - window.clearTimeout(timerSaveTimeoutRef.current); + if (currentSignature === timerDraftSignatureRef.current) { + return false; } - timerSaveTimeoutRef.current = window.setTimeout(async () => { - try { - const updatedEntry = await updateTimeEntry(runningEntry.id, { - description: timerDraft.description.trim(), - project_id: timerDraft.projectId || null, - tags: timerDraft.tags, - is_billable: timerDraft.isBillable, - }); + if (isTimerSavingRef.current || pendingTimerSignatureRef.current === currentSignature) { + return false; + } - const syncedDraft = buildTimerDraftState(updatedEntry); - timerDraftSignatureRef.current = serializeTimerDraft(syncedDraft); - setTimerDraft(syncedDraft); - setActiveRunningEntry(updatedEntry); - toast.success(saveSuccessText); - } catch (error) { - console.error(error); - toast.error(saveErrorText); - } - }, 500); + isTimerSavingRef.current = true; + pendingTimerSignatureRef.current = currentSignature; - return () => { - if (timerSaveTimeoutRef.current) { - window.clearTimeout(timerSaveTimeoutRef.current); - } - }; + try { + const updatedEntry = await updateTimeEntry(runningEntry.id, { + description: timerDraft.description.trim(), + project_id: timerDraft.projectId || null, + tags: timerDraft.tags, + is_billable: timerDraft.isBillable, + }); + + const syncedDraft = buildTimerDraftState(updatedEntry); + const syncedSignature = serializeTimerDraft(syncedDraft); + timerDraftSignatureRef.current = syncedSignature; + pendingTimerSignatureRef.current = syncedSignature; + setTimerDraft(syncedDraft); + setActiveRunningEntry(updatedEntry); + toast.success(saveSuccessText); + return true; + } catch (error) { + console.error(error); + pendingTimerSignatureRef.current = null; + toast.error(saveErrorText); + return false; + } finally { + isTimerSavingRef.current = false; + } }, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry, timerDraft]); useEffect(() => { - return () => { - if (timerSaveTimeoutRef.current) { - window.clearTimeout(timerSaveTimeoutRef.current); - } + if (!runningEntry) return; + + const handlePointerDown = (event: MouseEvent) => { + if (isInsideTimerEditorContext(event.target)) return; + void commitTimerDraft(); }; - }, []); + + document.addEventListener("mousedown", handlePointerDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + }; + }, [commitTimerDraft, isInsideTimerEditorContext, runningEntry]); + + const handleTimerBlurCapture = () => { + window.setTimeout(() => { + if (isInsideTimerEditorContext(document.activeElement)) return; + void commitTimerDraft(); + }, 0); + }; const closeCreateModal = () => { if (isSaving) return; @@ -2096,10 +2125,6 @@ export default function Timesheet() { try { setIsDiscardingTimer(true); - if (timerSaveTimeoutRef.current) { - window.clearTimeout(timerSaveTimeoutRef.current); - timerSaveTimeoutRef.current = null; - } await deleteTimeEntry(discardTimerModal.entry.id); timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); @@ -2129,7 +2154,11 @@ export default function Timesheet() { -
+
@@ -2165,6 +2195,7 @@ export default function Timesheet() { emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."} title={t.tags?.title || "Tags"} compact + portalOwnerId={timerEditorOwnerId} />
@@ -2185,46 +2216,50 @@ export default function Timesheet() {
{runningEntry ? ( <> - - - - ) : ( - - )} + + + + ) : ( + + )}
-
+
@@ -2263,6 +2299,7 @@ export default function Timesheet() { emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."} title={t.tags?.title || "Tags"} compact + portalOwnerId={timerEditorOwnerId} /> {runningEntry ? ( <> - - - - ) : ( - - )} + + + + ) : ( + + )}
@@ -2330,8 +2367,8 @@ export default function Timesheet() { client: t.projects?.clientLabel || "Client", tags: t.tags?.title || "Tags", clear: extendedTimesheet.clearFilters || "Clear filters", - customFrom: extendedTimesheet.customFromLabel || "From date", - customTo: extendedTimesheet.customToLabel || "To date", + customFrom: extendedTimesheet.customFromLabel || "From date", + customTo: extendedTimesheet.customToLabel || "To date", allClients: extendedTimesheet.allClientsLabel || "All clients", allProjects: extendedTimesheet.allProjectsLabel || "All projects", allTags: extendedTimesheet.allTagsLabel || "All tags",