diff --git a/src/components/timesheet/TimesheetFilterBar.tsx b/src/components/timesheet/TimesheetFilterBar.tsx index 6bb5f39..a1b9588 100644 --- a/src/components/timesheet/TimesheetFilterBar.tsx +++ b/src/components/timesheet/TimesheetFilterBar.tsx @@ -18,7 +18,8 @@ export interface TimeEntryFilters { interface TimesheetFilterBarProps { searchQuery: string; filters: TimeEntryFilters; - onApply: (searchQuery: string, filters: TimeEntryFilters) => void; + onSearchChange: (value: string) => void; + onApply: (filters: TimeEntryFilters) => void; onClearFilters: () => void; projects: Project[]; tags: Tag[]; @@ -41,6 +42,8 @@ interface TimesheetFilterBarProps { tagPrefix?: string; fromPrefix?: string; toPrefix?: string; + searchTags?: string; + noTagsFound?: string; }; } @@ -49,11 +52,15 @@ function FilterTagMultiSelect({ selectedTagIds, onChange, title, + searchPlaceholder, + emptyLabel, }: { tags: Tag[]; selectedTagIds: string[]; onChange: (tagIds: string[]) => void; title: string; + searchPlaceholder: string; + emptyLabel: string; }) { const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -145,7 +152,7 @@ function FilterTagMultiSelect({ type="text" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} - placeholder="Search tags..." + placeholder={searchPlaceholder} className="h-8 w-full rounded-md border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-800 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800" /> @@ -181,7 +188,7 @@ function FilterTagMultiSelect({ })} {filteredTags.length === 0 && (
- No tags found. + {emptyLabel}
)} @@ -215,6 +222,7 @@ function MiniFilterBlock({ export default function TimesheetFilterBar({ searchQuery, filters, + onSearchChange, onApply, onClearFilters, projects, @@ -223,13 +231,8 @@ export default function TimesheetFilterBar({ 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]); @@ -275,8 +278,8 @@ export default function TimesheetFilterBar({ setDraftSearchQuery(event.target.value)} + value={searchQuery} + onChange={(event) => onSearchChange(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" /> @@ -304,7 +307,7 @@ export default function TimesheetFilterBar({ ))} {filteredOptions.length === 0 && ( -
No results
+
{emptyLabel}
)} , diff --git a/src/locales/en.ts b/src/locales/en.ts index ec39d76..be4c449 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -423,13 +423,14 @@ export const en = { 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", + projectLabel: "Project", + noProject: "No project", + startLabel: "Start", + endLabel: "End", + timeLabel: "Time", + billable: "Billable", + noTagsHint: "Create tags first from the Tags page.", + clearFilters: "Clear filters", customFromLabel: "From date", customToLabel: "To date", allClientsLabel: "All clients", @@ -439,13 +440,21 @@ export const en = { hideFiltersLabel: "Hide filters", applyFiltersLabel: "Apply", clientFilterPrefix: "Client", - projectFilterPrefix: "Project", - tagFilterPrefix: "Tag", - fromFilterPrefix: "From", - toFilterPrefix: "To", - deletedProjectLabel: "Deleted project", - deletedTagLabel: "Deleted tag", - }, + projectFilterPrefix: "Project", + tagFilterPrefix: "Tag", + fromFilterPrefix: "From", + toFilterPrefix: "To", + deleteTitle: "Delete Time Entry", + deleteConfirmMessage: "Are you sure you want to delete this time entry?", + restartConfirmMessage: "Start a new running timer from this entry?", + discardConfirmMessage: "Are you sure you want to discard this running timer?", + searchTagsLabel: "Search tags...", + noTagsFoundLabel: "No tags found.", + searchProjectsLabel: "Search projects...", + noProjectsFoundLabel: "No projects found.", + deletedProjectLabel: "Deleted project", + deletedTagLabel: "Deleted tag", + }, reports: { title: "Reports", diff --git a/src/locales/fa.ts b/src/locales/fa.ts index fcf162b..e1a3f57 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -420,13 +420,14 @@ export const fa = { optionsError: "دریافت پروژه‌ها و تگ‌ها با خطا مواجه شد.", descriptionLabel: "توضیحات", descriptionPlaceholder: "روی چه چیزی کار می‌کنید؟", - projectLabel: "پروژه", - noProject: "بدون پروژه", - startLabel: "شروع", - endLabel: "پایان", - billable: "قابل صورتحساب", - noTagsHint: "ابتدا از صفحه تگ‌ها، تگ ایجاد کنید.", - clearFilters: "پاک کردن فیلترها", + projectLabel: "پروژه", + noProject: "بدون پروژه", + startLabel: "شروع", + endLabel: "پایان", + timeLabel: "زمان", + billable: "قابل صورتحساب", + noTagsHint: "ابتدا از صفحه تگ‌ها، تگ ایجاد کنید.", + clearFilters: "پاک کردن فیلترها", customFromLabel: "از تاریخ", customToLabel: "تا تاریخ", allClientsLabel: "همه مشتری‌ها", @@ -436,13 +437,21 @@ export const fa = { hideFiltersLabel: "مخفی کردن فیلترها", applyFiltersLabel: "اعمال", clientFilterPrefix: "مشتری", - projectFilterPrefix: "پروژه", - tagFilterPrefix: "تگ", - fromFilterPrefix: "از", - toFilterPrefix: "تا", - deletedProjectLabel: "پروژه حذف‌شده", - deletedTagLabel: "تگ حذف‌شده", - }, + projectFilterPrefix: "پروژه", + tagFilterPrefix: "تگ", + fromFilterPrefix: "از", + toFilterPrefix: "تا", + deleteTitle: "حذف ورودی زمان", + deleteConfirmMessage: "آیا از حذف این ورودی زمان اطمینان دارید؟", + restartConfirmMessage: "می‌خواهید یک تایمر جدید را از روی این ورودی شروع کنید؟", + discardConfirmMessage: "آیا از دور انداختن این تایمر در حال اجرا اطمینان دارید؟", + searchTagsLabel: "جست‌وجوی تگ‌ها...", + noTagsFoundLabel: "تگی پیدا نشد.", + searchProjectsLabel: "جست‌وجوی پروژه‌ها...", + noProjectsFoundLabel: "پروژه‌ای پیدا نشد.", + deletedProjectLabel: "پروژه حذف‌شده", + deletedTagLabel: "تگ حذف‌شده", + }, reports: { title: "گزارش‌ها", description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`, diff --git a/src/pages/Timesheet.tsx b/src/pages/Timesheet.tsx index 015feeb..d7c3f30 100644 --- a/src/pages/Timesheet.tsx +++ b/src/pages/Timesheet.tsx @@ -16,15 +16,15 @@ import { 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"; +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 { SearchableSelect } from "../components/ui/SearchableSelect"; +import { useWorkspace } from "../context/WorkspaceContext"; +import { useTranslation } from "../hooks/useTranslation"; type EntryModalMode = "manual" | "edit" | null; @@ -858,30 +858,35 @@ function TagMultiSelect({ ); } -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({}); +function ProjectInlineSelect({ + projects, + value, + onChange, + placeholder, + searchPlaceholder, + emptyLabel, + portalOwnerId, + className = "", + dropdownClassName = "", + disabled = false, +}: { + projects: Project[]; + value: string; + onChange: (projectId: string) => void; + placeholder: string; + searchPlaceholder: string; + emptyLabel: string; + portalOwnerId?: string; + className?: string; + dropdownClassName?: string; + disabled?: boolean; +}) { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const wrapperRef = useRef(null); + const buttonRef = useRef(null); + const dropdownRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState({}); useEffect(() => { if (!isOpen) return; @@ -896,9 +901,9 @@ function ProjectInlineSelect({ } }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [isOpen]); + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen]); useEffect(() => { if (!isOpen || !buttonRef.current) return; @@ -928,13 +933,27 @@ function ProjectInlineSelect({ 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]); + + useEffect(() => { + if (!isOpen) { + setQuery(""); + } + }, [isOpen]); + + const selectedProject = projects.find((project) => project.id === value); + const label = selectedProject?.name || placeholder; + const filteredProjects = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return projects; + return projects.filter((project) => { + const clientName = project.client?.name || ""; + return `${project.name} ${clientName}`.toLowerCase().includes(needle); + }); + }, [projects, query]); + + return (
- {projects.map((project) => { - const selected = project.id === value; - const unavailable = Boolean(project.is_deleted) && !selected; - return ( + {filteredProjects.map((project) => { + const selected = project.id === value; + const unavailable = Boolean(project.is_deleted) && !selected; + return ( - ); - })} -
- , - document.body, + title={project.name} + > + {project.name} + + ); + })} + {filteredProjects.length === 0 && ( +
{emptyLabel}
+ )} + + , + document.body, )} ); @@ -1229,14 +1263,16 @@ function EntryEditorFields({
- (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))} - placeholder={t.timesheet?.projectLabel || "Project"} - portalOwnerId={portalOwnerId} - className="min-w-0 max-w-fit flex-1" - /> + (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))} + placeholder={t.timesheet?.projectLabel || "Project"} + searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."} + emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."} + portalOwnerId={portalOwnerId} + className="min-w-0 max-w-fit flex-1" + /> {selectedProject?.client?.name && ( @@ -1307,21 +1343,28 @@ function EntryEditorFields({ />
-
- - setTimerDraft((current) => ({ ...current, projectId: String(value) }))} - options={[ - { value: "", label: t.timesheet?.projectLabel || "Project" }, - ...runningTimerProjects.map((project) => ({ value: project.id, label: project.name })), - ]} - className="min-w-[190px] max-w-[220px]" - buttonClassName="h-12 w-full rounded-none border-0 bg-transparent px-3 text-sm text-sky-600 shadow-none outline-none dark:bg-transparent dark:text-sky-400 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0" - disabled={isStartingTimer} - portalOwnerId={timerEditorOwnerId} - /> -
+
+ setTimerDraft((current) => ({ ...current, projectId: String(value) }))} + options={[ + { value: "", label: t.timesheet?.projectLabel || "Project" }, + ...runningTimerProjects.map((project) => ({ + value: project.id, + label: project.name, + searchText: project.client?.name || "", + })), + ]} + placeholder={t.timesheet?.projectLabel || "Project"} + searchPlaceholder={extendedTimesheet.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."} + emptyLabel={extendedTimesheet.noProjectsFoundLabel || "No projects found."} + className="min-w-[190px] max-w-[220px]" + buttonClassName="h-12 w-full rounded-none border-0 bg-transparent px-3 text-sm text-sky-600 shadow-none outline-none dark:bg-transparent dark:text-sky-400 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0" + disabled={isStartingTimer} + /> +
-
-