diff --git a/src/App.tsx b/src/App.tsx index 00b4c9d..a687551 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,10 @@ import WorkspaceDetail from "./pages/WorkspaceDetail" import EditWorkspace from "./pages/WorkspaceEdit" import Clients from "./pages/Clients" import { Projects } from "./pages/Projects" +import ProjectCreate from "./pages/ProjectCreate" +import ProjectEdit from "./pages/ProjectEdit" +import Tags from "./pages/Tags" +import Timesheet from "./pages/Timesheet" const MainLayout = () => { return ( @@ -33,7 +37,7 @@ const MainLayout = () => { const RootRedirect = () => { const isAuthenticated = !!localStorage.getItem("accessToken") - return isAuthenticated ? : + return isAuthenticated ? : } const router = createBrowserRouter([ @@ -51,12 +55,16 @@ const router = createBrowserRouter([ element: , children: [ { path: "/profile", element: }, + { path: "/timesheet", element: }, + { path: "/tags", element: }, { path: "/workspaces", element: }, { path: "/workspaces/create", element: }, { path: "/workspaces/:id", element: }, { path: "/workspaces/:id/edit", element: }, { path: "/clients", element: }, { path: "/projects", element: }, + { path: "/projects/create", element: }, + { path: "/projects/:id/edit", element: }, ], }, ], diff --git a/src/api/tags.ts b/src/api/tags.ts new file mode 100644 index 0000000..75fda29 --- /dev/null +++ b/src/api/tags.ts @@ -0,0 +1,61 @@ +import { authFetch } from "./client"; + +export interface Tag { + id: string; + workspace: string; + name: string; + color: string; + created_at: string; + updated_at: string; +} + +interface PaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +export const getTags = async ( + workspaceId: string, + params: { limit?: number; offset?: number; search?: string; ordering?: string } = {}, +): Promise> => { + const query = new URLSearchParams({ workspace: workspaceId }); + + if (params.limit !== undefined) query.append("limit", String(params.limit)); + if (params.offset !== undefined) query.append("offset", String(params.offset)); + if (params.search) query.append("search", params.search); + if (params.ordering) query.append("ordering", params.ordering); + + const response = await authFetch(`/api/tags/?${query.toString()}`); + if (!response.ok) throw new Error("Failed to fetch tags"); + return response.json(); +}; + +export const createTag = async (workspaceId: string, data: { name: string; color: string }) => { + const response = await authFetch("/api/tags/", { + method: "POST", + body: JSON.stringify({ + workspace_id: workspaceId, + ...data, + }), + }); + if (!response.ok) throw new Error("Failed to create tag"); + return response.json(); +}; + +export const updateTag = async (id: string, data: Partial>) => { + const response = await authFetch(`/api/tags/${id}/`, { + method: "PATCH", + body: JSON.stringify(data), + }); + if (!response.ok) throw new Error("Failed to update tag"); + return response.json(); +}; + +export const deleteTag = async (id: string) => { + const response = await authFetch(`/api/tags/${id}/`, { + method: "DELETE", + }); + if (!response.ok) throw new Error("Failed to delete tag"); +}; diff --git a/src/api/timeEntries.ts b/src/api/timeEntries.ts new file mode 100644 index 0000000..3f1fc75 --- /dev/null +++ b/src/api/timeEntries.ts @@ -0,0 +1,122 @@ +import { authFetch } from "./client"; + +export interface TimeEntry { + id: string; + workspace: string; + user: string; + project: string | null; + description: string; + start_time: string; + end_time: string | null; + duration: string | null; + tags: string[]; + is_billable: boolean; + hourly_rate: string | null; + currency: string; + created_at: string; + updated_at: string; +} + +export interface TimeEntryGroupDay { + key: string; + date: string; + total_ms: number; + entries: TimeEntry[]; +} + +export interface TimeEntryGroupWeek { + key: string; + week_start: string; + week_end: string; + total_ms: number; + days: TimeEntryGroupDay[]; +} + +interface GroupedTimeEntryResponse { + items_per_page: number; + current_page_items_count: number; + total_items: number; + offset: number; + next_offset: number | null; + has_more: boolean; + groups: TimeEntryGroupWeek[]; +} + +export interface TimeEntryPayload { + workspace_id?: string; + project_id?: string | null; + description?: string; + start_time?: string; + end_time?: string | null; + tags?: string[]; + is_billable?: boolean; +} + +export interface TimeEntryListParams { + limit?: number; + offset?: number; + search?: string; + status?: "running" | "ended" | "all"; + project?: string; + client?: string; + tags?: string[]; + started_after?: string; + started_before?: string; +} + +export const getTimeEntries = async ( + workspaceId: string, + params: TimeEntryListParams = {}, +): Promise => { + const query = new URLSearchParams({ workspace: workspaceId }); + + if (params.limit !== undefined) query.append("limit", String(params.limit)); + if (params.offset !== undefined) query.append("offset", String(params.offset)); + if (params.search) query.append("search", params.search); + if (params.status) query.append("status", params.status); + if (params.project) query.append("project", params.project); + if (params.client) query.append("client", params.client); + if (params.started_after) query.append("started_after", params.started_after); + if (params.started_before) query.append("started_before", params.started_before); + if (params.tags?.length) { + params.tags.forEach((tagId) => query.append("tags", tagId)); + } + + const response = await authFetch(`/api/time-entries/?${query.toString()}`); + if (!response.ok) throw new Error("Failed to fetch time entries"); + return response.json(); +}; + +export const createTimeEntry = async (payload: TimeEntryPayload) => { + const response = await authFetch("/api/time-entries/", { + method: "POST", + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error("Failed to create time entry"); + return response.json(); +}; + +export const updateTimeEntry = async (id: string, payload: TimeEntryPayload) => { + const response = await authFetch(`/api/time-entries/${id}/`, { + method: "PATCH", + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error("Failed to update time entry"); + return response.json(); +}; + +export const stopTimeEntry = async (id: string, endTime?: string) => { + const response = await authFetch(`/api/time-entries/${id}/stop/`, { + method: "POST", + body: JSON.stringify(endTime ? { end_time: endTime } : {}), + }); + if (!response.ok) throw new Error("Failed to stop time entry"); + return response.json(); +}; + +export const deleteTimeEntry = async (id: string) => { + const response = await authFetch(`/api/time-entries/${id}/`, { + method: "DELETE", + }); + if (!response.ok) throw new Error("Failed to delete time entry"); +}; diff --git a/src/components/InfiniteScroll.tsx b/src/components/InfiniteScroll.tsx index 08414bc..c417dc4 100644 --- a/src/components/InfiniteScroll.tsx +++ b/src/components/InfiniteScroll.tsx @@ -18,15 +18,28 @@ export const InfiniteScroll: React.FC = ({ loader, }) => { const observerTarget = useRef(null); + const onLoadMoreRef = useRef(onLoadMore); + const hasMoreRef = useRef(hasMore); + const isLoadingRef = useRef(isLoading); + + useEffect(() => { + onLoadMoreRef.current = onLoadMore; + hasMoreRef.current = hasMore; + isLoadingRef.current = isLoading; + }, [onLoadMore, hasMore, isLoading]); useEffect(() => { const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && hasMore && !isLoading) { - onLoadMore(); + if (entries[0].isIntersecting && hasMoreRef.current && !isLoadingRef.current) { + onLoadMoreRef.current(); } }, - { threshold: 0.1 } + { + root: null, + rootMargin: "200px", + threshold: 0 + } ); if (observerTarget.current) { @@ -34,12 +47,13 @@ export const InfiniteScroll: React.FC = ({ } return () => observer.disconnect(); - }, [hasMore, isLoading, onLoadMore]); + }, []); return (
{children} - {hasMore &&
} +
+ {isLoading && ( loader || (
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 5ee6936..b6dd634 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -3,24 +3,26 @@ import { X } from "lucide-react"; import { Card } from "./ui/card"; import { Button } from "./ui/button"; -interface ModalProps { - isOpen: boolean; - onClose: () => void; - title: string; - children: React.ReactNode; - footer?: React.ReactNode; - maxWidth?: string; - isFa?: boolean; -} +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + description?: React.ReactNode; + footer?: React.ReactNode; + maxWidth?: string; + isFa?: boolean; +} export const Modal: React.FC = ({ isOpen, onClose, - title, - children, - footer, - maxWidth = "max-w-lg", -}) => { + title, + children, + description, + footer, + maxWidth = "max-w-lg", +}) => { useEffect(() => { if (isOpen) { document.body.style.overflow = "hidden"; @@ -34,16 +36,16 @@ export const Modal: React.FC = ({ if (!isOpen) return null; - return ( -
- e.stopPropagation()} - > -
+ return ( +
+ e.stopPropagation()} + > +

{title}

@@ -58,14 +60,21 @@ export const Modal: React.FC = ({
-
{children}
- - {footer && ( -
- {footer} -
- )} -
-
- ); +
+ {description && ( +

{description}

+ )} + {children} +
+ + {footer && ( +
+
+ {footer} +
+
+ )} + +
+ ); }; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 0065c7a..743892a 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,14 +1,16 @@ import { useState } from 'react'; import { NavLink } from 'react-router-dom'; -import { - Users, - LayoutDashboard, - PanelLeftClose, - PanelLeftOpen, - PanelRightClose, - PanelRightOpen, - Briefcase, -} from 'lucide-react'; +import { + Users, + LayoutDashboard, + PanelLeftClose, + PanelLeftOpen, + PanelRightClose, + PanelRightOpen, + Briefcase, + Clock3, + Tags, +} from 'lucide-react'; import { useTranslation } from '../hooks/useTranslation'; export const Sidebar = () => { @@ -21,10 +23,20 @@ export const Sidebar = () => { ? (isCollapsed ? PanelRightOpen : PanelRightClose) : (isCollapsed ? PanelLeftOpen : PanelLeftClose); - const navItems = [ - { - path: '/workspaces', - icon: LayoutDashboard, + const navItems = [ + { + path: '/timesheet', + icon: Clock3, + label: t.sidebar?.timesheet || 'Timesheet' + }, + { + path: '/tags', + icon: Tags, + label: t.sidebar?.tags || 'Tags' + }, + { + path: '/workspaces', + icon: LayoutDashboard, label: t.sidebar?.workspaces || 'Workspaces' }, { diff --git a/src/components/timesheet/TimesheetFilterBar.tsx b/src/components/timesheet/TimesheetFilterBar.tsx new file mode 100644 index 0000000..3d59322 --- /dev/null +++ b/src/components/timesheet/TimesheetFilterBar.tsx @@ -0,0 +1,387 @@ +import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { BriefcaseBusiness, CalendarRange, Check, ChevronDown, FolderKanban, Search, SlidersHorizontal, Tag as TagIcon, X } from "lucide-react"; + +import type { Project } from "../../api/projects"; +import type { Tag } from "../../api/tags"; +import JalaliDatePicker from "../ui/JalaliDatePicker"; +import { Select } from "../ui/Select"; + +export interface TimeEntryFilters { + projectId: string; + clientId: string; + tagIds: string[]; + startedAfter: string; + startedBefore: string; +} + +interface TimesheetFilterBarProps { + searchQuery: string; + filters: TimeEntryFilters; + onApply: (searchQuery: string, filters: TimeEntryFilters) => void; + onClearFilters: () => void; + projects: Project[]; + tags: Tag[]; + searchPlaceholder: string; + labels?: { + project?: string; + client?: string; + tags?: string; + clear?: string; + customFrom?: string; + customTo?: string; + allClients?: string; + allProjects?: string; + allTags?: string; + showFilters?: string; + hideFilters?: string; + apply?: string; + clientPrefix?: string; + projectPrefix?: string; + tagPrefix?: string; + fromPrefix?: string; + toPrefix?: string; + }; +} + +function FilterTagMultiSelect({ + tags, + selectedTagIds, + onChange, + title, +}: { + tags: Tag[]; + selectedTagIds: string[]; + onChange: (tagIds: string[]) => void; + title: string; +}) { + const [isOpen, setIsOpen] = useState(false); + const [dropdownStyle, setDropdownStyle] = useState({}); + const wrapperRef = useRef(null); + const buttonRef = useRef(null); + const dropdownRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + if (!wrapperRef.current?.contains(target) && !dropdownRef.current?.contains(target)) { + 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 = Math.max(rect.width, 260); + const spaceBelow = window.innerHeight - rect.bottom; + const openUpward = spaceBelow < 280 && rect.top > spaceBelow; + + setDropdownStyle({ + position: "fixed", + top: openUpward ? `${rect.top - 6}px` : `${rect.bottom + 6}px`, + left: `${Math.max(12, rect.right - dropdownWidth)}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 selectedTags = tags.filter((tag) => selectedTagIds.includes(tag.id)); + const label = selectedTags.length > 0 ? selectedTags.map((tag) => tag.name).join(" | ") : title; + + return ( +
+ + + {isOpen && + createPortal( +
+
+ {tags.map((tag) => { + const selected = selectedTagIds.includes(tag.id); + return ( + + ); + })} +
+
, + document.body, + )} +
+ ); +} + +function MiniFilterBlock({ + icon, + label, + children, +}: { + icon: ReactNode; + label: string; + children: ReactNode; +}) { + return ( +
+
+ {icon} + {label} +
+ {children} +
+ ); +} + +export default function TimesheetFilterBar({ + searchQuery, + filters, + onApply, + onClearFilters, + projects, + tags, + searchPlaceholder, + labels, +}: TimesheetFilterBarProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [draftSearchQuery, setDraftSearchQuery] = useState(searchQuery); + const [draftFilters, setDraftFilters] = useState(filters); + + useEffect(() => { + setDraftSearchQuery(searchQuery); + }, [searchQuery]); + + useEffect(() => { + setDraftFilters(filters); + }, [filters]); + + const clients = useMemo( + () => + Array.from( + new Map( + projects + .filter((project) => project.client) + .map((project) => [project.client!.id, { value: project.client!.id, label: project.client!.name }]), + ).values(), + ), + [projects], + ); + + const selectedClient = clients.find((client) => client.value === filters.clientId) || null; + const selectedProject = projects.find((project) => project.id === filters.projectId) || null; + const selectedTags = tags.filter((tag) => filters.tagIds.includes(tag.id)); + + const activeChips = [ + filters.startedAfter ? `${labels?.fromPrefix || labels?.customFrom || "From"}: ${filters.startedAfter}` : null, + filters.startedBefore ? `${labels?.toPrefix || labels?.customTo || "To"}: ${filters.startedBefore}` : null, + selectedClient ? `${labels?.clientPrefix || labels?.client || "Client"}: ${selectedClient.label}` : null, + selectedProject ? `${labels?.projectPrefix || labels?.project || "Project"}: ${selectedProject.name}` : null, + ...selectedTags.map((tag) => `${labels?.tagPrefix || labels?.tags || "Tag"}: ${tag.name}`), + ].filter(Boolean) as string[]; + + const hasActiveFilters = Boolean( + searchQuery.trim() || + filters.clientId || + filters.projectId || + filters.tagIds.length || + filters.startedAfter || + filters.startedBefore, + ); + + return ( +
+
+
+
+ + setDraftSearchQuery(event.target.value)} + placeholder={searchPlaceholder} + className="h-9 w-full rounded-md border border-slate-200 bg-slate-50 pl-9 pr-3 text-sm 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 rtl:pl-3 rtl:pr-9" + /> +
+ +
+ + + + + +
+
+ + {activeChips.length > 0 && ( +
+ {activeChips.map((chip) => ( + + {chip} + + ))} +
+ )} + + {isExpanded && ( +
+ } label={labels?.customFrom || "From"}> + setDraftFilters((current) => ({ ...current, startedAfter: value }))} + placeholder="YYYY/MM/DD" + inputClassName="h-8 border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100" + /> + + + } label={labels?.customTo || "To"}> + setDraftFilters((current) => ({ ...current, startedBefore: value }))} + placeholder="YYYY/MM/DD" + inputClassName="h-8 border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100" + /> + + + } label={labels?.client || "Client"}> + setDraftFilters((current) => ({ ...current, projectId }))} + options={[{ value: "", label: labels?.allProjects || "All projects" }, ...( + draftFilters.clientId + ? projects.filter((project) => project.client?.id === draftFilters.clientId) + : projects + ).map((project) => ({ value: project.id, label: project.name }))]} + className="w-full" + buttonClassName="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900" + /> + + + } label={labels?.tags || "Tags"}> + setDraftFilters((current) => ({ ...current, tagIds }))} + title={labels?.allTags || "All tags"} + /> + +
+ )} +
+
+ ); +} diff --git a/src/components/ui/JalaliDatePicker.tsx b/src/components/ui/JalaliDatePicker.tsx index 342286b..c03711c 100644 --- a/src/components/ui/JalaliDatePicker.tsx +++ b/src/components/ui/JalaliDatePicker.tsx @@ -6,14 +6,16 @@ import gregorian from "react-date-object/calendars/gregorian" import gregorian_en from "react-date-object/locales/gregorian_en" import "react-multi-date-picker/styles/backgrounds/bg-dark.css" -interface JalaliDatePickerProps { - value: string | null | undefined; - onChange: (date: string) => void; - label?: string; - disabled?: boolean; -} - -export default function JalaliDatePicker({ value, onChange, label, disabled }: JalaliDatePickerProps) { +interface JalaliDatePickerProps { + value: string | null | undefined; + onChange: (date: string) => void; + label?: string; + disabled?: boolean; + inputClassName?: string; + placeholder?: string; +} + +export default function JalaliDatePicker({ value, onChange, label, disabled, inputClassName = "", placeholder }: JalaliDatePickerProps) { const isFa = document.documentElement.dir === 'rtl' const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')) @@ -42,14 +44,17 @@ export default function JalaliDatePicker({ value, onChange, label, disabled }: J {label} )} - void; - options: SelectOption[]; - className?: string; - buttonClassName?: string; - isLoading?: boolean; - disabled?: boolean; - loadingText?: string; -} +interface SelectProps { + value: string | number; + onChange: (value: string) => void; + options: SelectOption[]; + className?: string; + buttonClassName?: string; + isLoading?: boolean; + disabled?: boolean; + loadingText?: string; + showChevron?: boolean; + portalOwnerId?: string; +} export const Select: React.FC = ({ value, @@ -24,10 +26,12 @@ export const Select: React.FC = ({ options, className = "", buttonClassName = "", - isLoading = false, - disabled = false, - loadingText = "", -}) => { + isLoading = false, + disabled = false, + loadingText = "", + showChevron = true, + portalOwnerId, +}) => { const [isOpen, setIsOpen] = useState(false); const [dropdownStyle, setDropdownStyle] = useState({}); const buttonRef = useRef(null); @@ -106,30 +110,31 @@ export const Select: React.FC = ({ className={`flex items-center justify-between bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md px-3 py-2 text-sm text-slate-700 dark:text-slate-300 outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${buttonClassName}`} > {isLoading ? loadingText : selectedOption?.label} - {isLoading ? ( - - - - - ) : ( - - - - )} - + {isLoading ? ( + + + + + ) : showChevron ? ( + + + + ) : null} + {isOpen && !isDisabled && createPortal( -
+
{options.map((option) => (
`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"}

+
+ )} +
+ + +
+ )} + + + + + + } + > +
+
+ + setFormName(event.target.value)} placeholder={t.tags?.namePlaceholder || "Design"} /> +
+ +
+ + setFormColor(event.target.value)} className="h-10 w-14 cursor-pointer rounded-md border border-slate-200 dark:border-slate-700 bg-transparent" /> +
+
+
+
+ ); +} 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" : ""} + /> +
+ +
+ + 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" + /> + +
+