import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useSearchParams } from "react-router-dom"; 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"; import { createTimeEntry, deleteTimeEntry, getTimeEntries, stopTimeEntry, type TimeEntryGroupWeek, type TimeEntry, type TimeEntryListParams, type TimeEntryPayload, updateTimeEntry, } from "../api/timeEntries"; import { getTags, type Tag } from "../api/tags"; import { Modal } from "../components/Modal"; import EmptyStateCard from "../components/EmptyStateCard"; import { InfiniteScroll } from "../components/InfiniteScroll"; import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timesheet/TimesheetFilterBar"; import JalaliDatePicker from "../components/ui/JalaliDatePicker"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { SearchableSelect } from "../components/ui/SearchableSelect"; import { useWorkspace } from "../context/WorkspaceContext"; import { useTranslation } from "../hooks/useTranslation"; import { readArrayParam, readStringParam, updateQueryParams, } from "../lib/queryParams"; type EntryModalMode = "manual" | "edit" | null; interface EntryFormState { description: string; projectId: string; startDate: string; startTime: string; endDate: string; endTime: string; isBillable: boolean; tags: string[]; } interface TimerDraftState { description: string; projectId: string; isBillable: boolean; tags: string[]; } const EMPTY_FORM: EntryFormState = { description: "", projectId: "", startDate: "", startTime: "", endDate: "", endTime: "", isBillable: false, tags: [], }; const EMPTY_TIMER_DRAFT: TimerDraftState = { description: "", projectId: "", isBillable: false, tags: [], }; const DEFAULT_ENTRY_FILTERS: TimeEntryFilters = { projectId: "", clientId: "", tagIds: [], startedAfter: "", startedBefore: "", }; const pad = (value: number) => String(value).padStart(2, "0"); const normalizeDigits = (value: string) => value .replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit))) .replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit))); const parseApiDateTime = (value?: string | null) => { if (!value) return null; const normalized = normalizeDigits(String(value).trim()); const candidates = Array.from(new Set([normalized, normalized.replace(" ", "T")])); for (const candidate of candidates) { const parsed = new Date(candidate); if (!Number.isNaN(parsed.getTime())) { return parsed; } } const match = normalized.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/); if (!match) return null; const [, year, month, day, hours, minutes, seconds] = match; return new Date( Number(year), Number(month) - 1, Number(day), Number(hours), Number(minutes), Number(seconds || 0), 0, ); }; const formatTimeInputValue = (value: string) => { const digits = normalizeDigits(value).replace(/\D/g, "").slice(0, 6); if (digits.length <= 2) return digits; if (digits.length <= 4) return `${digits.slice(0, 2)}:${digits.slice(2)}`; return `${digits.slice(0, 2)}:${digits.slice(2, 4)}:${digits.slice(4)}`; }; const getTimeCursorPosition = (digitCount: number) => { if (digitCount <= 2) return digitCount; if (digitCount <= 4) return digitCount + 1; return Math.min(digitCount + 2, 8); }; const handleFormattedTimeInputChange = ( event: React.ChangeEvent, onChange: (value: string) => void, ) => { const input = event.target; const selectionStart = input.selectionStart ?? input.value.length; const digitsBeforeCursor = normalizeDigits(input.value.slice(0, selectionStart)).replace(/\D/g, "").slice(0, 6); const formattedValue = formatTimeInputValue(input.value); const nextCursor = getTimeCursorPosition(digitsBeforeCursor.length); onChange(formattedValue); window.requestAnimationFrame(() => { if (document.activeElement !== input) return; input.setSelectionRange(nextCursor, nextCursor); }); }; const isValidTimeValue = (value: string) => { if (!/^\d{2}:\d{2}:\d{2}$/.test(value)) return false; const [hours, minutes, seconds] = value.split(":").map(Number); return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59 && seconds >= 0 && seconds <= 59; }; const getLocalDateParts = (value?: string | null) => { const parsed = parseApiDateTime(value); if (!parsed) { return { date: "", time: "", }; } return { date: `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}`, time: `${pad(parsed.getHours())}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`, }; }; const combineDateAndTime = (dateValue: string, timeValue: string) => { if (!dateValue || !isValidTimeValue(timeValue)) return null; const [year, month, day] = dateValue.split("-").map(Number); const [hours, minutes, seconds] = timeValue.split(":").map(Number); return new Date(year, month - 1, day, hours, minutes, seconds, 0).toISOString(); }; const formatDateTime = (value: string, locale: "en" | "fa") => { const parsed = parseApiDateTime(value); if (!parsed) return value; return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", { dateStyle: "medium", timeStyle: "medium", }).format(parsed); }; const formatDateOnly = (value: string, locale: "en" | "fa") => { const parsed = parseApiDateTime(value); if (!parsed) return value; return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", { dateStyle: "medium", }).format(parsed); }; const formatDuration = (entry: TimeEntry, now = Date.now()) => { const start = parseApiDateTime(entry.start_time)?.getTime(); const end = entry.end_time ? parseApiDateTime(entry.end_time)?.getTime() : now; if (!start || !end) return "00:00:00"; const totalSeconds = Math.max(0, Math.floor((end - start) / 1000)); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":"); }; const getEntryDurationMs = (entry: TimeEntry, now = Date.now()) => { const start = parseApiDateTime(entry.start_time)?.getTime(); const end = entry.end_time ? parseApiDateTime(entry.end_time)?.getTime() : now; if (!start || !end) return 0; return Math.max(0, end - start); }; const formatDurationMs = (durationMs: number) => { const totalSeconds = Math.max(0, Math.floor(durationMs / 1000)); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":"); }; const formatTimeOnly = (value?: string | null, locale: "en" | "fa" = "en") => { const parsed = parseApiDateTime(value); if (!parsed) return "--:--:--"; return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }).format(parsed); }; const getWeekStart = (date: Date) => { const value = new Date(date); value.setHours(0, 0, 0, 0); value.setDate(value.getDate() - value.getDay()); return value; }; const formatWeekRange = (date: Date, locale: "en" | "fa") => { const start = getWeekStart(date); const end = new Date(start); end.setDate(start.getDate() + 6); const formatter = new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", { month: "short", day: "numeric", }); return `${formatter.format(start)} - ${formatter.format(end)}`; }; const formatDayLabel = (date: Date, locale: "en" | "fa") => new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", { weekday: "short", month: "short", day: "numeric", }).format(date); const mergeGroupedHistory = (currentGroups: TimeEntryGroupWeek[], nextGroups: TimeEntryGroupWeek[]) => { const merged = currentGroups.map((week) => ({ ...week, days: week.days.map((day) => ({ ...day, entries: [...day.entries] })), })); nextGroups.forEach((incomingWeek) => { const existingWeek = merged.find((week) => week.key === incomingWeek.key); if (!existingWeek) { merged.push({ ...incomingWeek, days: incomingWeek.days.map((day) => ({ ...day, entries: [...day.entries] })), }); return; } existingWeek.total_ms = incomingWeek.total_ms; incomingWeek.days.forEach((incomingDay) => { const existingDay = existingWeek.days.find((day) => day.key === incomingDay.key); if (!existingDay) { existingWeek.days.push({ ...incomingDay, entries: [...incomingDay.entries] }); return; } existingDay.total_ms = incomingDay.total_ms; const existingIds = new Set(existingDay.entries.map((entry) => entry.id)); incomingDay.entries.forEach((entry) => { if (!existingIds.has(entry.id)) { existingDay.entries.push(entry); } }); }); }); return merged; }; const updateGroupedHistoryEntry = ( groups: TimeEntryGroupWeek[], updatedEntry: TimeEntry, ) => { const filteredGroups = groups .map((week) => ({ ...week, days: week.days .map((day) => ({ ...day, entries: day.entries.filter((entry) => entry.id !== updatedEntry.id), })) .filter((day) => day.entries.length > 0), })) .filter((week) => week.days.length > 0); if (!updatedEntry.end_time) { return filteredGroups; } const start = parseApiDateTime(updatedEntry.start_time); if (!start) { return filteredGroups; } const weekStart = getWeekStart(start); const weekKey = `${weekStart.getFullYear()}-${pad(weekStart.getMonth() + 1)}-${pad(weekStart.getDate())}`; const dayKey = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}`; const weekEnd = new Date(weekStart); weekEnd.setDate(weekStart.getDate() + 6); const merged = filteredGroups.map((week) => ({ ...week, days: week.days.map((day) => ({ ...day, entries: [...day.entries] })), })); let targetWeek = merged.find((week) => week.key === weekKey); if (!targetWeek) { targetWeek = { key: weekKey, week_start: `${weekStart.getFullYear()}-${pad(weekStart.getMonth() + 1)}-${pad(weekStart.getDate())}`, week_end: `${weekEnd.getFullYear()}-${pad(weekEnd.getMonth() + 1)}-${pad(weekEnd.getDate())}`, total_ms: 0, days: [], }; merged.push(targetWeek); } let targetDay = targetWeek.days.find((day) => day.key === dayKey); if (!targetDay) { targetDay = { key: dayKey, date: dayKey, total_ms: 0, entries: [], }; targetWeek.days.push(targetDay); } targetDay.entries.unshift(updatedEntry); targetDay.entries.sort((a, b) => { const aTime = parseApiDateTime(a.start_time)?.getTime() || 0; const bTime = parseApiDateTime(b.start_time)?.getTime() || 0; return bTime - aTime; }); targetDay.total_ms = targetDay.entries.reduce((sum, entry) => sum + getEntryDurationMs(entry), 0); targetWeek.total_ms = targetWeek.days.reduce((sum, day) => sum + day.total_ms, 0); merged.sort((a, b) => (a.week_start < b.week_start ? 1 : -1)); merged.forEach((week) => { week.days.sort((a, b) => (a.date < b.date ? 1 : -1)); }); return merged; }; const buildEntryFormState = (entry?: TimeEntry | null): EntryFormState => { if (!entry) { const now = getLocalDateParts(new Date().toISOString()); return { ...EMPTY_FORM, startDate: now.date, startTime: now.time, }; } const start = getLocalDateParts(entry.start_time); const end = getLocalDateParts(entry.end_time); return { description: entry.description || "", projectId: entry.project || "", startDate: start.date, startTime: start.time, endDate: end.date, endTime: end.time, isBillable: entry.is_billable, tags: entry.tags || [], }; }; const buildTimerDraftState = (entry?: TimeEntry | null): TimerDraftState => ({ description: entry?.description || "", projectId: entry?.project || "", isBillable: entry?.is_billable || false, tags: entry?.tags || [], }); const serializeTimerDraft = (state: TimerDraftState) => JSON.stringify({ description: state.description.trim(), projectId: state.projectId || "", isBillable: state.isBillable, tags: [...state.tags].sort(), }); const serializeEntryDraft = (state: EntryFormState) => JSON.stringify({ description: state.description.trim(), projectId: state.projectId || "", startDate: state.startDate || "", startTime: state.startTime || "", endDate: state.endDate || "", endTime: state.endTime || "", isBillable: state.isBillable, tags: [...state.tags].sort(), }); const toggleTagId = (currentTags: string[], tagId: string) => currentTags.includes(tagId) ? currentTags.filter((currentId) => currentId !== tagId) : [...currentTags, tagId]; const buildPayloadFromState = ( state: EntryFormState, options: { includeWorkspace: boolean; workspaceId?: string }, ): { payload?: TimeEntryPayload; error?: string } => { const startDateTime = combineDateAndTime(state.startDate, state.startTime); if (!startDateTime) { return { error: "Start date and time are required." }; } let endDateTime: string | null = null; const hasEndValue = Boolean(state.endDate || state.endTime); if (hasEndValue) { if (!state.endDate || !state.endTime) { return { error: "End date and time must both be filled." }; } endDateTime = combineDateAndTime(state.endDate, state.endTime); if (!endDateTime) { return { error: "End time is invalid." }; } } const payload: TimeEntryPayload = { description: state.description.trim(), project_id: state.projectId || null, start_time: startDateTime, end_time: endDateTime, tags: state.tags, is_billable: state.isBillable, }; if (options.includeWorkspace && options.workspaceId) { payload.workspace_id = options.workspaceId; } return { payload }; }; const buildDeletedProjectLabel = (projectName: string, deletedLabel: string) => `${deletedLabel}: ${projectName}`; const buildDeletedTagLabel = (tagName: string, deletedLabel: string) => `${deletedLabel}: ${tagName}`; const getEntryProjectOption = (entry?: TimeEntry | null, deletedProjectLabel?: string): Project | null => { if (!entry?.project_details) return null; return { id: entry.project_details.id, name: entry.project_details.is_deleted ? buildDeletedProjectLabel(entry.project_details.name, deletedProjectLabel || "Deleted project") : entry.project_details.name, description: "", color: "", is_archived: false, is_deleted: entry.project_details.is_deleted, workspace: entry.workspace, client: entry.project_details.client_name ? { id: "", name: entry.project_details.client_name } : null, }; }; const buildProjectOptionsForEntry = ( activeProjects: Project[], entry: TimeEntry | null | undefined, selectedProjectId: string, deletedProjectLabel?: string, ) => { const projectsById = new Map(activeProjects.map((project) => [project.id, project])); const currentProject = getEntryProjectOption(entry, deletedProjectLabel); if ( currentProject && currentProject.id === selectedProjectId && !projectsById.has(currentProject.id) ) { projectsById.set(currentProject.id, currentProject); } return Array.from(projectsById.values()); }; const buildTagOptionsForEntry = ( activeTags: Tag[], entry: TimeEntry | null | undefined, selectedTagIds: string[], ) => { const tagsById = new Map(activeTags.map((tag) => [tag.id, tag])); const entryWorkspaceId = entry?.workspace || ""; const entryCreatedAt = entry?.created_at || ""; const entryUpdatedAt = entry?.updated_at || ""; (entry?.tag_details || []).forEach((tag) => { if (!selectedTagIds.includes(tag.id) || tagsById.has(tag.id)) return; tagsById.set(tag.id, { id: tag.id, workspace: entryWorkspaceId, name: tag.name, color: tag.color, is_deleted: tag.is_deleted, created_at: entryCreatedAt, updated_at: entryUpdatedAt, }); }); return Array.from(tagsById.values()); }; const getProjectDisplayDetails = (entry: TimeEntry, activeProjects: Project[]) => { const activeProject = activeProjects.find((item) => item.id === entry.project); if (activeProject) { return { name: activeProject.name, clientName: activeProject.client?.name || null, isDeleted: Boolean(activeProject.is_deleted), }; } if (!entry.project_details) { return null; } return { name: entry.project_details.name, clientName: entry.project_details.client_name, isDeleted: entry.project_details.is_deleted, }; }; const getTagDisplayDetails = (entry: TimeEntry, activeTags: Tag[]) => { const activeTagsById = new Map(activeTags.map((tag) => [tag.id, tag])); return entry.tags.map((tagId) => { const activeTag = activeTagsById.get(tagId); if (activeTag) { return { id: activeTag.id, name: activeTag.name, color: activeTag.color, isDeleted: Boolean(activeTag.is_deleted), }; } const deletedTag = entry.tag_details.find((tag) => tag.id === tagId); if (!deletedTag) { return null; } return { id: deletedTag.id, name: deletedTag.name, color: deletedTag.color, isDeleted: deletedTag.is_deleted, }; }).filter(Boolean) as Array<{ id: string; name: string; color: string; isDeleted: boolean }>; }; function TimeField({ label, value, onChange, placeholder, compact = false, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; compact?: boolean; }) { return (
handleFormattedTimeInputChange(event, onChange)} />
); } function BillableIconButton({ checked, onChange, label, disabled = false, compact = false, }: { checked: boolean; onChange: (checked: boolean) => void; label: string; disabled?: boolean; compact?: boolean; }) { return ( ); } function TagMultiSelect({ tags, selectedTags, onToggleTag, emptyHint, title, compact = false, portalOwnerId, className = "", buttonClassName = "", compactDisplayMode = "summary", }: { tags: Tag[]; selectedTags: string[]; onToggleTag: (tagId: string) => void; emptyHint: string; title: string; compact?: boolean; portalOwnerId?: string; className?: string; buttonClassName?: string; compactDisplayMode?: "summary" | "chips"; }) { 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({}); useEffect(() => { if (!isOpen) return; const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node; const clickedInsideTrigger = wrapperRef.current?.contains(target); const clickedInsideDropdown = dropdownRef.current?.contains(target); if (!clickedInsideTrigger && !clickedInsideDropdown) { setIsOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isOpen]); useEffect(() => { if (!isOpen || !buttonRef.current) return; const rect = buttonRef.current.getBoundingClientRect(); const dropdownWidth = compact ? 256 : Math.max(rect.width, 256); const spaceBelow = window.innerHeight - rect.bottom; const openUpward = spaceBelow < 280 && rect.top > spaceBelow; setDropdownStyle({ 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, }); }, [compact, isOpen]); useEffect(() => { const closeOnViewportChange = () => setIsOpen(false); if (isOpen) { window.addEventListener("resize", closeOnViewportChange); } return () => { window.removeEventListener("resize", closeOnViewportChange); }; }, [isOpen]); useEffect(() => { if (!isOpen) { setSearchQuery(""); } }, [isOpen]); const selectedTagObjects = tags.filter((tag) => selectedTags.includes(tag.id)); const selectedLabels = selectedTagObjects.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 : "" : selectedLabels.length > 0 ? selectedLabels.join(", ") : title; return (
{!compact &&

{title}

} {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); const isUnavailable = Boolean(tag.is_deleted) && !selected; return ( ); })} {filteredTags.length === 0 && (
No tags found.
)}
)}
, document.body ) )}
); } function ProjectInlineSelect({ projects, value, onChange, placeholder, searchPlaceholder, emptyLabel, portalOwnerId, className = "", dropdownClassName = "", disabled = false, }: { projects: Project[]; value: string; onChange: (projectId: string) => void; placeholder: string; searchPlaceholder: string; emptyLabel: string; portalOwnerId?: string; className?: string; dropdownClassName?: string; disabled?: boolean; }) { const [isOpen, setIsOpen] = useState(false); const [query, setQuery] = useState(""); const wrapperRef = useRef(null); const buttonRef = useRef(null); const dropdownRef = useRef(null); const [dropdownStyle, setDropdownStyle] = useState({}); useEffect(() => { if (!isOpen) return; const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node; const clickedInsideTrigger = wrapperRef.current?.contains(target); const clickedInsideDropdown = dropdownRef.current?.contains(target); if (!clickedInsideTrigger && !clickedInsideDropdown) { setIsOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isOpen]); useEffect(() => { if (!isOpen || !buttonRef.current) return; const rect = buttonRef.current.getBoundingClientRect(); const dropdownWidth = 220; const spaceBelow = window.innerHeight - rect.bottom; const openUpward = spaceBelow < 280 && rect.top > spaceBelow; setDropdownStyle({ position: "fixed", top: openUpward ? `${rect.top - 8}px` : `${rect.bottom + 8}px`, left: `${Math.max(12, rect.left)}px`, transform: openUpward ? "translateY(-100%)" : "none", zIndex: 100000, }); }, [isOpen]); useEffect(() => { const closeOnViewportChange = () => setIsOpen(false); if (isOpen) { window.addEventListener("resize", closeOnViewportChange); window.addEventListener("scroll", closeOnViewportChange, true); } return () => { window.removeEventListener("resize", closeOnViewportChange); window.removeEventListener("scroll", closeOnViewportChange, true); }; }, [isOpen]); useEffect(() => { if (!isOpen) { setQuery(""); } }, [isOpen]); const selectedProject = projects.find((project) => project.id === value); const label = selectedProject?.name || placeholder; const filteredProjects = useMemo(() => { const needle = query.trim().toLowerCase(); if (!needle) return projects; return projects.filter((project) => { const clientName = project.client?.name || ""; return `${project.name} ${clientName}`.toLowerCase().includes(needle); }); }, [projects, query]); return (
{isOpen && !disabled && createPortal(
setQuery(event.target.value)} placeholder={searchPlaceholder} 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-800 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800" />
{filteredProjects.map((project) => { const selected = project.id === value; const unavailable = Boolean(project.is_deleted) && !selected; return ( ); })} {filteredProjects.length === 0 && (
{emptyLabel}
)}
, document.body, )}
); } function CompactDateTimeField({ label, dateValue, timeValue, onDateChange, onTimeChange, }: { label: string; dateValue: string; timeValue: string; onDateChange: (value: string) => void; onTimeChange: (value: string) => void; }) { return (
onTimeChange(formatTimeInputValue(event.target.value))} />
); } function InlineTimeRangeField({ startTime, endTime, onStartTimeChange, onEndTimeChange, }: { startTime: string; endTime: string; onStartTimeChange: (value: string) => void; onEndTimeChange: (value: string) => void; }) { return (
handleFormattedTimeInputChange(event, onStartTimeChange)} /> - handleFormattedTimeInputChange(event, onEndTimeChange)} />
); } function DateRangePopover({ startDate, endDate, onStartDateChange, onEndDateChange, portalOwnerId, }: { startDate: string; endDate: string; onStartDateChange: (value: string) => void; onEndDateChange: (value: string) => void; portalOwnerId?: string; }) { const [isOpen, setIsOpen] = useState(false); const wrapperRef = useRef(null); const buttonRef = useRef(null); const dropdownRef = useRef(null); const [dropdownStyle, setDropdownStyle] = useState({}); useEffect(() => { if (!isOpen) return; const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node; const clickedInsideTrigger = wrapperRef.current?.contains(target); const clickedInsideDropdown = dropdownRef.current?.contains(target); if (!clickedInsideTrigger && !clickedInsideDropdown) { setIsOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isOpen]); useEffect(() => { if (!isOpen || !buttonRef.current) return; const rect = buttonRef.current.getBoundingClientRect(); const dropdownWidth = 280; const spaceBelow = window.innerHeight - rect.bottom; const openUpward = spaceBelow < 240 && rect.top > spaceBelow; setDropdownStyle({ 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, }); }, [isOpen]); return (
{isOpen && createPortal(
, document.body, )}
); } function DeleteEntryButton({ onDelete, }: { onDelete: () => void; }) { return ( ); } function EntryEditorFields({ state, onChange, onToggleTag, onProjectChange, projects, tags, t, isRtl, compact = false, portalOwnerId, }: { state: EntryFormState; onChange: (patch: Partial) => void; onToggleTag: (tagId: string) => void; onProjectChange?: (projectId: string) => void; projects: Project[]; tags: Tag[]; t: any; isRtl: boolean; compact?: boolean; portalOwnerId?: string; }) { if (compact) { const selectedProject = projects.find((project) => project.id === state.projectId); return (
onChange({ description: event.target.value })} placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"} className="h-12 w-[200px] 2xl:w-[400px] shrink-0 rounded-none border-0 bg-transparent px-0 pe-3 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 truncate dark:bg-transparent dark:text-slate-100" />
(onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))} placeholder={t.timesheet?.projectLabel || "Project"} searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."} emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."} portalOwnerId={portalOwnerId} className="min-w-0 max-w-fit flex-1" /> {selectedProject?.client?.name && ( - {selectedProject.client.name} )}
onChange({ isBillable: checked })} label={t.timesheet?.billable || "Billable"} compact />
onChange({ startTime: value })} onEndTimeChange={(value) => onChange({ endTime: value })} />
onChange({ startDate: value })} onEndDateChange={(value) => onChange({ endDate: value })} portalOwnerId={portalOwnerId} />
); } return (
onChange({ description: event.target.value })} placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"} className={compact ? "h-9 px-2 text-xs" : ""} />
onChange({ projectId: String(value) })} options={[ { value: "", label: t.timesheet?.noProject || "No project" }, ...projects.map((project) => ({ value: project.id, label: project.name, searchText: project.client?.name || "", })), ]} placeholder={t.timesheet?.noProject || "No project"} searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."} emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."} className="w-full" buttonClassName={compact ? "w-full h-9 px-2 text-xs" : "w-full"} />
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({ isBillable: checked })} label={t.timesheet?.billable || "Billable"} />
); } function RecordedEntryCard({ entry, t, projects, tags, onDelete, onRestart, onEntryUpdated, variant = "desktop", lang, }: { entry: TimeEntry; t: any; projects: Project[]; tags: Tag[]; onDelete: (entry: TimeEntry) => void; onRestart: (entry: TimeEntry) => void; onEntryUpdated: (entry: TimeEntry) => void; variant?: "desktop" | "tablet"; lang: "en" | "fa"; }) { const [draft, setDraft] = useState(() => buildEntryFormState(entry)); const [validationMessage, setValidationMessage] = useState(""); const syncedSignatureRef = useRef(serializeEntryDraft(buildEntryFormState(entry))); const rowRef = useRef(null); const isSavingRef = useRef(false); const pendingSignatureRef = useRef(null); const editorOwnerId = `time-entry-editor-${entry.id}`; const timesheetCopy = (t.timesheet as { saveError?: string; saveSuccess?: string }) || {}; const saveErrorText = timesheetCopy.saveError || "Failed to save time entry"; const saveSuccessText = timesheetCopy.saveSuccess || "Time entry saved"; const deletedProjectLabel = t.timesheet?.deletedProjectLabel || "Deleted project"; const deletedTagLabel = t.timesheet?.deletedTagLabel || "Deleted tag"; const editorProjects = useMemo( () => buildProjectOptionsForEntry(projects, entry, draft.projectId, deletedProjectLabel), [deletedProjectLabel, draft.projectId, entry, projects], ); const editorTags = useMemo( () => buildTagOptionsForEntry(tags, entry, draft.tags), [draft.tags, entry, tags], ); useEffect(() => { const nextDraft = buildEntryFormState(entry); const nextSignature = serializeEntryDraft(nextDraft); syncedSignatureRef.current = nextSignature; pendingSignatureRef.current = nextSignature; setDraft(nextDraft); setValidationMessage(""); }, [entry]); const isInsideEditorContext = useCallback((target: EventTarget | null) => { if (!(target instanceof Node)) return false; if (rowRef.current?.contains(target)) return true; return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${editorOwnerId}"]`)); }, [editorOwnerId]); const commitDraft = useCallback(async () => { const currentSignature = serializeEntryDraft(draft); if (currentSignature === syncedSignatureRef.current) { setValidationMessage(""); return false; } if (isSavingRef.current || pendingSignatureRef.current === currentSignature) { return false; } const { payload, error } = buildPayloadFromState(draft, { includeWorkspace: false }); if (!payload) { setValidationMessage(error || ""); return false; } setValidationMessage(""); isSavingRef.current = true; pendingSignatureRef.current = currentSignature; try { const updatedEntry = await updateTimeEntry(entry.id, payload); const updatedDraft = buildEntryFormState(updatedEntry); const updatedSignature = serializeEntryDraft(updatedDraft); syncedSignatureRef.current = updatedSignature; pendingSignatureRef.current = updatedSignature; setDraft(updatedDraft); onEntryUpdated(updatedEntry); toast.success(saveSuccessText); return true; } catch (error) { console.error(error); pendingSignatureRef.current = null; toast.error(saveErrorText); return false; } finally { isSavingRef.current = false; } }, [draft, entry.id, onEntryUpdated, saveErrorText, saveSuccessText]); const commitPatchedDraft = useCallback(async (patch: Partial) => { const nextDraft = { ...draft, ...patch }; const nextSignature = serializeEntryDraft(nextDraft); setDraft(nextDraft); if (nextSignature === syncedSignatureRef.current) { setValidationMessage(""); return false; } if (isSavingRef.current || pendingSignatureRef.current === nextSignature) { return false; } const { payload, error } = buildPayloadFromState(nextDraft, { includeWorkspace: false }); if (!payload) { setValidationMessage(error || ""); return false; } setValidationMessage(""); isSavingRef.current = true; pendingSignatureRef.current = nextSignature; try { const updatedEntry = await updateTimeEntry(entry.id, payload); const updatedDraft = buildEntryFormState(updatedEntry); const updatedSignature = serializeEntryDraft(updatedDraft); syncedSignatureRef.current = updatedSignature; pendingSignatureRef.current = updatedSignature; setDraft(updatedDraft); onEntryUpdated(updatedEntry); toast.success(saveSuccessText); return true; } catch (error) { console.error(error); pendingSignatureRef.current = null; toast.error(saveErrorText); return false; } finally { isSavingRef.current = false; } }, [draft, entry.id, onEntryUpdated, saveErrorText, saveSuccessText]); useEffect(() => { const handlePointerDown = (event: MouseEvent) => { if (isInsideEditorContext(event.target)) return; void commitDraft(); }; document.addEventListener("mousedown", handlePointerDown); return () => { document.removeEventListener("mousedown", handlePointerDown); }; }, [commitDraft, isInsideEditorContext]); const handleBlurCapture = () => { window.setTimeout(() => { if (isInsideEditorContext(document.activeElement)) return; void commitDraft(); }, 0); }; if (variant === "tablet") { return (
setDraft((current) => ({ ...current, ...patch }))} onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))} onProjectChange={(projectId) => void commitPatchedDraft({ projectId })} projects={editorProjects} tags={editorTags} t={t} isRtl={false} portalOwnerId={editorOwnerId} />
{formatDateTime(entry.start_time, lang)}
{formatDuration(entry)}
{validationMessage && (

{validationMessage}

)}
); } return (
setDraft((current) => ({ ...current, ...patch }))} onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))} onProjectChange={(projectId) => void commitPatchedDraft({ projectId })} projects={editorProjects} tags={editorTags} t={t} isRtl={false} compact portalOwnerId={editorOwnerId} />
{formatDuration(entry)}
onDelete(entry)} />
{validationMessage && (

{validationMessage}

)}
); } function MobileRecordedEntryCard({ entry, t, projects, tags, onEdit, onDelete, onRequestRestart, lang, }: { entry: TimeEntry; t: any; projects: Project[]; tags: Tag[]; onEdit: (entry: TimeEntry) => void; onDelete: (entry: TimeEntry) => void; onRequestRestart: (entry: TimeEntry) => void; lang: "en" | "fa"; }) { const deletedProjectLabel = t.timesheet?.deletedProjectLabel || "Deleted project"; const deletedTagLabel = t.timesheet?.deletedTagLabel || "Deleted tag"; const project = getProjectDisplayDetails(entry, projects); const entryTags = getTagDisplayDetails(entry, tags); 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]); const closeSwipe = () => { touchStartXRef.current = null; setSwipeOffset(0); }; const handleTouchStart = (event: React.TouchEvent) => { if (menuOpen) { setMenuOpen(false); } touchStartXRef.current = event.touches[0]?.clientX ?? null; }; const handleTouchMove = (event: React.TouchEvent) => { if (touchStartXRef.current === null) return; const delta = (event.touches[0]?.clientX ?? 0) - touchStartXRef.current; setSwipeOffset(Math.max(-88, Math.min(88, delta))); }; const handleTouchEnd = () => { if (swipeOffset <= -72) { closeSwipe(); onDelete(entry); return; } if (swipeOffset >= 72) { closeSwipe(); onRequestRestart(entry); return; } closeSwipe(); }; return (

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

{project && ( {"\u2022"} {project.isDeleted ? buildDeletedProjectLabel(project.name, deletedProjectLabel) : project.name} )} {project?.clientName && ( - {project.clientName} )}
{formatTimeOnly(entry.start_time, lang)} - {formatTimeOnly(entry.end_time, lang)}

{formatDuration(entry)}

{(entryTags.length > 0 || entry.is_billable) && (
{entryTags.length > 0 && ( {entryTags .map((tag) => (tag.isDeleted ? buildDeletedTagLabel(tag.name, deletedTagLabel) : tag.name)) .join(" | ")} )} {entry.is_billable && ( )}
)}
{menuOpen && createPortal(
, document.body, )}
); } function TimesheetSkeleton({ loadingLabel }: { loadingLabel: string }) { return (
{loadingLabel}
{[0, 1].map((weekIndex) => (
{[0, 1].map((dayIndex) => (
{[0, 1].map((entryIndex) => (
))}
))}
))}
); } export default function Timesheet() { const { t, lang } = useTranslation(); const { activeWorkspace } = useWorkspace(); const [searchParams, setSearchParams] = useSearchParams(); const isRtl = lang === "fa"; const extendedTimesheet = (t.timesheet as { deleteTitle?: string; deleteConfirmMessage?: string; saveSuccess?: string; saveError?: string; clearFilters?: string; customFromLabel?: string; customToLabel?: string; allClientsLabel?: string; allProjectsLabel?: string; allTagsLabel?: string; showFiltersLabel?: string; hideFiltersLabel?: string; applyFiltersLabel?: string; clientFilterPrefix?: string; projectFilterPrefix?: string; tagFilterPrefix?: string; fromFilterPrefix?: string; toFilterPrefix?: string; restartConfirmMessage?: string; discardConfirmMessage?: string; deletedProjectLabel?: string; deletedTagLabel?: string; searchTagsLabel?: string; noTagsFoundLabel?: string; searchProjectsLabel?: string; noProjectsFoundLabel?: string; }) || {}; const [projects, setProjects] = useState([]); const [tags, setTags] = useState([]); const [groupedHistory, setGroupedHistory] = useState([]); const [activeRunningEntry, setActiveRunningEntry] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); const searchQuery = readStringParam(searchParams, "search", ""); const filters = useMemo( () => ({ projectId: readStringParam(searchParams, "project", DEFAULT_ENTRY_FILTERS.projectId), clientId: readStringParam(searchParams, "client", DEFAULT_ENTRY_FILTERS.clientId), tagIds: readArrayParam(searchParams, "tags"), startedAfter: readStringParam(searchParams, "from", DEFAULT_ENTRY_FILTERS.startedAfter), startedBefore: readStringParam(searchParams, "to", DEFAULT_ENTRY_FILTERS.startedBefore), }), [searchParams], ); const hasActiveHistoryFilters = Boolean( searchQuery || filters.projectId || filters.clientId || filters.tagIds.length || filters.startedAfter || filters.startedBefore, ); const [hasMoreHistory, setHasMoreHistory] = useState(false); const [nextOffset, setNextOffset] = useState(0); const [limit] = useState(20); const [ticker, setTicker] = useState(Date.now()); const [modalMode, setModalMode] = useState(null); const [formState, setFormState] = useState(EMPTY_FORM); const [editingEntry, setEditingEntry] = useState(null); const [isSaving, setIsSaving] = useState(false); 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 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, 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; const deletedProjectLabel = extendedTimesheet.deletedProjectLabel || "Deleted project"; const deletedTagLabel = extendedTimesheet.deletedTagLabel || "Deleted tag"; const runningTimerProjects = useMemo( () => buildProjectOptionsForEntry(projects, runningEntry, timerDraft.projectId, deletedProjectLabel), [deletedProjectLabel, projects, runningEntry, timerDraft.projectId], ); const runningTimerTags = useMemo( () => buildTagOptionsForEntry(tags, runningEntry, timerDraft.tags), [runningEntry, tags, timerDraft.tags], ); const modalProjects = useMemo( () => buildProjectOptionsForEntry(projects, editingEntry, formState.projectId, deletedProjectLabel), [deletedProjectLabel, editingEntry, formState.projectId, projects], ); const modalTags = useMemo( () => buildTagOptionsForEntry(tags, editingEntry, formState.tags), [editingEntry, formState.tags, tags], ); useEffect(() => { if (!runningEntry) return; const intervalId = window.setInterval(() => setTicker(Date.now()), 1000); return () => window.clearInterval(intervalId); }, [runningEntry]); useEffect(() => { if (!activeWorkspace?.id) return; const loadOptions = async () => { try { const [projectsData, tagsData] = await Promise.all([ getProjects(activeWorkspace.id, { limit: 100, offset: 0, ordering: "name" }), getTags(activeWorkspace.id, { limit: 100, offset: 0, ordering: "name" }), ]); setProjects((projectsData.results || []).filter((project: Project) => !project.is_archived)); setTags(tagsData.results || []); } catch (error) { console.error(error); toast.error(t.timesheet?.optionsError || "Failed to load projects and tags"); } }; void loadOptions(); }, [activeWorkspace?.id, t.timesheet?.optionsError]); useEffect(() => { setGroupedHistory([]); setNextOffset(0); setHasMoreHistory(false); }, [activeWorkspace?.id]); useEffect(() => { const timeoutId = window.setTimeout(() => { setDebouncedSearchQuery(searchQuery); }, 350); return () => window.clearTimeout(timeoutId); }, [searchQuery]); useEffect(() => { setGroupedHistory([]); setNextOffset(0); setHasMoreHistory(false); }, [debouncedSearchQuery, filters]); useEffect(() => { if (!filters.clientId || !filters.projectId) return; const projectStillMatchesClient = projects.some( (project) => project.id === filters.projectId && project.client?.id === filters.clientId, ); if (!projectStillMatchesClient) { setSearchParams( (current) => updateQueryParams(current, { project: "" }, { project: "" }), { replace: true }, ); } }, [filters.clientId, filters.projectId, projects, setSearchParams]); const loadHistory = useCallback(async ({ offset = 0, append = false }: { offset?: number; append?: boolean } = {}) => { if (!activeWorkspace?.id) return; try { if (append) { setIsLoadingMore(true); } else { setIsLoading(true); } const params: TimeEntryListParams = { limit, offset, search: debouncedSearchQuery, status: "ended", project: filters.projectId || undefined, client: filters.clientId || undefined, tags: filters.tagIds, started_after: filters.startedAfter || undefined, started_before: filters.startedBefore || undefined, }; const data = await getTimeEntries(activeWorkspace.id, params); setGroupedHistory((current) => (append ? mergeGroupedHistory(current, data.groups || []) : (data.groups || []))); setHasMoreHistory(Boolean(data.has_more)); setNextOffset(data.next_offset ?? null); } catch (error) { console.error(error); toast.error(t.timesheet?.fetchError || "Failed to load time entries"); } finally { if (append) { setIsLoadingMore(false); } else { setIsLoading(false); } } }, [activeWorkspace?.id, debouncedSearchQuery, filters, limit, t.timesheet?.fetchError]); const loadRunningEntry = useCallback(async () => { if (!activeWorkspace?.id) { setActiveRunningEntry(null); return; } try { const data = await getTimeEntries(activeWorkspace.id, { limit: 1, offset: 0, status: "running", }); const entry = data.groups?.[0]?.days?.[0]?.entries?.[0] || null; setActiveRunningEntry(entry); } catch (error) { console.error(error); } }, [activeWorkspace?.id]); useEffect(() => { if (!activeWorkspace?.id) return; const timeoutId = window.setTimeout(() => { void loadHistory(); }, 250); return () => window.clearTimeout(timeoutId); }, [activeWorkspace?.id, debouncedSearchQuery, limit, loadHistory, filters]); useEffect(() => { void loadRunningEntry(); }, [loadRunningEntry]); 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]); 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 false; const currentSignature = serializeTimerDraft(timerDraft); if (currentSignature === timerDraftSignatureRef.current) { return false; } if (isTimerSavingRef.current || pendingTimerSignatureRef.current === currentSignature) { return false; } isTimerSavingRef.current = true; pendingTimerSignatureRef.current = currentSignature; 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(() => { 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; setModalMode(null); setEditingEntry(null); setFormState(EMPTY_FORM); }; const openCreateModal = () => { setModalMode("manual"); setEditingEntry(null); setFormState(buildEntryFormState()); }; const openEditModal = (entry: TimeEntry) => { setEditingEntry(entry); setModalMode("edit"); setFormState(buildEntryFormState(entry)); }; const handleSaveEntryModal = async () => { if (modalMode === "manual" && !activeWorkspace?.id) return; const { payload, error } = buildPayloadFromState(formState, { includeWorkspace: modalMode === "manual", workspaceId: activeWorkspace?.id, }); if (!payload) { toast.error(error || (t.timesheet?.saveError || "Failed to save time entry")); return; } try { setIsSaving(true); if (modalMode === "edit" && editingEntry) { const updatedEntry = await updateTimeEntry(editingEntry.id, payload); setGroupedHistory((current) => updateGroupedHistoryEntry(current, updatedEntry)); toast.success(t.timesheet?.updateSuccess || "Time entry updated successfully."); } else { if (!activeWorkspace?.id) return; await createTimeEntry(payload); toast.success(t.timesheet?.createSuccess || "Time entry created"); await loadHistory(); await loadRunningEntry(); } setModalMode(null); setEditingEntry(null); setFormState(EMPTY_FORM); } catch (error) { console.error(error); toast.error(t.timesheet?.saveError || "Failed to save time entry"); } finally { setIsSaving(false); } }; const handleStartTimer = async () => { if (!activeWorkspace?.id || runningEntry) return; try { setIsStartingTimer(true); await createTimeEntry({ workspace_id: activeWorkspace.id, description: timerDraft.description.trim(), project_id: timerDraft.projectId || null, start_time: new Date().toISOString(), tags: timerDraft.tags, is_billable: timerDraft.isBillable, }); toast.success(t.timesheet?.startSuccess || "Timer started"); await loadHistory(); await loadRunningEntry(); } catch (error) { console.error(error); toast.error(t.timesheet?.saveError || "Failed to save time entry"); } finally { setIsStartingTimer(false); } }; 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 handleRestartFromEntry = async (entry: TimeEntry) => { if (!activeWorkspace?.id || runningEntry) return; try { const restartProjectId = entry.project_details?.is_deleted ? null : entry.project; const restartTagIds = (entry.tag_details || []) .filter((tag) => !tag.is_deleted) .map((tag) => tag.id); await createTimeEntry({ workspace_id: activeWorkspace.id, description: entry.description, project_id: restartProjectId, tags: restartTagIds, is_billable: entry.is_billable, start_time: new Date().toISOString(), }); toast.success(t.timesheet?.startSuccess || "Timer started"); await loadHistory(); await loadRunningEntry(); } catch (error) { console.error(error); toast.error(t.timesheet?.saveError || "Failed to save time entry"); } }; 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 confirmDelete = async () => { if (!deleteModal.entry) return; try { setIsDeleting(true); await deleteTimeEntry(deleteModal.entry.id); toast.success(t.timesheet?.deleteSuccess || "Time entry deleted"); setDeleteModal({ isOpen: false, entry: null }); setGroupedHistory((current) => current .map((week) => ({ ...week, days: week.days .map((day) => ({ ...day, entries: day.entries.filter((entry) => entry.id !== deleteModal.entry!.id), })) .filter((day) => day.entries.length > 0) .map((day) => ({ ...day, total_ms: day.entries.reduce((sum, item) => sum + getEntryDurationMs(item), 0) })), })) .filter((week) => week.days.length > 0) .map((week) => ({ ...week, total_ms: week.days.reduce((sum, day) => sum + day.total_ms, 0) })), ); await loadRunningEntry(); } catch (error) { console.error(error); toast.error(t.timesheet?.deleteError || "Failed to delete time entry"); } 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 handleEntryUpdated = useCallback((updatedEntry: TimeEntry) => { setGroupedHistory((current) => updateGroupedHistoryEntry(current, updatedEntry)); if (!updatedEntry.end_time) { setActiveRunningEntry(updatedEntry); } }, []); const handleApplyFilters = useCallback((nextFilters: TimeEntryFilters) => { setSearchParams( (current) => updateQueryParams( current, { project: nextFilters.projectId, client: nextFilters.clientId, tags: nextFilters.tagIds, from: nextFilters.startedAfter, to: nextFilters.startedBefore, }, { project: "", client: "", from: "", to: "", }, ), { replace: true }, ); }, [setSearchParams]); const handleClearFilters = useCallback(() => { setSearchParams( (current) => updateQueryParams( current, { search: "", project: "", client: "", tags: [], from: "", to: "", }, { search: "", project: "", client: "", from: "", to: "", }, ), { replace: true }, ); setDebouncedSearchQuery(""); }, [setSearchParams]); 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); 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}
; } return (

{t.timesheet?.title || 'Timesheets'}

{t.timesheet?.description(activeWorkspace?.name || "-") || 'Manage your Timesheet'}

setTimerDraft((current) => ({ ...current, description: event.target.value }))} disabled={isStartingTimer} className="h-12 rounded-none border-0 bg-transparent dark:bg-transparent px-5 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" />
setTimerDraft((current) => ({ ...current, projectId: String(value) }))} options={[ { value: "", label: t.timesheet?.projectLabel || "Project" }, ...runningTimerProjects.map((project) => ({ value: project.id, label: project.name, searchText: project.client?.name || "", })), ]} placeholder={t.timesheet?.projectLabel || "Project"} searchPlaceholder={extendedTimesheet.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."} emptyLabel={extendedTimesheet.noProjectsFoundLabel || "No projects found."} className="min-w-[190px] max-w-[220px]" 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} />
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) })) } emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."} title={t.tags?.title || "Tags"} compact portalOwnerId={timerEditorOwnerId} className="max-w-[240px]" buttonClassName="max-w-[240px]" />
setTimerDraft((current) => ({ ...current, isBillable: checked }))} label={t.timesheet?.billable || "Billable"} disabled={isStartingTimer} compact />
{runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"}
{runningEntry ? ( <> ) : ( )}
setTimerDraft((current) => ({ ...current, description: event.target.value }))} disabled={isStartingTimer} className="h-10 border-slate-200 bg-slate-50 text-sm dark:border-slate-700 dark:bg-slate-900" />
setTimerDraft((current) => ({ ...current, projectId: String(value) }))} options={[ { value: "", label: t.timesheet?.projectLabel || "Project" }, ...runningTimerProjects.map((project) => ({ value: project.id, label: project.name, searchText: project.client?.name || "", })), ]} placeholder={t.timesheet?.projectLabel || "Project"} searchPlaceholder={extendedTimesheet.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."} emptyLabel={extendedTimesheet.noProjectsFoundLabel || "No projects found."} 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" disabled={isStartingTimer} />
{runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"}
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) })) } emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."} title={t.tags?.title || "Tags"} compact portalOwnerId={timerEditorOwnerId} /> setTimerDraft((current) => ({ ...current, isBillable: checked }))} label={t.timesheet?.billable || "Billable"} disabled={isStartingTimer} compact />
{runningEntry ? ( <> ) : ( )}
setSearchParams( (current) => updateQueryParams(current, { search: value }, { search: "" }), { replace: true }, ) } onApply={handleApplyFilters} onClearFilters={handleClearFilters} projects={projects} tags={tags} searchPlaceholder={t.timesheet?.searchPlaceholder || "Search time entries..."} labels={{ project: t.timesheet?.projectLabel || "Project", 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", allClients: extendedTimesheet.allClientsLabel || "All clients", allProjects: extendedTimesheet.allProjectsLabel || "All projects", allTags: extendedTimesheet.allTagsLabel || "All tags", showFilters: extendedTimesheet.showFiltersLabel || "Show filters", hideFilters: extendedTimesheet.hideFiltersLabel || "Hide filters", apply: extendedTimesheet.applyFiltersLabel || "Apply", clientPrefix: extendedTimesheet.clientFilterPrefix || "Client", projectPrefix: extendedTimesheet.projectFilterPrefix || "Project", tagPrefix: extendedTimesheet.tagFilterPrefix || "Tag", fromPrefix: extendedTimesheet.fromFilterPrefix || "From", toPrefix: extendedTimesheet.toFilterPrefix || "To", searchTags: extendedTimesheet.searchTagsLabel || t.tags?.searchPlaceholder || "Search tags...", noTagsFound: extendedTimesheet.noTagsFoundLabel || "No tags found.", }} />
{isLoading ? ( ) : (
{groupedHistory.map((week) => (

{formatWeekRange(new Date(`${week.week_start}T00:00:00`), lang)}

{t.workspace?.weekTotal}: {formatDurationMs(week.total_ms)}

{week.days.map((day) => (

{formatDayLabel(new Date(`${day.date}T00:00:00`), lang)}

{t.reports?.total || "Total"}: {formatDurationMs(day.total_ms)}

{day.entries.map((entry) => (
))}
))}
))} {groupedHistory.length === 0 && ( )}
)} } > setFormState((current) => ({ ...current, ...patch }))} onToggleTag={(tagId) => setFormState((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))} projects={modalProjects} tags={modalTags} t={t} isRtl={isRtl} /> {deleteModal.entry && ( } >

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

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

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

)} {restartModal.entry && ( } >

{extendedTimesheet.restartConfirmMessage || t.timesheet?.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.discardConfirmMessage || t.timesheet?.discardConfirmMessage || "Are you sure you want to discard this running timer?"}

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

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

)}
); }