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 { 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 { 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 { Select } from "../components/ui/Select"; import { useWorkspace } from "../context/WorkspaceContext"; import { useTranslation } from "../hooks/useTranslation"; 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 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 }; }; 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, }: { tags: Tag[]; selectedTags: string[]; onToggleTag: (tagId: string) => void; emptyHint: string; title: string; compact?: boolean; portalOwnerId?: string; }) { 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); window.addEventListener("scroll", closeOnViewportChange, true); } return () => { 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 ? 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); return ( ); })} {filteredTags.length === 0 && (
No tags found.
)}
)}
, document.body ) )}
); } function ProjectInlineSelect({ projects, value, onChange, placeholder, portalOwnerId, className = "", dropdownClassName = "", disabled = false, }: { projects: Project[]; value: string; onChange: (projectId: string) => void; placeholder: string; portalOwnerId?: string; className?: string; dropdownClassName?: string; disabled?: boolean; }) { 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 = 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`, width: `${dropdownWidth}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]); const selectedProject = projects.find((project) => project.id === value); const label = selectedProject?.name || placeholder; return (
{isOpen && !disabled && createPortal(
{projects.map((project) => { const selected = project.id === value; return ( ); })}
, 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-[220px] 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"} portalOwnerId={portalOwnerId} className="max-w-[180px]" /> {selectedProject && ( - {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" : ""} />
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, 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" />