feat(timesheet): add live search and searchable project selectors

This commit is contained in:
2026-04-29 01:25:05 +03:30
parent 8868b7d1cc
commit 05f2b4a4bb
5 changed files with 342 additions and 244 deletions

View File

@@ -18,7 +18,8 @@ export interface TimeEntryFilters {
interface TimesheetFilterBarProps { interface TimesheetFilterBarProps {
searchQuery: string; searchQuery: string;
filters: TimeEntryFilters; filters: TimeEntryFilters;
onApply: (searchQuery: string, filters: TimeEntryFilters) => void; onSearchChange: (value: string) => void;
onApply: (filters: TimeEntryFilters) => void;
onClearFilters: () => void; onClearFilters: () => void;
projects: Project[]; projects: Project[];
tags: Tag[]; tags: Tag[];
@@ -41,6 +42,8 @@ interface TimesheetFilterBarProps {
tagPrefix?: string; tagPrefix?: string;
fromPrefix?: string; fromPrefix?: string;
toPrefix?: string; toPrefix?: string;
searchTags?: string;
noTagsFound?: string;
}; };
} }
@@ -49,11 +52,15 @@ function FilterTagMultiSelect({
selectedTagIds, selectedTagIds,
onChange, onChange,
title, title,
searchPlaceholder,
emptyLabel,
}: { }: {
tags: Tag[]; tags: Tag[];
selectedTagIds: string[]; selectedTagIds: string[];
onChange: (tagIds: string[]) => void; onChange: (tagIds: string[]) => void;
title: string; title: string;
searchPlaceholder: string;
emptyLabel: string;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -145,7 +152,7 @@ function FilterTagMultiSelect({
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)} 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" 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"
/> />
</div> </div>
@@ -181,7 +188,7 @@ function FilterTagMultiSelect({
})} })}
{filteredTags.length === 0 && ( {filteredTags.length === 0 && (
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400"> <div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
No tags found. {emptyLabel}
</div> </div>
)} )}
</div> </div>
@@ -215,6 +222,7 @@ function MiniFilterBlock({
export default function TimesheetFilterBar({ export default function TimesheetFilterBar({
searchQuery, searchQuery,
filters, filters,
onSearchChange,
onApply, onApply,
onClearFilters, onClearFilters,
projects, projects,
@@ -223,13 +231,8 @@ export default function TimesheetFilterBar({
labels, labels,
}: TimesheetFilterBarProps) { }: TimesheetFilterBarProps) {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [draftSearchQuery, setDraftSearchQuery] = useState(searchQuery);
const [draftFilters, setDraftFilters] = useState<TimeEntryFilters>(filters); const [draftFilters, setDraftFilters] = useState<TimeEntryFilters>(filters);
useEffect(() => {
setDraftSearchQuery(searchQuery);
}, [searchQuery]);
useEffect(() => { useEffect(() => {
setDraftFilters(filters); setDraftFilters(filters);
}, [filters]); }, [filters]);
@@ -275,8 +278,8 @@ export default function TimesheetFilterBar({
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 rtl:left-auto rtl:right-3" /> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 rtl:left-auto rtl:right-3" />
<input <input
type="text" type="text"
value={draftSearchQuery} value={searchQuery}
onChange={(event) => setDraftSearchQuery(event.target.value)} onChange={(event) => onSearchChange(event.target.value)}
placeholder={searchPlaceholder} 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" 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({
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setDraftSearchQuery(""); onSearchChange("");
setDraftFilters({ setDraftFilters({
projectId: "", projectId: "",
clientId: "", clientId: "",
@@ -389,6 +392,8 @@ export default function TimesheetFilterBar({
selectedTagIds={draftFilters.tagIds} selectedTagIds={draftFilters.tagIds}
onChange={(tagIds) => setDraftFilters((current) => ({ ...current, tagIds }))} onChange={(tagIds) => setDraftFilters((current) => ({ ...current, tagIds }))}
title={labels?.allTags || "All tags"} title={labels?.allTags || "All tags"}
searchPlaceholder={labels?.searchTags || "Search tags..."}
emptyLabel={labels?.noTagsFound || "No tags found."}
/> />
</MiniFilterBlock> </MiniFilterBlock>
</div> </div>
@@ -396,7 +401,7 @@ export default function TimesheetFilterBar({
<div className="mt-2 flex justify-end"> <div className="mt-2 flex justify-end">
<button <button
type="button" type="button"
onClick={() => onApply(draftSearchQuery, draftFilters)} onClick={() => onApply(draftFilters)}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md border bg-sky-50 border-sky-200 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-300 px-3 text-sm font-medium transition hover:border-sky-700 hover:bg-sky-700 hover:text-sky-100 dark:hover:border-sky-400 dark:hover:text-sky-900 dark:hover:bg-sky-400" className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md border bg-sky-50 border-sky-200 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-300 px-3 text-sm font-medium transition hover:border-sky-700 hover:bg-sky-700 hover:text-sky-100 dark:hover:border-sky-400 dark:hover:text-sky-900 dark:hover:bg-sky-400"
> >
{labels?.apply || "Apply"} {labels?.apply || "Apply"}

View File

@@ -16,6 +16,7 @@ interface SearchableSelectProps {
options: SearchableSelectOption[]; options: SearchableSelectOption[];
placeholder?: string; placeholder?: string;
searchPlaceholder?: string; searchPlaceholder?: string;
emptyLabel?: string;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
buttonClassName?: string; buttonClassName?: string;
@@ -27,6 +28,7 @@ export function SearchableSelect({
options, options,
placeholder = "", placeholder = "",
searchPlaceholder = "Search...", searchPlaceholder = "Search...",
emptyLabel = "No results",
disabled = false, disabled = false,
className = "", className = "",
buttonClassName = "", buttonClassName = "",
@@ -136,7 +138,7 @@ export function SearchableSelect({
</button> </button>
))} ))}
{filteredOptions.length === 0 && ( {filteredOptions.length === 0 && (
<div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">No results</div> <div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">{emptyLabel}</div>
)} )}
</div> </div>
</div>, </div>,

View File

@@ -427,6 +427,7 @@ export const en = {
noProject: "No project", noProject: "No project",
startLabel: "Start", startLabel: "Start",
endLabel: "End", endLabel: "End",
timeLabel: "Time",
billable: "Billable", billable: "Billable",
noTagsHint: "Create tags first from the Tags page.", noTagsHint: "Create tags first from the Tags page.",
clearFilters: "Clear filters", clearFilters: "Clear filters",
@@ -443,6 +444,14 @@ export const en = {
tagFilterPrefix: "Tag", tagFilterPrefix: "Tag",
fromFilterPrefix: "From", fromFilterPrefix: "From",
toFilterPrefix: "To", 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", deletedProjectLabel: "Deleted project",
deletedTagLabel: "Deleted tag", deletedTagLabel: "Deleted tag",
}, },

View File

@@ -424,6 +424,7 @@ export const fa = {
noProject: "بدون پروژه", noProject: "بدون پروژه",
startLabel: "شروع", startLabel: "شروع",
endLabel: "پایان", endLabel: "پایان",
timeLabel: "زمان",
billable: "قابل صورتحساب", billable: "قابل صورتحساب",
noTagsHint: "ابتدا از صفحه تگ‌ها، تگ ایجاد کنید.", noTagsHint: "ابتدا از صفحه تگ‌ها، تگ ایجاد کنید.",
clearFilters: "پاک کردن فیلترها", clearFilters: "پاک کردن فیلترها",
@@ -440,6 +441,14 @@ export const fa = {
tagFilterPrefix: "تگ", tagFilterPrefix: "تگ",
fromFilterPrefix: "از", fromFilterPrefix: "از",
toFilterPrefix: "تا", toFilterPrefix: "تا",
deleteTitle: "حذف ورودی زمان",
deleteConfirmMessage: "آیا از حذف این ورودی زمان اطمینان دارید؟",
restartConfirmMessage: "می‌خواهید یک تایمر جدید را از روی این ورودی شروع کنید؟",
discardConfirmMessage: "آیا از دور انداختن این تایمر در حال اجرا اطمینان دارید؟",
searchTagsLabel: "جست‌وجوی تگ‌ها...",
noTagsFoundLabel: "تگی پیدا نشد.",
searchProjectsLabel: "جست‌وجوی پروژه‌ها...",
noProjectsFoundLabel: "پروژه‌ای پیدا نشد.",
deletedProjectLabel: "پروژه حذف‌شده", deletedProjectLabel: "پروژه حذف‌شده",
deletedTagLabel: "تگ حذف‌شده", deletedTagLabel: "تگ حذف‌شده",
}, },

View File

@@ -22,7 +22,7 @@ import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timeshe
import JalaliDatePicker from "../components/ui/JalaliDatePicker"; import JalaliDatePicker from "../components/ui/JalaliDatePicker";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Select } from "../components/ui/Select"; import { SearchableSelect } from "../components/ui/SearchableSelect";
import { useWorkspace } from "../context/WorkspaceContext"; import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
@@ -863,6 +863,8 @@ function ProjectInlineSelect({
value, value,
onChange, onChange,
placeholder, placeholder,
searchPlaceholder,
emptyLabel,
portalOwnerId, portalOwnerId,
className = "", className = "",
dropdownClassName = "", dropdownClassName = "",
@@ -872,12 +874,15 @@ function ProjectInlineSelect({
value: string; value: string;
onChange: (projectId: string) => void; onChange: (projectId: string) => void;
placeholder: string; placeholder: string;
searchPlaceholder: string;
emptyLabel: string;
portalOwnerId?: string; portalOwnerId?: string;
className?: string; className?: string;
dropdownClassName?: string; dropdownClassName?: string;
disabled?: boolean; disabled?: boolean;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@@ -931,8 +936,22 @@ function ProjectInlineSelect({
}; };
}, [isOpen]); }, [isOpen]);
useEffect(() => {
if (!isOpen) {
setQuery("");
}
}, [isOpen]);
const selectedProject = projects.find((project) => project.id === value); const selectedProject = projects.find((project) => project.id === value);
const label = selectedProject?.name || placeholder; 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 ( return (
<div ref={wrapperRef} className={`relative min-w-0 ${className}`}> <div ref={wrapperRef} className={`relative min-w-0 ${className}`}>
@@ -959,7 +978,19 @@ function ProjectInlineSelect({
data-entry-editor-owner={portalOwnerId} data-entry-editor-owner={portalOwnerId}
className={`rounded-2xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-800 ${dropdownClassName}`} className={`rounded-2xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-800 ${dropdownClassName}`}
> >
<div className="max-h-64 space-y-1 overflow-y-auto"> <div className="border-b border-slate-200 p-2 dark:border-slate-700">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder}
className="h-8 w-full rounded-md border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-800 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800"
/>
</div>
</div>
<div className="max-h-64 space-y-1 overflow-y-auto p-2">
<button <button
type="button" type="button"
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
@@ -976,7 +1007,7 @@ function ProjectInlineSelect({
{placeholder} {placeholder}
</button> </button>
{projects.map((project) => { {filteredProjects.map((project) => {
const selected = project.id === value; const selected = project.id === value;
const unavailable = Boolean(project.is_deleted) && !selected; const unavailable = Boolean(project.is_deleted) && !selected;
return ( return (
@@ -1002,6 +1033,9 @@ function ProjectInlineSelect({
</button> </button>
); );
})} })}
{filteredProjects.length === 0 && (
<div className="px-3 py-3 text-xs text-slate-500 dark:text-slate-400">{emptyLabel}</div>
)}
</div> </div>
</div>, </div>,
document.body, document.body,
@@ -1234,6 +1268,8 @@ function EntryEditorFields({
value={state.projectId} value={state.projectId}
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))} onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))}
placeholder={t.timesheet?.projectLabel || "Project"} placeholder={t.timesheet?.projectLabel || "Project"}
searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."}
portalOwnerId={portalOwnerId} portalOwnerId={portalOwnerId}
className="min-w-0 max-w-fit flex-1" className="min-w-0 max-w-fit flex-1"
/> />
@@ -1311,13 +1347,20 @@ function EntryEditorFields({
<label className={`block font-medium text-slate-700 dark:text-slate-300 ${compact ? "mb-0.5 text-[11px]" : "mb-1 text-sm"}`}> <label className={`block font-medium text-slate-700 dark:text-slate-300 ${compact ? "mb-0.5 text-[11px]" : "mb-1 text-sm"}`}>
{t.timesheet?.projectLabel || "Project"} {t.timesheet?.projectLabel || "Project"}
</label> </label>
<Select <SearchableSelect
value={state.projectId} value={state.projectId}
onChange={(value) => onChange({ projectId: String(value) })} onChange={(value) => onChange({ projectId: String(value) })}
options={[ options={[
{ value: "", label: t.timesheet?.noProject || "No project" }, { value: "", label: t.timesheet?.noProject || "No project" },
...projects.map((project) => ({ value: project.id, label: project.name })), ...projects.map((project) => ({
value: project.id,
label: project.name,
searchText: project.client?.name || "",
})),
]} ]}
placeholder={t.timesheet?.noProject || "No project"}
searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."}
className="w-full" className="w-full"
buttonClassName={compact ? "w-full h-9 px-2 text-xs" : "w-full"} buttonClassName={compact ? "w-full h-9 px-2 text-xs" : "w-full"}
/> />
@@ -1898,8 +1941,13 @@ export default function Timesheet() {
fromFilterPrefix?: string; fromFilterPrefix?: string;
toFilterPrefix?: string; toFilterPrefix?: string;
restartConfirmMessage?: string; restartConfirmMessage?: string;
discardConfirmMessage?: string;
deletedProjectLabel?: string; deletedProjectLabel?: string;
deletedTagLabel?: string; deletedTagLabel?: string;
searchTagsLabel?: string;
noTagsFoundLabel?: string;
searchProjectsLabel?: string;
noProjectsFoundLabel?: string;
}) || {}; }) || {};
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
@@ -1909,6 +1957,7 @@ export default function Timesheet() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [filters, setFilters] = useState<TimeEntryFilters>(DEFAULT_ENTRY_FILTERS); const [filters, setFilters] = useState<TimeEntryFilters>(DEFAULT_ENTRY_FILTERS);
const [hasMoreHistory, setHasMoreHistory] = useState(false); const [hasMoreHistory, setHasMoreHistory] = useState(false);
const [nextOffset, setNextOffset] = useState<number | null>(0); const [nextOffset, setNextOffset] = useState<number | null>(0);
@@ -1982,7 +2031,7 @@ export default function Timesheet() {
getTags(activeWorkspace.id, { limit: 100, offset: 0, ordering: "name" }), getTags(activeWorkspace.id, { limit: 100, offset: 0, ordering: "name" }),
]); ]);
setProjects(projectsData.results || []); setProjects((projectsData.results || []).filter((project: Project) => !project.is_archived));
setTags(tagsData.results || []); setTags(tagsData.results || []);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -1995,17 +2044,26 @@ export default function Timesheet() {
useEffect(() => { useEffect(() => {
setSearchQuery(""); setSearchQuery("");
setDebouncedSearchQuery("");
setFilters(DEFAULT_ENTRY_FILTERS); setFilters(DEFAULT_ENTRY_FILTERS);
setGroupedHistory([]); setGroupedHistory([]);
setNextOffset(0); setNextOffset(0);
setHasMoreHistory(false); setHasMoreHistory(false);
}, [activeWorkspace?.id]); }, [activeWorkspace?.id]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 350);
return () => window.clearTimeout(timeoutId);
}, [searchQuery]);
useEffect(() => { useEffect(() => {
setGroupedHistory([]); setGroupedHistory([]);
setNextOffset(0); setNextOffset(0);
setHasMoreHistory(false); setHasMoreHistory(false);
}, [filters, searchQuery]); }, [debouncedSearchQuery, filters]);
useEffect(() => { useEffect(() => {
if (!filters.clientId || !filters.projectId) return; if (!filters.clientId || !filters.projectId) return;
@@ -2031,7 +2089,7 @@ export default function Timesheet() {
const params: TimeEntryListParams = { const params: TimeEntryListParams = {
limit, limit,
offset, offset,
search: searchQuery, search: debouncedSearchQuery,
status: "ended", status: "ended",
project: filters.projectId || undefined, project: filters.projectId || undefined,
client: filters.clientId || undefined, client: filters.clientId || undefined,
@@ -2053,7 +2111,7 @@ export default function Timesheet() {
setIsLoading(false); setIsLoading(false);
} }
} }
}, [activeWorkspace?.id, filters, limit, searchQuery, t.timesheet?.fetchError]); }, [activeWorkspace?.id, debouncedSearchQuery, filters, limit, t.timesheet?.fetchError]);
const loadRunningEntry = useCallback(async () => { const loadRunningEntry = useCallback(async () => {
if (!activeWorkspace?.id) { if (!activeWorkspace?.id) {
@@ -2082,7 +2140,7 @@ export default function Timesheet() {
}, 250); }, 250);
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, [activeWorkspace?.id, limit, loadHistory, filters, searchQuery]); }, [activeWorkspace?.id, debouncedSearchQuery, limit, loadHistory, filters]);
useEffect(() => { useEffect(() => {
void loadRunningEntry(); void loadRunningEntry();
@@ -2373,13 +2431,13 @@ export default function Timesheet() {
} }
}, []); }, []);
const handleApplyFilters = useCallback((nextSearchQuery: string, nextFilters: TimeEntryFilters) => { const handleApplyFilters = useCallback((nextFilters: TimeEntryFilters) => {
setSearchQuery(nextSearchQuery);
setFilters(nextFilters); setFilters(nextFilters);
}, []); }, []);
const handleClearFilters = useCallback(() => { const handleClearFilters = useCallback(() => {
setSearchQuery(""); setSearchQuery("");
setDebouncedSearchQuery("");
setFilters(DEFAULT_ENTRY_FILTERS); setFilters(DEFAULT_ENTRY_FILTERS);
}, []); }, []);
@@ -2440,17 +2498,23 @@ export default function Timesheet() {
</div> </div>
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
<Select <SearchableSelect
value={timerDraft.projectId} value={timerDraft.projectId}
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))} onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
options={[ options={[
{ value: "", label: t.timesheet?.projectLabel || "Project" }, { value: "", label: t.timesheet?.projectLabel || "Project" },
...runningTimerProjects.map((project) => ({ value: project.id, label: project.name })), ...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]" 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" 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} disabled={isStartingTimer}
portalOwnerId={timerEditorOwnerId}
/> />
</div> </div>
@@ -2541,17 +2605,23 @@ export default function Timesheet() {
/> />
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2"> <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<Select <SearchableSelect
value={timerDraft.projectId} value={timerDraft.projectId}
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))} onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
options={[ options={[
{ value: "", label: t.timesheet?.projectLabel || "Project" }, { value: "", label: t.timesheet?.projectLabel || "Project" },
...runningTimerProjects.map((project) => ({ value: project.id, label: project.name })), ...runningTimerProjects.map((project) => ({
value: project.id,
label: project.name,
searchText: project.client?.name || "",
})),
]} ]}
placeholder={t.timesheet?.projectLabel || "Project"}
searchPlaceholder={extendedTimesheet.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
emptyLabel={extendedTimesheet.noProjectsFoundLabel || "No projects found."}
className="w-full" className="w-full"
buttonClassName="h-10 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0" buttonClassName="h-10 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
disabled={isStartingTimer} disabled={isStartingTimer}
portalOwnerId={timerEditorOwnerId}
/> />
<div className="flex h-10 min-w-[110px] items-center justify-center rounded-md border border-slate-200 bg-slate-50 px-3 text-sm font-semibold text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"> <div className="flex h-10 min-w-[110px] items-center justify-center rounded-md border border-slate-200 bg-slate-50 px-3 text-sm font-semibold text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white">
@@ -2628,6 +2698,7 @@ export default function Timesheet() {
<TimesheetFilterBar <TimesheetFilterBar
searchQuery={searchQuery} searchQuery={searchQuery}
filters={filters} filters={filters}
onSearchChange={setSearchQuery}
onApply={handleApplyFilters} onApply={handleApplyFilters}
onClearFilters={handleClearFilters} onClearFilters={handleClearFilters}
projects={projects} projects={projects}
@@ -2651,6 +2722,8 @@ export default function Timesheet() {
tagPrefix: extendedTimesheet.tagFilterPrefix || "Tag", tagPrefix: extendedTimesheet.tagFilterPrefix || "Tag",
fromPrefix: extendedTimesheet.fromFilterPrefix || "From", fromPrefix: extendedTimesheet.fromFilterPrefix || "From",
toPrefix: extendedTimesheet.toFilterPrefix || "To", toPrefix: extendedTimesheet.toFilterPrefix || "To",
searchTags: extendedTimesheet.searchTagsLabel || t.tags?.searchPlaceholder || "Search tags...",
noTagsFound: extendedTimesheet.noTagsFoundLabel || "No tags found.",
}} }}
/> />
</div> </div>
@@ -2683,7 +2756,7 @@ export default function Timesheet() {
{formatDayLabel(new Date(`${day.date}T00:00:00`), lang)} {formatDayLabel(new Date(`${day.date}T00:00:00`), lang)}
</p> </p>
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
Total: <span className="font-semibold text-slate-700 dark:text-slate-200">{formatDurationMs(day.total_ms)}</span> {t.reports?.total || "Total"}: <span className="font-semibold text-slate-700 dark:text-slate-200">{formatDurationMs(day.total_ms)}</span>
</p> </p>
</div> </div>
@@ -2763,7 +2836,7 @@ export default function Timesheet() {
<Modal <Modal
isOpen={deleteModal.isOpen} isOpen={deleteModal.isOpen}
onClose={closeDeleteModal} onClose={closeDeleteModal}
title={extendedTimesheet.deleteTitle || "Delete Time Entry"} title={extendedTimesheet.deleteTitle || t.timesheet?.deleteTitle || "Delete Time Entry"}
maxWidth="max-w-md" maxWidth="max-w-md"
footer={ footer={
<> <>
@@ -2778,7 +2851,7 @@ export default function Timesheet() {
> >
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400"> <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{extendedTimesheet.deleteConfirmMessage || "Are you sure you want to delete this time entry?"} {extendedTimesheet.deleteConfirmMessage || t.timesheet?.deleteConfirmMessage || "Are you sure you want to delete this time entry?"}
</p> </p>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60"> <div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60">
<p className="font-medium text-slate-900 dark:text-white"> <p className="font-medium text-slate-900 dark:text-white">
@@ -2812,7 +2885,7 @@ export default function Timesheet() {
> >
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400"> <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{(extendedTimesheet.restartConfirmMessage || "Start a new running timer from this entry?")} {extendedTimesheet.restartConfirmMessage || t.timesheet?.restartConfirmMessage || "Start a new running timer from this entry?"}
</p> </p>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60"> <div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60">
<p className="font-medium text-slate-900 dark:text-white"> <p className="font-medium text-slate-900 dark:text-white">
@@ -2846,7 +2919,7 @@ export default function Timesheet() {
> >
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400"> <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{extendedTimesheet.deleteConfirmMessage || "Are you sure you want to delete this time entry?"} {extendedTimesheet.discardConfirmMessage || t.timesheet?.discardConfirmMessage || "Are you sure you want to discard this running timer?"}
</p> </p>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60"> <div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60">
<p className="font-medium text-slate-900 dark:text-white"> <p className="font-medium text-slate-900 dark:text-white">