`Manage projects for ${workspaceName}`,
active: "Active Projects",
@@ -281,6 +290,105 @@ export const en = {
restore: "Restore",
archive: "Archive",
clientFetchError: "Failed to load clients.",
- },
-
-}
+ namePlaceholder: "Project name...",
+ teamMembers: "Team Members",
+ creator: "Creator",
+ addUser: "Add user by mobile",
+ addFromWorkspace: "Add from workspace",
+ searchMembers: "Search members...",
+ addAllWorkspaceMembers: "Add all workspace members",
+ confirmDeleteTitle: "Remove Member",
+ confirmDeleteDesc: "Are you sure you want to remove this member from the project?",
+ createSuccess: "Project created successfully.",
+ createError: "Failed to create project.",
+ updateSuccess: "Project updated successfully.",
+ updateError: "Failed to update project.",
+ edit: "Edit Project",
+ memberAlreadyAdded: "This user is already on the project team.",
+ roles: {
+ member: "Member",
+ manager: "Manager"
+ },
+ projectMembers: "Project Members",
+ removeAllWorkspaceMembers: "Remove All",
+ searchWorkspaceMembers: "Search by name or enter mobile number...",
+ userNotFound: "No user found with this mobile number.",
+ alreadyInProject: "Already Added",
+ addToProject: "Add to Project",
+ noWorkspaceMembers: "No members found.",
+ },
+
+ tags: {
+ title: "Tags",
+ description: (workspaceName: string) => `Manage tags for ${workspaceName}`,
+ create: "Create Tag",
+ createTitle: "Create Tag",
+ editTitle: "Edit Tag",
+ searchPlaceholder: "Search tags...",
+ nameLabel: "Tag Name",
+ namePlaceholder: "e.g. Design",
+ colorLabel: "Color",
+ emptyState: "No tags found",
+ selectWorkspace: "Please select a workspace first.",
+ fetchError: "Failed to load tags",
+ createSuccess: "Tag created successfully.",
+ updateSuccess: "Tag updated successfully.",
+ saveError: "Failed to save tag.",
+ deleteSuccess: "Tag deleted successfully.",
+ deleteError: "Failed to delete tag.",
+ },
+
+ timesheet: {
+ title: "Timesheet",
+ description: (workspaceName: string) => `Track time inside ${workspaceName}`,
+ selectWorkspace: "Please select a workspace first.",
+ addEntry: "Add Entry",
+ startTimer: "Start Timer",
+ stopTimer: "Stop Timer",
+ timerRunning: "Timer Running",
+ runningLabel: "Current timer",
+ runningBadge: "Running",
+ noRunningEntry: "No running entry",
+ searchPlaceholder: "Search time entries...",
+ orderingNewest: "Newest first",
+ orderingOldest: "Oldest first",
+ emptyState: "No time entries found",
+ emptyDescription: "No description",
+ createTitle: "Add Time Entry",
+ startTitle: "Start Timer",
+ editTitle: "Edit Time Entry",
+ createSuccess: "Time entry created successfully.",
+ startSuccess: "Timer started successfully.",
+ updateSuccess: "Time entry updated successfully.",
+ saveError: "Failed to save time entry.",
+ stopSuccess: "Timer stopped successfully.",
+ stopError: "Failed to stop timer.",
+ deleteSuccess: "Time entry deleted successfully.",
+ deleteError: "Failed to delete time entry.",
+ fetchError: "Failed to load time entries.",
+ optionsError: "Failed to load projects and tags.",
+ descriptionLabel: "Description",
+ descriptionPlaceholder: "What are you working on?",
+ projectLabel: "Project",
+ noProject: "No project",
+ startLabel: "Start",
+ endLabel: "End",
+ billable: "Billable",
+ noTagsHint: "Create tags first from the Tags page.",
+ clearFilters: "Clear filters",
+ customFromLabel: "From",
+ customToLabel: "To",
+ allClientsLabel: "All clients",
+ allProjectsLabel: "All projects",
+ allTagsLabel: "All tags",
+ showFiltersLabel: "Show filters",
+ hideFiltersLabel: "Hide filters",
+ applyFiltersLabel: "Apply",
+ clientFilterPrefix: "Client",
+ projectFilterPrefix: "Project",
+ tagFilterPrefix: "Tag",
+ fromFilterPrefix: "From",
+ toFilterPrefix: "To",
+ },
+
+}
diff --git a/src/locales/fa.ts b/src/locales/fa.ts
index 9a93e5a..ccd6c38 100644
--- a/src/locales/fa.ts
+++ b/src/locales/fa.ts
@@ -5,11 +5,16 @@ export const fa = {
confirmLogoutTitle: "تایید خروج",
confirmLogoutMessage: "آیا مطمئن هستید که میخواهید از حساب خود خارج شوید؟",
confirmLeave: "تغییرات ذخیره نشدهای دارید. آیا مطمئن هستید که میخواهید خارج شوید؟",
+ add: "افزودن",
+ create: "ایجاد",
cancel: "لغو",
save: "ذخیره",
+ remove: "حذف",
lightMode: "حالت روشن",
darkMode: "حالت تاریک",
- loadingText: "در حال بارگزاری...",
+ loadingText: "در حال بارگذاری...",
+ loading: "در حال بارگذاری...",
+ noMoreResults: "نتیجه دیگری نیست.",
actions: {
create: "ایجاد",
@@ -147,10 +152,12 @@ export const fa = {
emptyState: "شما در هیچ ورکاسپیس عضو نیستید.",
createTitle: "ایجاد ورکاسپیس",
editTitle: "ویرایش ورکاسپیس",
- detailTitle: "جزئیات ورکاسپیس",
- save: "ذخیره",
- create: "ایجاد",
- back: "بازگشت به ورکاسپیسها",
+ detailTitle: "جزئیات ورکاسپیس",
+ save: "ذخیره",
+ create: "ایجاد",
+ noWorkspaceTitle: "خوش آمدید!",
+ noWorkspaceDesc: "لطفاً اولین ورکاسپیس خود را ایجاد کنید.",
+ back: "بازگشت به ورکاسپیسها",
roleLabel: "نقش شما",
roles: {
owner: "مالک",
@@ -234,13 +241,15 @@ export const fa = {
next: "بعدی",
},
- sidebar: {
- workspaces: 'ورکاسپیسها',
- clients: 'مشتریان',
- projects: "پروژهها",
- expand: 'باز کردن',
- collapse: 'جمع کردن',
- },
+ sidebar: {
+ timesheet: 'تایمشیت',
+ workspaces: 'ورکاسپیسها',
+ clients: 'مشتریان',
+ projects: "پروژهها",
+ tags: "تگها",
+ expand: 'باز کردن',
+ collapse: 'جمع کردن',
+ },
ordering: {
createdAtDesc: "جدیدترین",
@@ -250,7 +259,7 @@ export const fa = {
nameDesc: "نام (نزولی)",
},
- projects: {
+ projects: {
title: "پروژهها",
description: (workspaceName: string) => `مدیریت پروژهها برای ${workspaceName}`,
active: "پروژههای فعال",
@@ -278,5 +287,104 @@ export const fa = {
restore: "بازیابی",
archive: "بایگانی",
clientFetchError: "خطا در دریافت لیست مشتریان.",
- },
-}
+ memberAlreadyAdded: "این کاربر قبلا اضافه شده است",
+ creator: "سازنده",
+ addUser: "افزودن کاربر",
+ addFromWorkspace: "افزودن از اعضای ورکاسپیس",
+ searchMembers: "جستجوی اعضا",
+ addAllWorkspaceMembers: "افزودن همه اعضای ورکاسپیس",
+ confirmDeleteTitle: "حذف عضو",
+ confirmDeleteDesc: "آیا مطمئن هستید که میخواهید این عضو را حذف کنید؟",
+ roles: {
+ member: "عضو",
+ manager: "مدیر"
+ },
+ namePlaceholder: "نام پروژه...",
+ teamMembers: "اعضای تیم",
+ createSuccess: "پروژه با موفقیت ایجاد شد.",
+ createError: "خطا در ایجاد پروژه.",
+ updateSuccess: "پروژه با موفقیت بهروزرسانی شد.",
+ updateError: "بهروزرسانی پروژه با خطا مواجه شد.",
+ edit: "ویرایش پروژه",
+ projectMembers: "اعضای پروژه",
+ removeAllWorkspaceMembers: "حذف همه",
+ searchWorkspaceMembers: "جستجو با نام یا وارد کردن شماره موبایل...",
+ userNotFound: "کاربری با این شماره موبایل یافت نشد.",
+ alreadyInProject: "قبلاً اضافه شده",
+ addToProject: "افزودن به پروژه",
+ noWorkspaceMembers: "عضوی یافت نشد.",
+ },
+
+ tags: {
+ title: "تگها",
+ description: (workspaceName: string) => `مدیریت تگها برای ${workspaceName}`,
+ create: "ایجاد تگ",
+ createTitle: "ایجاد تگ",
+ editTitle: "ویرایش تگ",
+ searchPlaceholder: "جستوجوی تگها...",
+ nameLabel: "نام تگ",
+ namePlaceholder: "مثلاً طراحی",
+ colorLabel: "رنگ",
+ emptyState: "تگی یافت نشد",
+ selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
+ fetchError: "دریافت تگها با خطا مواجه شد.",
+ createSuccess: "تگ با موفقیت ایجاد شد.",
+ updateSuccess: "تگ با موفقیت بهروزرسانی شد.",
+ saveError: "ذخیره تگ با خطا مواجه شد.",
+ deleteSuccess: "تگ با موفقیت حذف شد.",
+ deleteError: "حذف تگ با خطا مواجه شد.",
+ },
+
+ timesheet: {
+ title: "تایمشیت",
+ description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`,
+ selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
+ addEntry: "افزودن ورودی",
+ startTimer: "شروع تایمر",
+ stopTimer: "توقف تایمر",
+ timerRunning: "تایمر فعال است",
+ runningLabel: "تایمر فعلی",
+ runningBadge: "در حال اجرا",
+ noRunningEntry: "تایمر فعالی وجود ندارد",
+ searchPlaceholder: "جستوجوی ورودیهای زمان...",
+ orderingNewest: "جدیدترین",
+ orderingOldest: "قدیمیترین",
+ emptyState: "ورودی زمانی یافت نشد",
+ emptyDescription: "بدون توضیح",
+ createTitle: "افزودن ورودی زمان",
+ startTitle: "شروع تایمر",
+ editTitle: "ویرایش ورودی زمان",
+ createSuccess: "ورودی زمان با موفقیت ایجاد شد.",
+ startSuccess: "تایمر با موفقیت شروع شد.",
+ updateSuccess: "ورودی زمان با موفقیت بهروزرسانی شد.",
+ saveError: "ذخیره ورودی زمان با خطا مواجه شد.",
+ stopSuccess: "تایمر با موفقیت متوقف شد.",
+ stopError: "توقف تایمر با خطا مواجه شد.",
+ deleteSuccess: "ورودی زمان با موفقیت حذف شد.",
+ deleteError: "حذف ورودی زمان با خطا مواجه شد.",
+ fetchError: "دریافت ورودیهای زمان با خطا مواجه شد.",
+ optionsError: "دریافت پروژهها و تگها با خطا مواجه شد.",
+ descriptionLabel: "توضیحات",
+ descriptionPlaceholder: "روی چه چیزی کار میکنید؟",
+ projectLabel: "پروژه",
+ noProject: "بدون پروژه",
+ startLabel: "شروع",
+ endLabel: "پایان",
+ billable: "قابل صورتحساب",
+ noTagsHint: "ابتدا از صفحه تگها، تگ ایجاد کنید.",
+ clearFilters: "پاک کردن فیلترها",
+ customFromLabel: "از",
+ customToLabel: "تا",
+ allClientsLabel: "همه مشتریها",
+ allProjectsLabel: "همه پروژهها",
+ allTagsLabel: "همه تگها",
+ showFiltersLabel: "نمایش فیلترها",
+ hideFiltersLabel: "مخفی کردن فیلترها",
+ applyFiltersLabel: "اعمال",
+ clientFilterPrefix: "مشتری",
+ projectFilterPrefix: "پروژه",
+ tagFilterPrefix: "تگ",
+ fromFilterPrefix: "از",
+ toFilterPrefix: "تا",
+ },
+}
diff --git a/src/pages/Tags.tsx b/src/pages/Tags.tsx
new file mode 100644
index 0000000..2113bb6
--- /dev/null
+++ b/src/pages/Tags.tsx
@@ -0,0 +1,242 @@
+import { useEffect, useState } from "react";
+import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { createTag, deleteTag, getTags, type Tag, updateTag } from "../api/tags";
+import { useWorkspace } from "../context/WorkspaceContext";
+import { useTranslation } from "../hooks/useTranslation";
+import FilterBar from "../components/FilterBar";
+import { Modal } from "../components/Modal";
+import { Pagination } from "../components/Pagination";
+import { Button } from "../components/ui/button";
+import { Card, CardContent, CardTitle } from "../components/ui/card";
+import { Input } from "../components/ui/input";
+
+const DEFAULT_COLOR = "#3B82F6";
+
+export default function Tags() {
+ const { t } = useTranslation();
+ const { activeWorkspace } = useWorkspace();
+
+ const [tags, setTags] = useState
([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [ordering, setOrdering] = useState("-updated_at");
+ const [currentPage, setCurrentPage] = useState(1);
+ const [totalItems, setTotalItems] = useState(0);
+ const [limit, setLimit] = useState(10);
+
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [editingTag, setEditingTag] = useState(null);
+ const [formName, setFormName] = useState("");
+ const [formColor, setFormColor] = useState(DEFAULT_COLOR);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const orderingOptions = [
+ { value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" },
+ { value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
+ { value: "created_at", label: t.ordering?.createdAt || "Oldest First" },
+ { value: "name", label: t.ordering?.name || "Name (A-Z)" },
+ { value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
+ ];
+
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [searchQuery, ordering]);
+
+ useEffect(() => {
+ if (!activeWorkspace?.id) return;
+
+ const timeoutId = setTimeout(() => {
+ void loadTags();
+ }, 250);
+
+ return () => clearTimeout(timeoutId);
+ }, [activeWorkspace?.id, searchQuery, ordering, currentPage, limit]);
+
+ const loadTags = async () => {
+ if (!activeWorkspace?.id) return;
+
+ try {
+ setIsLoading(true);
+ const data = await getTags(activeWorkspace.id, {
+ limit,
+ offset: (currentPage - 1) * limit,
+ ordering,
+ search: searchQuery,
+ });
+ setTags(data.results || []);
+ setTotalItems(data.count || 0);
+ } catch (error) {
+ console.error(error);
+ toast.error(t.tags?.fetchError || "Failed to load tags");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const openCreateModal = () => {
+ setEditingTag(null);
+ setFormName("");
+ setFormColor(DEFAULT_COLOR);
+ setIsModalOpen(true);
+ };
+
+ const openEditModal = (tag: Tag) => {
+ setEditingTag(tag);
+ setFormName(tag.name);
+ setFormColor(tag.color || DEFAULT_COLOR);
+ setIsModalOpen(true);
+ };
+
+ const closeModal = () => {
+ if (isSaving) return;
+ setIsModalOpen(false);
+ setEditingTag(null);
+ setFormName("");
+ setFormColor(DEFAULT_COLOR);
+ };
+
+ const handleSubmit = async () => {
+ if (!activeWorkspace?.id || !formName.trim()) return;
+
+ try {
+ setIsSaving(true);
+
+ if (editingTag) {
+ await updateTag(editingTag.id, { name: formName.trim(), color: formColor });
+ toast.success(t.tags?.updateSuccess || "Tag updated");
+ } else {
+ await createTag(activeWorkspace.id, { name: formName.trim(), color: formColor });
+ toast.success(t.tags?.createSuccess || "Tag created");
+ }
+
+ closeModal();
+ await loadTags();
+ } catch (error) {
+ console.error(error);
+ toast.error(t.tags?.saveError || "Failed to save tag");
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleDelete = async (tag: Tag) => {
+ try {
+ await deleteTag(tag.id);
+ toast.success(t.tags?.deleteSuccess || "Tag deleted");
+ await loadTags();
+ } catch (error) {
+ console.error(error);
+ toast.error(t.tags?.deleteError || "Failed to delete tag");
+ }
+ };
+
+ if (!activeWorkspace) {
+ return {t.tags?.selectWorkspace || t.clients.selectWorkspace}
;
+ }
+
+ return (
+
+
+
+
{t.tags?.title || "Tags"}
+
+ {t.tags?.description?.(activeWorkspace.name) || `Manage tags for ${activeWorkspace.name}`}
+
+
+
+
+
+
+
+ {isLoading ? (
+
{t.loading || "Loading..."}
+ ) : (
+
+
+ {tags.map((tag) => (
+
+
+
+
+
+
{tag.name}
+
{tag.color || DEFAULT_COLOR}
+
+
+
+
+
+
+
+
+
+ ))}
+
+ {tags.length === 0 && (
+
+
+
{t.tags?.emptyState || "No tags found"}
+
+ )}
+
+
+
+
+ )}
+
+
+
+
+ >
+ }
+ >
+
+
+
+ );
+}
diff --git a/src/pages/Timesheet.tsx b/src/pages/Timesheet.tsx
new file mode 100644
index 0000000..155a8a9
--- /dev/null
+++ b/src/pages/Timesheet.tsx
@@ -0,0 +1,2495 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, 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 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]);
+
+ const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name);
+ const joinedSelectedLabels = selectedLabels.join(" | ");
+ const buttonLabel = compact
+ ? selectedTags.length > 0
+ ? joinedSelectedLabels
+ : ""
+ : selectedLabels.length > 0
+ ? selectedLabels.join(", ")
+ : title;
+
+ return (
+
+ {!compact &&
{title}
}
+
+
+ {isOpen && (
+ createPortal(
+
+ {tags.length === 0 ? (
+
{emptyHint}
+ ) : (
+
+ {tags.map((tag) => {
+ const selected = selectedTags.includes(tag.id);
+ return (
+
+ );
+ })}
+
+ )}
+
,
+ 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" : ""}
+ />
+
+
+
+
+
+
+
+
+ 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,
+}: {
+ entry: TimeEntry;
+ t: any;
+ projects: Project[];
+ tags: Tag[];
+ onDelete: (entry: TimeEntry) => void;
+ onRestart: (entry: TimeEntry) => void;
+ onEntryUpdated: (entry: TimeEntry) => void;
+}) {
+ 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";
+
+ 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);
+ };
+
+ return (
+
+
+
setDraft((current) => ({ ...current, ...patch }))}
+ onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
+ onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
+ projects={projects}
+ tags={tags}
+ t={t}
+ isRtl={false}
+ compact
+ portalOwnerId={editorOwnerId}
+ />
+
+
+ {formatDuration(entry)}
+
+
+
+
+
+ onDelete(entry)} />
+
+
+
+ {validationMessage && (
+
+ )}
+
+ );
+}
+
+function MobileRecordedEntryCard({
+ entry,
+ t,
+ projects,
+ tags,
+ onEdit,
+ onDelete,
+ onRequestRestart,
+}: {
+ entry: TimeEntry;
+ t: any;
+ projects: Project[];
+ tags: Tag[];
+ 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({});
+
+ 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.name}
+
+ )}
+ {project?.client?.name && (
+
+ - {project.client.name}
+
+ )}
+
+
+
+ {formatTimeOnly(entry.start_time)} - {formatTimeOnly(entry.end_time)}
+ {formatDateTime(entry.start_time, "en")}
+
+
+
+
+
{formatDuration(entry)}
+
+
+
+ {(entryTags.length > 0 || entry.is_billable) && (
+
+ {entryTags.length > 0 && (
+
+ {entryTags.map((tag) => tag.name).join(" | ")}
+
+ )}
+ {entry.is_billable && (
+
+
+
+ )}
+
+ )}
+
+
+
+ {menuOpen &&
+ createPortal(
+
+
+
+
+
,
+ document.body,
+ )}
+
+ );
+}
+
+export default function Timesheet() {
+ const { t, lang } = useTranslation();
+ const { activeWorkspace } = useWorkspace();
+ 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;
+ }) || {};
+
+ 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 [searchQuery, setSearchQuery] = useState("");
+ const [filters, setFilters] = useState(DEFAULT_ENTRY_FILTERS);
+ 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 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 runningEntry = activeRunningEntry;
+
+ 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 || []);
+ 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(() => {
+ setSearchQuery("");
+ setFilters(DEFAULT_ENTRY_FILTERS);
+ setGroupedHistory([]);
+ setNextOffset(0);
+ setHasMoreHistory(false);
+ }, [activeWorkspace?.id]);
+
+ useEffect(() => {
+ setGroupedHistory([]);
+ setNextOffset(0);
+ setHasMoreHistory(false);
+ }, [filters, searchQuery]);
+
+ useEffect(() => {
+ if (!filters.clientId || !filters.projectId) return;
+
+ const projectStillMatchesClient = projects.some(
+ (project) => project.id === filters.projectId && project.client?.id === filters.clientId,
+ );
+
+ if (!projectStillMatchesClient) {
+ setFilters((current) => ({ ...current, projectId: "" }));
+ }
+ }, [filters.clientId, filters.projectId, projects]);
+
+ 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: searchQuery,
+ 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, filters, limit, searchQuery, 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, limit, loadHistory, filters, searchQuery]);
+
+ useEffect(() => {
+ void loadRunningEntry();
+ }, [loadRunningEntry]);
+
+ useEffect(() => {
+ if (!runningEntry) {
+ timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
+ setTimerDraft(EMPTY_TIMER_DRAFT);
+ return;
+ }
+
+ const nextDraft = buildTimerDraftState(runningEntry);
+ timerDraftSignatureRef.current = serializeTimerDraft(nextDraft);
+ setTimerDraft(nextDraft);
+ }, [runningEntry]);
+
+ useEffect(() => {
+ const saveErrorText = extendedTimesheet.saveError || "Failed to save time entry";
+ const saveSuccessText = extendedTimesheet.saveSuccess || "Time entry saved";
+
+ if (!runningEntry) return;
+
+ const currentSignature = serializeTimerDraft(timerDraft);
+ if (currentSignature === timerDraftSignatureRef.current) return;
+
+ if (timerSaveTimeoutRef.current) {
+ window.clearTimeout(timerSaveTimeoutRef.current);
+ }
+
+ timerSaveTimeoutRef.current = window.setTimeout(async () => {
+ try {
+ const updatedEntry = await updateTimeEntry(runningEntry.id, {
+ description: timerDraft.description.trim(),
+ project_id: timerDraft.projectId || null,
+ tags: timerDraft.tags,
+ is_billable: timerDraft.isBillable,
+ });
+
+ const syncedDraft = buildTimerDraftState(updatedEntry);
+ timerDraftSignatureRef.current = serializeTimerDraft(syncedDraft);
+ setTimerDraft(syncedDraft);
+ setActiveRunningEntry(updatedEntry);
+ toast.success(saveSuccessText);
+ } catch (error) {
+ console.error(error);
+ toast.error(saveErrorText);
+ }
+ }, 500);
+
+ return () => {
+ if (timerSaveTimeoutRef.current) {
+ window.clearTimeout(timerSaveTimeoutRef.current);
+ }
+ };
+ }, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry, timerDraft]);
+
+ useEffect(() => {
+ return () => {
+ if (timerSaveTimeoutRef.current) {
+ window.clearTimeout(timerSaveTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ 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 {
+ await createTimeEntry({
+ workspace_id: activeWorkspace.id,
+ description: entry.description,
+ project_id: entry.project,
+ tags: entry.tags,
+ 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((nextSearchQuery: string, nextFilters: TimeEntryFilters) => {
+ setSearchQuery(nextSearchQuery);
+ setFilters(nextFilters);
+ }, []);
+
+ const handleClearFilters = useCallback(() => {
+ setSearchQuery("");
+ 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]);
+
+ if (!activeWorkspace) {
+ return {t.timesheet?.selectWorkspace || t.clients.selectWorkspace}
;
+ }
+
+ return (
+
+
+
+ {t.timesheet?.title || "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, tags: toggleTagId(current.tags, tagId) }))
+ }
+ emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
+ title={t.tags?.title || "Tags"}
+ compact
+ />
+
+
+
+ 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, tags: toggleTagId(current.tags, tagId) }))
+ }
+ emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
+ title={t.tags?.title || "Tags"}
+ compact
+ />
+
+ setTimerDraft((current) => ({ ...current, isBillable: checked }))}
+ label={t.timesheet?.billable || "Billable"}
+ disabled={isStartingTimer}
+ compact
+ />
+
+
+
+ {runningEntry ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
{t.loading || "Loading..."}
+ ) : (
+
+
+ {groupedHistory.map((week) => (
+
+
+
+ {formatWeekRange(new Date(`${week.week_start}T00:00:00`), lang)}
+
+
+ Week total: {formatDurationMs(week.total_ms)}
+
+
+
+ {week.days.map((day) => (
+
+
+
+ {formatDayLabel(new Date(`${day.date}T00:00:00`), lang)}
+
+
+ Total: {formatDurationMs(day.total_ms)}
+
+
+
+
+ {day.entries.map((entry) => (
+
+ ))}
+
+
+ ))}
+
+ ))}
+
+ {groupedHistory.length === 0 && (
+
+
+
{t.timesheet?.emptyState || "No time entries found"}
+
+ )}
+
+
+ )}
+
+
+
+
+ >
+ }
+ >
+ setFormState((current) => ({ ...current, ...patch }))}
+ onToggleTag={(tagId) => setFormState((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
+ projects={projects}
+ tags={tags}
+ t={t}
+ isRtl={isRtl}
+ />
+
+
+ {deleteModal.entry && (
+
+
+
+ >
+ }
+ >
+
+
+ {extendedTimesheet.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 || "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)}
+
+
+
+
+ )}
+
+ );
+}