From 441cc0c008a4fbe7cb574d6415fbed3fc6f23306 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 24 Apr 2026 23:04:27 +0330 Subject: [PATCH] fix(timesheet): refine responsive filter bar and timer actions --- .../timesheet/TimesheetFilterBar.tsx | 168 ++-- src/pages/Timesheet.tsx | 807 +++++++++--------- 2 files changed, 484 insertions(+), 491 deletions(-) diff --git a/src/components/timesheet/TimesheetFilterBar.tsx b/src/components/timesheet/TimesheetFilterBar.tsx index 3d59322..cab8475 100644 --- a/src/components/timesheet/TimesheetFilterBar.tsx +++ b/src/components/timesheet/TimesheetFilterBar.tsx @@ -242,7 +242,7 @@ export default function TimesheetFilterBar({ return (
-
+
-
+
-
- {activeChips.length > 0 && ( -
- {activeChips.map((chip) => ( - - {chip} - - ))} -
- )} - {isExpanded && ( -
- } label={labels?.customFrom || "From"}> - setDraftFilters((current) => ({ ...current, startedAfter: value }))} - placeholder="YYYY/MM/DD" - inputClassName="h-8 border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100" - /> - +
+
+ } label={labels?.customFrom || "From"}> + setDraftFilters((current) => ({ ...current, startedAfter: value }))} + placeholder="YYYY/MM/DD" + inputClassName="h-8 border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100" + /> + - } label={labels?.customTo || "To"}> - setDraftFilters((current) => ({ ...current, startedBefore: value }))} - placeholder="YYYY/MM/DD" - inputClassName="h-8 border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100" - /> - + } label={labels?.customTo || "To"}> + setDraftFilters((current) => ({ ...current, startedBefore: value }))} + placeholder="YYYY/MM/DD" + inputClassName="h-8 border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100" + /> + - } label={labels?.client || "Client"}> - + setDraftFilters((current) => ({ + ...current, + clientId, + projectId: + current.projectId && + !projects.some((project) => project.id === current.projectId && project.client?.id === clientId) + ? "" + : current.projectId, + })) + } + options={[{ value: "", label: labels?.allClients || "All clients" }, ...clients]} + className="w-full" + buttonClassName="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900" + /> + - } label={labels?.project || "Project"}> - setDraftFilters((current) => ({ ...current, projectId }))} + options={[{ value: "", label: labels?.allProjects || "All projects" }, ...( + draftFilters.clientId + ? projects.filter((project) => project.client?.id === draftFilters.clientId) + : projects + ).map((project) => ({ value: project.id, label: project.name }))]} + className="w-full" + buttonClassName="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900" + /> + - } label={labels?.tags || "Tags"}> - setDraftFilters((current) => ({ ...current, tagIds }))} - title={labels?.allTags || "All tags"} - /> - + } label={labels?.tags || "Tags"}> + setDraftFilters((current) => ({ ...current, tagIds }))} + title={labels?.allTags || "All tags"} + /> + +
+ +
+ +
)}
diff --git a/src/pages/Timesheet.tsx b/src/pages/Timesheet.tsx index 155a8a9..63a81bb 100644 --- a/src/pages/Timesheet.tsx +++ b/src/pages/Timesheet.tsx @@ -1095,8 +1095,8 @@ function EntryEditorFields({ ); } - return ( -
+ return ( +
-
-
- onChange({ startDate: date })} - inputClassName={compact ? "h-9 px-2 text-xs" : undefined} - /> - onChange({ startTime: value })} - compact={compact} - /> -
- -
- onChange({ endDate: date })} - inputClassName={compact ? "h-9 px-2 text-xs" : undefined} - /> - onChange({ endTime: value })} - compact={compact} - /> -
-
+
+
+ onChange({ startDate: date })} + inputClassName={compact ? "h-9 px-2 text-xs" : undefined} + /> + onChange({ startTime: value })} + compact={compact} + /> +
+ +
+ onChange({ endDate: date })} + inputClassName={compact ? "h-9 px-2 text-xs" : undefined} + /> + onChange({ endTime: value })} + compact={compact} + /> +
+
void; - onRestart: (entry: TimeEntry) => void; + onRestart: (entry: TimeEntry) => void; onEntryUpdated: (entry: TimeEntry) => void; }) { const [draft, setDraft] = useState(() => buildEntryFormState(entry)); @@ -1382,65 +1382,65 @@ function MobileRecordedEntryCard({ onEdit: (entry: TimeEntry) => void; onDelete: (entry: TimeEntry) => void; onRequestRestart: (entry: TimeEntry) => void; -}) { - const project = projects.find((item) => item.id === entry.project); - const entryTags = tags.filter((tag) => entry.tags.includes(tag.id)); - const wrapperRef = useRef(null); - const buttonRef = useRef(null); - const dropdownRef = useRef(null); - const touchStartXRef = useRef(null); - const [menuOpen, setMenuOpen] = useState(false); - const [swipeOffset, setSwipeOffset] = useState(0); - const [menuStyle, setMenuStyle] = useState({}); +}) { + const project = projects.find((item) => item.id === entry.project); + const entryTags = tags.filter((tag) => entry.tags.includes(tag.id)); + const wrapperRef = useRef(null); + const buttonRef = useRef(null); + const dropdownRef = useRef(null); + const touchStartXRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); + const [swipeOffset, setSwipeOffset] = useState(0); + const [menuStyle, setMenuStyle] = useState({}); - useEffect(() => { - if (!menuOpen) return; - - const handlePointerDown = (event: MouseEvent) => { - if ( - wrapperRef.current?.contains(event.target as Node) || - dropdownRef.current?.contains(event.target as Node) - ) { - return; - } - setMenuOpen(false); - }; - - document.addEventListener("mousedown", handlePointerDown); - return () => document.removeEventListener("mousedown", handlePointerDown); - }, [menuOpen]); - - useEffect(() => { - if (!menuOpen || !buttonRef.current) return; - - const rect = buttonRef.current.getBoundingClientRect(); - const dropdownWidth = 168; - const spaceBelow = window.innerHeight - rect.bottom; - const openUpward = spaceBelow < 180 && rect.top > spaceBelow; - - setMenuStyle({ - position: "fixed", - top: openUpward ? `${rect.top - 8}px` : `${rect.bottom + 8}px`, - left: `${Math.max(12, rect.right - dropdownWidth)}px`, - width: `${dropdownWidth}px`, - transform: openUpward ? "translateY(-100%)" : "none", - zIndex: 100000, - }); - }, [menuOpen]); - - useEffect(() => { - const closeMenu = () => setMenuOpen(false); - - if (menuOpen) { - window.addEventListener("resize", closeMenu); - window.addEventListener("scroll", closeMenu, true); - } - - return () => { - window.removeEventListener("resize", closeMenu); - window.removeEventListener("scroll", closeMenu, true); - }; - }, [menuOpen]); + useEffect(() => { + if (!menuOpen) return; + + const handlePointerDown = (event: MouseEvent) => { + if ( + wrapperRef.current?.contains(event.target as Node) || + dropdownRef.current?.contains(event.target as Node) + ) { + return; + } + setMenuOpen(false); + }; + + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, [menuOpen]); + + useEffect(() => { + if (!menuOpen || !buttonRef.current) return; + + const rect = buttonRef.current.getBoundingClientRect(); + const dropdownWidth = 168; + const spaceBelow = window.innerHeight - rect.bottom; + const openUpward = spaceBelow < 180 && rect.top > spaceBelow; + + setMenuStyle({ + position: "fixed", + top: openUpward ? `${rect.top - 8}px` : `${rect.bottom + 8}px`, + left: `${Math.max(12, rect.right - dropdownWidth)}px`, + width: `${dropdownWidth}px`, + transform: openUpward ? "translateY(-100%)" : "none", + zIndex: 100000, + }); + }, [menuOpen]); + + useEffect(() => { + const closeMenu = () => setMenuOpen(false); + + if (menuOpen) { + window.addEventListener("resize", closeMenu); + window.addEventListener("scroll", closeMenu, true); + } + + return () => { + window.removeEventListener("resize", closeMenu); + window.removeEventListener("scroll", closeMenu, true); + }; + }, [menuOpen]); const closeSwipe = () => { touchStartXRef.current = null; @@ -1493,17 +1493,17 @@ function MobileRecordedEntryCard({ onTouchEnd={handleTouchEnd} onTouchCancel={closeSwipe} > -
- -
+
+ +
@@ -1551,54 +1551,54 @@ function MobileRecordedEntryCard({
)}
-
- - {menuOpen && - createPortal( -
- - - -
, - document.body, - )} -
- ); -} +
+ + {menuOpen && + createPortal( +
+ + + +
, + document.body, + )} +
+ ); +} export default function Timesheet() { const { t, lang } = useTranslation(); @@ -1618,13 +1618,13 @@ export default function Timesheet() { showFiltersLabel?: string; hideFiltersLabel?: string; applyFiltersLabel?: string; - clientFilterPrefix?: string; - projectFilterPrefix?: string; - tagFilterPrefix?: string; - fromFilterPrefix?: string; - toFilterPrefix?: string; - restartConfirmMessage?: string; - }) || {}; + clientFilterPrefix?: string; + projectFilterPrefix?: string; + tagFilterPrefix?: string; + fromFilterPrefix?: string; + toFilterPrefix?: string; + restartConfirmMessage?: string; + }) || {}; const [projects, setProjects] = useState([]); const [tags, setTags] = useState([]); @@ -1649,21 +1649,21 @@ export default function Timesheet() { const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT)); const timerSaveTimeoutRef = useRef(null); - const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({ - isOpen: false, - entry: null, - }); - const [isDeleting, setIsDeleting] = useState(false); - const [restartModal, setRestartModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({ - isOpen: false, - entry: null, - }); - const [isRestarting, setIsRestarting] = useState(false); - const [discardTimerModal, setDiscardTimerModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({ - isOpen: false, - entry: null, - }); - const [isDiscardingTimer, setIsDiscardingTimer] = useState(false); + const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({ + isOpen: false, + entry: null, + }); + const [isDeleting, setIsDeleting] = useState(false); + const [restartModal, setRestartModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({ + isOpen: false, + entry: null, + }); + const [isRestarting, setIsRestarting] = useState(false); + const [discardTimerModal, setDiscardTimerModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({ + isOpen: false, + entry: null, + }); + const [isDiscardingTimer, setIsDiscardingTimer] = useState(false); const runningEntry = activeRunningEntry; @@ -1931,17 +1931,17 @@ export default function Timesheet() { } }; - const handleStop = async (entry: TimeEntry) => { - try { - await stopTimeEntry(entry.id); - toast.success(t.timesheet?.stopSuccess || "Timer stopped"); - timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); - setTimerDraft(EMPTY_TIMER_DRAFT); - await loadHistory(); - await loadRunningEntry(); - } catch (error) { - console.error(error); - toast.error(t.timesheet?.stopError || "Failed to stop timer"); + const handleStop = async (entry: TimeEntry) => { + try { + await stopTimeEntry(entry.id); + toast.success(t.timesheet?.stopSuccess || "Timer stopped"); + timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); + setTimerDraft(EMPTY_TIMER_DRAFT); + await loadHistory(); + await loadRunningEntry(); + } catch (error) { + console.error(error); + toast.error(t.timesheet?.stopError || "Failed to stop timer"); } }; @@ -1967,35 +1967,35 @@ export default function Timesheet() { } }; - const openDeleteModal = (entry: TimeEntry) => { - setDeleteModal({ isOpen: true, entry }); - }; - - const openRestartModal = (entry: TimeEntry) => { - setRestartModal({ isOpen: true, entry }); - }; - - const closeDeleteModal = () => { - if (isDeleting) return; - setDeleteModal({ isOpen: false, entry: null }); - }; - - const closeRestartModal = () => { - if (isRestarting) return; - setRestartModal({ isOpen: false, entry: null }); - }; - - const openDiscardTimerModal = () => { - if (!runningEntry || isDiscardingTimer) return; - setDiscardTimerModal({ isOpen: true, entry: runningEntry }); - }; - - const closeDiscardTimerModal = () => { - if (isDiscardingTimer) return; - setDiscardTimerModal({ isOpen: false, entry: null }); - }; + const openDeleteModal = (entry: TimeEntry) => { + setDeleteModal({ isOpen: true, entry }); + }; - const confirmDelete = async () => { + const openRestartModal = (entry: TimeEntry) => { + setRestartModal({ isOpen: true, entry }); + }; + + const closeDeleteModal = () => { + if (isDeleting) return; + setDeleteModal({ isOpen: false, entry: null }); + }; + + const closeRestartModal = () => { + if (isRestarting) return; + setRestartModal({ isOpen: false, entry: null }); + }; + + const openDiscardTimerModal = () => { + if (!runningEntry || isDiscardingTimer) return; + setDiscardTimerModal({ isOpen: true, entry: runningEntry }); + }; + + const closeDiscardTimerModal = () => { + if (isDiscardingTimer) return; + setDiscardTimerModal({ isOpen: false, entry: null }); + }; + + const confirmDelete = async () => { if (!deleteModal.entry) return; try { @@ -2025,19 +2025,19 @@ export default function Timesheet() { } finally { setIsDeleting(false); } - }; - - const confirmRestart = async () => { - if (!restartModal.entry) return; - - try { - setIsRestarting(true); - await handleRestartFromEntry(restartModal.entry); - setRestartModal({ isOpen: false, entry: null }); - } finally { - setIsRestarting(false); - } - }; + }; + + const confirmRestart = async () => { + if (!restartModal.entry) return; + + try { + setIsRestarting(true); + await handleRestartFromEntry(restartModal.entry); + setRestartModal({ isOpen: false, entry: null }); + } finally { + setIsRestarting(false); + } + }; const handleEntryUpdated = useCallback((updatedEntry: TimeEntry) => { setGroupedHistory((current) => updateGroupedHistoryEntry(current, updatedEntry)); @@ -2056,36 +2056,36 @@ export default function Timesheet() { setFilters(DEFAULT_ENTRY_FILTERS); }, []); - const handleLoadMore = useCallback(() => { - if (!hasMoreHistory || nextOffset === null || isLoadingMore) return; - void loadHistory({ offset: nextOffset, append: true }); - }, [hasMoreHistory, isLoadingMore, loadHistory, nextOffset]); - - const handleDiscardTimerDraft = useCallback(async () => { - if (!discardTimerModal.entry || isDiscardingTimer) return; - - try { - setIsDiscardingTimer(true); - if (timerSaveTimeoutRef.current) { - window.clearTimeout(timerSaveTimeoutRef.current); - timerSaveTimeoutRef.current = null; - } - - await deleteTimeEntry(discardTimerModal.entry.id); - timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); - setTimerDraft(EMPTY_TIMER_DRAFT); - setActiveRunningEntry(null); - setDiscardTimerModal({ isOpen: false, entry: null }); - toast.success(t.timesheet?.deleteSuccess || "Time entry deleted"); - await loadHistory(); - await loadRunningEntry(); - } catch (error) { - console.error(error); - toast.error(t.timesheet?.deleteError || "Failed to delete time entry"); - } finally { - setIsDiscardingTimer(false); - } - }, [discardTimerModal.entry, isDiscardingTimer, loadHistory, loadRunningEntry, t.timesheet?.deleteError, t.timesheet?.deleteSuccess]); + const handleLoadMore = useCallback(() => { + if (!hasMoreHistory || nextOffset === null || isLoadingMore) return; + void loadHistory({ offset: nextOffset, append: true }); + }, [hasMoreHistory, isLoadingMore, loadHistory, nextOffset]); + + const handleDiscardTimerDraft = useCallback(async () => { + if (!discardTimerModal.entry || isDiscardingTimer) return; + + try { + setIsDiscardingTimer(true); + if (timerSaveTimeoutRef.current) { + window.clearTimeout(timerSaveTimeoutRef.current); + timerSaveTimeoutRef.current = null; + } + + await deleteTimeEntry(discardTimerModal.entry.id); + timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); + setTimerDraft(EMPTY_TIMER_DRAFT); + setActiveRunningEntry(null); + setDiscardTimerModal({ isOpen: false, entry: null }); + toast.success(t.timesheet?.deleteSuccess || "Time entry deleted"); + await loadHistory(); + await loadRunningEntry(); + } catch (error) { + console.error(error); + toast.error(t.timesheet?.deleteError || "Failed to delete time entry"); + } finally { + setIsDiscardingTimer(false); + } + }, [discardTimerModal.entry, isDiscardingTimer, loadHistory, loadRunningEntry, t.timesheet?.deleteError, t.timesheet?.deleteSuccess]); if (!activeWorkspace) { return
{t.timesheet?.selectWorkspace || t.clients.selectWorkspace}
; @@ -2097,11 +2097,6 @@ export default function Timesheet() {

{t.timesheet?.title || "Timesheet"}

- -
@@ -2125,7 +2120,7 @@ export default function Timesheet() { ...projects.map((project) => ({ value: project.id, label: project.name })), ]} className="min-w-[170px]" - buttonClassName="h-12 w-full rounded-none border-0 bg-transparent px-3 text-sm text-sky-600 shadow-none outline-none dark:bg-transparent dark:text-sky-400 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0" + buttonClassName="h-12 w-full rounded-none border-0 bg-transparent px-3 text-sm text-sky-600 shadow-none outline-none dark:bg-transparent dark:text-sky-400 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0" disabled={isStartingTimer} />
@@ -2157,27 +2152,27 @@ export default function Timesheet() { {runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"}
-
- {runningEntry ? ( - <> - - - - ) : ( - - )} -
+
+ {runningEntry ? ( + <> + + + + ) : ( + + )} +
@@ -2201,7 +2196,7 @@ export default function Timesheet() { ...projects.map((project) => ({ value: project.id, label: project.name })), ]} className="w-full" - buttonClassName="h-10 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0" + buttonClassName="h-10 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0" disabled={isStartingTimer} /> @@ -2210,10 +2205,10 @@ export default function Timesheet() { -
-
- +
+ setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) })) @@ -2229,33 +2224,33 @@ export default function Timesheet() { label={t.timesheet?.billable || "Billable"} disabled={isStartingTimer} compact - /> -
- -
- {runningEntry ? ( - <> - - - - ) : ( - - )} -
-
-
- + /> + + +
+ {runningEntry ? ( + <> + + + + ) : ( + + )} +
+ + +
- +
))} @@ -2390,8 +2385,8 @@ export default function Timesheet() { /> - {deleteModal.entry && ( - - - )} - - {restartModal.entry && ( - - - - - } - > -
-

- {(extendedTimesheet.restartConfirmMessage || "Start a new running timer from this entry?")} -

-
-

- {restartModal.entry.description || t.timesheet?.emptyDescription || "No description"} -

-

- {formatDateTime(restartModal.entry.start_time, lang)} - {restartModal.entry.end_time ? ` - ${formatDateTime(restartModal.entry.end_time, lang)}` : ""} -

-
-
-
- )} - - {discardTimerModal.entry && ( - - - - - } - > -
-

- {extendedTimesheet.deleteConfirmMessage || "Are you sure you want to delete this time entry?"} -

-
-

- {discardTimerModal.entry.description || t.timesheet?.emptyDescription || "No description"} -

-

- {formatDateTime(discardTimerModal.entry.start_time, lang)} -

-
-
-
- )} - - ); -} + + )} + + {restartModal.entry && ( + + + + + } + > +
+

+ {(extendedTimesheet.restartConfirmMessage || "Start a new running timer from this entry?")} +

+
+

+ {restartModal.entry.description || t.timesheet?.emptyDescription || "No description"} +

+

+ {formatDateTime(restartModal.entry.start_time, lang)} + {restartModal.entry.end_time ? ` - ${formatDateTime(restartModal.entry.end_time, lang)}` : ""} +

+
+
+
+ )} + + {discardTimerModal.entry && ( + + + + + } + > +
+

+ {extendedTimesheet.deleteConfirmMessage || "Are you sure you want to delete this time entry?"} +

+
+

+ {discardTimerModal.entry.description || t.timesheet?.emptyDescription || "No description"} +

+

+ {formatDateTime(discardTimerModal.entry.start_time, lang)} +

+
+
+
+ )} + + ); +}