feat(timesheet): add live search and searchable project selectors
This commit is contained in:
@@ -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"}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -423,13 +423,14 @@ export const en = {
|
|||||||
optionsError: "Failed to load projects and tags.",
|
optionsError: "Failed to load projects and tags.",
|
||||||
descriptionLabel: "Description",
|
descriptionLabel: "Description",
|
||||||
descriptionPlaceholder: "What are you working on?",
|
descriptionPlaceholder: "What are you working on?",
|
||||||
projectLabel: "Project",
|
projectLabel: "Project",
|
||||||
noProject: "No project",
|
noProject: "No project",
|
||||||
startLabel: "Start",
|
startLabel: "Start",
|
||||||
endLabel: "End",
|
endLabel: "End",
|
||||||
billable: "Billable",
|
timeLabel: "Time",
|
||||||
noTagsHint: "Create tags first from the Tags page.",
|
billable: "Billable",
|
||||||
clearFilters: "Clear filters",
|
noTagsHint: "Create tags first from the Tags page.",
|
||||||
|
clearFilters: "Clear filters",
|
||||||
customFromLabel: "From date",
|
customFromLabel: "From date",
|
||||||
customToLabel: "To date",
|
customToLabel: "To date",
|
||||||
allClientsLabel: "All clients",
|
allClientsLabel: "All clients",
|
||||||
@@ -439,13 +440,21 @@ export const en = {
|
|||||||
hideFiltersLabel: "Hide filters",
|
hideFiltersLabel: "Hide filters",
|
||||||
applyFiltersLabel: "Apply",
|
applyFiltersLabel: "Apply",
|
||||||
clientFilterPrefix: "Client",
|
clientFilterPrefix: "Client",
|
||||||
projectFilterPrefix: "Project",
|
projectFilterPrefix: "Project",
|
||||||
tagFilterPrefix: "Tag",
|
tagFilterPrefix: "Tag",
|
||||||
fromFilterPrefix: "From",
|
fromFilterPrefix: "From",
|
||||||
toFilterPrefix: "To",
|
toFilterPrefix: "To",
|
||||||
deletedProjectLabel: "Deleted project",
|
deleteTitle: "Delete Time Entry",
|
||||||
deletedTagLabel: "Deleted tag",
|
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: {
|
reports: {
|
||||||
title: "Reports",
|
title: "Reports",
|
||||||
|
|||||||
@@ -420,13 +420,14 @@ export const fa = {
|
|||||||
optionsError: "دریافت پروژهها و تگها با خطا مواجه شد.",
|
optionsError: "دریافت پروژهها و تگها با خطا مواجه شد.",
|
||||||
descriptionLabel: "توضیحات",
|
descriptionLabel: "توضیحات",
|
||||||
descriptionPlaceholder: "روی چه چیزی کار میکنید؟",
|
descriptionPlaceholder: "روی چه چیزی کار میکنید؟",
|
||||||
projectLabel: "پروژه",
|
projectLabel: "پروژه",
|
||||||
noProject: "بدون پروژه",
|
noProject: "بدون پروژه",
|
||||||
startLabel: "شروع",
|
startLabel: "شروع",
|
||||||
endLabel: "پایان",
|
endLabel: "پایان",
|
||||||
billable: "قابل صورتحساب",
|
timeLabel: "زمان",
|
||||||
noTagsHint: "ابتدا از صفحه تگها، تگ ایجاد کنید.",
|
billable: "قابل صورتحساب",
|
||||||
clearFilters: "پاک کردن فیلترها",
|
noTagsHint: "ابتدا از صفحه تگها، تگ ایجاد کنید.",
|
||||||
|
clearFilters: "پاک کردن فیلترها",
|
||||||
customFromLabel: "از تاریخ",
|
customFromLabel: "از تاریخ",
|
||||||
customToLabel: "تا تاریخ",
|
customToLabel: "تا تاریخ",
|
||||||
allClientsLabel: "همه مشتریها",
|
allClientsLabel: "همه مشتریها",
|
||||||
@@ -436,13 +437,21 @@ export const fa = {
|
|||||||
hideFiltersLabel: "مخفی کردن فیلترها",
|
hideFiltersLabel: "مخفی کردن فیلترها",
|
||||||
applyFiltersLabel: "اعمال",
|
applyFiltersLabel: "اعمال",
|
||||||
clientFilterPrefix: "مشتری",
|
clientFilterPrefix: "مشتری",
|
||||||
projectFilterPrefix: "پروژه",
|
projectFilterPrefix: "پروژه",
|
||||||
tagFilterPrefix: "تگ",
|
tagFilterPrefix: "تگ",
|
||||||
fromFilterPrefix: "از",
|
fromFilterPrefix: "از",
|
||||||
toFilterPrefix: "تا",
|
toFilterPrefix: "تا",
|
||||||
deletedProjectLabel: "پروژه حذفشده",
|
deleteTitle: "حذف ورودی زمان",
|
||||||
deletedTagLabel: "تگ حذفشده",
|
deleteConfirmMessage: "آیا از حذف این ورودی زمان اطمینان دارید؟",
|
||||||
},
|
restartConfirmMessage: "میخواهید یک تایمر جدید را از روی این ورودی شروع کنید؟",
|
||||||
|
discardConfirmMessage: "آیا از دور انداختن این تایمر در حال اجرا اطمینان دارید؟",
|
||||||
|
searchTagsLabel: "جستوجوی تگها...",
|
||||||
|
noTagsFoundLabel: "تگی پیدا نشد.",
|
||||||
|
searchProjectsLabel: "جستوجوی پروژهها...",
|
||||||
|
noProjectsFoundLabel: "پروژهای پیدا نشد.",
|
||||||
|
deletedProjectLabel: "پروژه حذفشده",
|
||||||
|
deletedTagLabel: "تگ حذفشده",
|
||||||
|
},
|
||||||
reports: {
|
reports: {
|
||||||
title: "گزارشها",
|
title: "گزارشها",
|
||||||
description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`,
|
description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`,
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ import {
|
|||||||
updateTimeEntry,
|
updateTimeEntry,
|
||||||
} from "../api/timeEntries";
|
} from "../api/timeEntries";
|
||||||
import { getTags, type Tag } from "../api/tags";
|
import { getTags, type Tag } from "../api/tags";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { InfiniteScroll } from "../components/InfiniteScroll";
|
import { InfiniteScroll } from "../components/InfiniteScroll";
|
||||||
import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timesheet/TimesheetFilterBar";
|
import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timesheet/TimesheetFilterBar";
|
||||||
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";
|
||||||
|
|
||||||
type EntryModalMode = "manual" | "edit" | null;
|
type EntryModalMode = "manual" | "edit" | null;
|
||||||
|
|
||||||
@@ -858,30 +858,35 @@ function TagMultiSelect({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectInlineSelect({
|
function ProjectInlineSelect({
|
||||||
projects,
|
projects,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
portalOwnerId,
|
searchPlaceholder,
|
||||||
className = "",
|
emptyLabel,
|
||||||
dropdownClassName = "",
|
portalOwnerId,
|
||||||
disabled = false,
|
className = "",
|
||||||
}: {
|
dropdownClassName = "",
|
||||||
projects: Project[];
|
disabled = false,
|
||||||
value: string;
|
}: {
|
||||||
onChange: (projectId: string) => void;
|
projects: Project[];
|
||||||
placeholder: string;
|
value: string;
|
||||||
portalOwnerId?: string;
|
onChange: (projectId: string) => void;
|
||||||
className?: string;
|
placeholder: string;
|
||||||
dropdownClassName?: string;
|
searchPlaceholder: string;
|
||||||
disabled?: boolean;
|
emptyLabel: string;
|
||||||
}) {
|
portalOwnerId?: string;
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
className?: string;
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
dropdownClassName?: string;
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
disabled?: boolean;
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
}) {
|
||||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
@@ -896,9 +901,9 @@ function ProjectInlineSelect({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || !buttonRef.current) return;
|
if (!isOpen || !buttonRef.current) return;
|
||||||
@@ -928,13 +933,27 @@ function ProjectInlineSelect({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("resize", closeOnViewportChange);
|
window.removeEventListener("resize", closeOnViewportChange);
|
||||||
window.removeEventListener("scroll", closeOnViewportChange, true);
|
window.removeEventListener("scroll", closeOnViewportChange, true);
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const selectedProject = projects.find((project) => project.id === value);
|
useEffect(() => {
|
||||||
const label = selectedProject?.name || placeholder;
|
if (!isOpen) {
|
||||||
|
setQuery("");
|
||||||
return (
|
}
|
||||||
|
}, [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 (
|
||||||
<div ref={wrapperRef} className={`relative min-w-0 ${className}`}>
|
<div ref={wrapperRef} className={`relative min-w-0 ${className}`}>
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
@@ -958,11 +977,23 @@ function ProjectInlineSelect({
|
|||||||
style={dropdownStyle}
|
style={dropdownStyle}
|
||||||
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">
|
||||||
<button
|
<div className="relative">
|
||||||
type="button"
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
<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
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange("");
|
onChange("");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
@@ -976,10 +1007,10 @@ 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 (
|
||||||
<button
|
<button
|
||||||
key={project.id}
|
key={project.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -989,22 +1020,25 @@ function ProjectInlineSelect({
|
|||||||
onChange(project.id);
|
onChange(project.id);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex w-full items-center rounded-xl px-3 py-2 text-sm transition-colors ${
|
className={`flex w-full items-center rounded-xl px-3 py-2 text-sm transition-colors ${
|
||||||
selected
|
selected
|
||||||
? "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
|
? "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
|
||||||
: unavailable
|
: unavailable
|
||||||
? "cursor-not-allowed text-slate-400 opacity-70 dark:text-slate-500"
|
? "cursor-not-allowed text-slate-400 opacity-70 dark:text-slate-500"
|
||||||
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700/70"
|
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700/70"
|
||||||
}`}
|
}`}
|
||||||
title={project.name}
|
title={project.name}
|
||||||
>
|
>
|
||||||
<span className={`truncate ${project.is_deleted ? "italic" : ""}`}>{project.name}</span>
|
<span className={`truncate ${project.is_deleted ? "italic" : ""}`}>{project.name}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
{filteredProjects.length === 0 && (
|
||||||
</div>,
|
<div className="px-3 py-3 text-xs text-slate-500 dark:text-slate-400">{emptyLabel}</div>
|
||||||
document.body,
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1229,14 +1263,16 @@ function EntryEditorFields({
|
|||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center">
|
<div className="flex min-w-0 flex-1 items-center">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||||
<ProjectInlineSelect
|
<ProjectInlineSelect
|
||||||
projects={projects}
|
projects={projects}
|
||||||
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"}
|
||||||
portalOwnerId={portalOwnerId}
|
searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
|
||||||
className="min-w-0 max-w-fit flex-1"
|
emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."}
|
||||||
/>
|
portalOwnerId={portalOwnerId}
|
||||||
|
className="min-w-0 max-w-fit flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
{selectedProject?.client?.name && (
|
{selectedProject?.client?.name && (
|
||||||
<span className="min-w-0 max-w-[120px] 2xl:max-w-fit shrink truncate text-sm text-slate-400 dark:text-slate-500" title={selectedProject.client.name}>
|
<span className="min-w-0 max-w-[120px] 2xl:max-w-fit shrink truncate text-sm text-slate-400 dark:text-slate-500" title={selectedProject.client.name}>
|
||||||
@@ -1307,21 +1343,28 @@ function EntryEditorFields({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<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,
|
||||||
className="w-full"
|
label: project.name,
|
||||||
buttonClassName={compact ? "w-full h-9 px-2 text-xs" : "w-full"}
|
searchText: project.client?.name || "",
|
||||||
/>
|
})),
|
||||||
</div>
|
]}
|
||||||
|
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"
|
||||||
|
buttonClassName={compact ? "w-full h-9 px-2 text-xs" : "w-full"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className={compact ? "" : "space-y-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60"}>
|
<div className={compact ? "" : "space-y-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60"}>
|
||||||
@@ -1893,23 +1936,29 @@ export default function Timesheet() {
|
|||||||
hideFiltersLabel?: string;
|
hideFiltersLabel?: string;
|
||||||
applyFiltersLabel?: string;
|
applyFiltersLabel?: string;
|
||||||
clientFilterPrefix?: string;
|
clientFilterPrefix?: string;
|
||||||
projectFilterPrefix?: string;
|
projectFilterPrefix?: string;
|
||||||
tagFilterPrefix?: string;
|
tagFilterPrefix?: string;
|
||||||
fromFilterPrefix?: string;
|
fromFilterPrefix?: string;
|
||||||
toFilterPrefix?: string;
|
toFilterPrefix?: string;
|
||||||
restartConfirmMessage?: string;
|
restartConfirmMessage?: string;
|
||||||
deletedProjectLabel?: string;
|
discardConfirmMessage?: string;
|
||||||
deletedTagLabel?: string;
|
deletedProjectLabel?: string;
|
||||||
}) || {};
|
deletedTagLabel?: string;
|
||||||
|
searchTagsLabel?: string;
|
||||||
|
noTagsFoundLabel?: string;
|
||||||
|
searchProjectsLabel?: string;
|
||||||
|
noProjectsFoundLabel?: string;
|
||||||
|
}) || {};
|
||||||
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
const [groupedHistory, setGroupedHistory] = useState<TimeEntryGroupWeek[]>([]);
|
const [groupedHistory, setGroupedHistory] = useState<TimeEntryGroupWeek[]>([]);
|
||||||
const [activeRunningEntry, setActiveRunningEntry] = useState<TimeEntry | null>(null);
|
const [activeRunningEntry, setActiveRunningEntry] = useState<TimeEntry | null>(null);
|
||||||
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 [filters, setFilters] = useState<TimeEntryFilters>(DEFAULT_ENTRY_FILTERS);
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
|
||||||
|
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);
|
||||||
const [limit] = useState(20);
|
const [limit] = useState(20);
|
||||||
@@ -1977,35 +2026,44 @@ export default function Timesheet() {
|
|||||||
|
|
||||||
const loadOptions = async () => {
|
const loadOptions = async () => {
|
||||||
try {
|
try {
|
||||||
const [projectsData, tagsData] = await Promise.all([
|
const [projectsData, tagsData] = await Promise.all([
|
||||||
getProjects(activeWorkspace.id, { limit: 100, offset: 0, ordering: "name" }),
|
getProjects(activeWorkspace.id, { limit: 100, offset: 0, ordering: "name" }),
|
||||||
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);
|
||||||
toast.error(t.timesheet?.optionsError || "Failed to load projects and tags");
|
toast.error(t.timesheet?.optionsError || "Failed to load projects and tags");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void loadOptions();
|
void loadOptions();
|
||||||
}, [activeWorkspace?.id, t.timesheet?.optionsError]);
|
}, [activeWorkspace?.id, t.timesheet?.optionsError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setFilters(DEFAULT_ENTRY_FILTERS);
|
setDebouncedSearchQuery("");
|
||||||
setGroupedHistory([]);
|
setFilters(DEFAULT_ENTRY_FILTERS);
|
||||||
setNextOffset(0);
|
setGroupedHistory([]);
|
||||||
setHasMoreHistory(false);
|
setNextOffset(0);
|
||||||
}, [activeWorkspace?.id]);
|
setHasMoreHistory(false);
|
||||||
|
}, [activeWorkspace?.id]);
|
||||||
useEffect(() => {
|
|
||||||
setGroupedHistory([]);
|
useEffect(() => {
|
||||||
setNextOffset(0);
|
const timeoutId = window.setTimeout(() => {
|
||||||
setHasMoreHistory(false);
|
setDebouncedSearchQuery(searchQuery);
|
||||||
}, [filters, searchQuery]);
|
}, 350);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setGroupedHistory([]);
|
||||||
|
setNextOffset(0);
|
||||||
|
setHasMoreHistory(false);
|
||||||
|
}, [debouncedSearchQuery, filters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filters.clientId || !filters.projectId) return;
|
if (!filters.clientId || !filters.projectId) return;
|
||||||
@@ -2028,11 +2086,11 @@ export default function Timesheet() {
|
|||||||
} else {
|
} else {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
}
|
}
|
||||||
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,
|
||||||
tags: filters.tagIds,
|
tags: filters.tagIds,
|
||||||
@@ -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) {
|
||||||
@@ -2074,15 +2132,15 @@ export default function Timesheet() {
|
|||||||
}
|
}
|
||||||
}, [activeWorkspace?.id]);
|
}, [activeWorkspace?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeWorkspace?.id) return;
|
if (!activeWorkspace?.id) return;
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
void loadHistory();
|
void loadHistory();
|
||||||
}, 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,15 +2431,15 @@ 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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLoadMore = useCallback(() => {
|
const handleLoadMore = useCallback(() => {
|
||||||
if (!hasMoreHistory || nextOffset === null || isLoadingMore) return;
|
if (!hasMoreHistory || nextOffset === null || isLoadingMore) return;
|
||||||
@@ -2439,20 +2497,26 @@ 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,
|
||||||
className="min-w-[190px] max-w-[220px]"
|
label: project.name,
|
||||||
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"
|
searchText: project.client?.name || "",
|
||||||
disabled={isStartingTimer}
|
})),
|
||||||
portalOwnerId={timerEditorOwnerId}
|
]}
|
||||||
/>
|
placeholder={t.timesheet?.projectLabel || "Project"}
|
||||||
</div>
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 shrink items-center">
|
<div className="flex min-w-0 shrink items-center">
|
||||||
<TagMultiSelect
|
<TagMultiSelect
|
||||||
@@ -2540,19 +2604,25 @@ export default function Timesheet() {
|
|||||||
className="h-10 border-slate-200 bg-slate-50 text-sm dark:border-slate-700 dark:bg-slate-900"
|
className="h-10 border-slate-200 bg-slate-50 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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,
|
||||||
className="w-full"
|
label: project.name,
|
||||||
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"
|
searchText: project.client?.name || "",
|
||||||
disabled={isStartingTimer}
|
})),
|
||||||
portalOwnerId={timerEditorOwnerId}
|
]}
|
||||||
/>
|
placeholder={t.timesheet?.projectLabel || "Project"}
|
||||||
|
searchPlaceholder={extendedTimesheet.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
|
||||||
|
emptyLabel={extendedTimesheet.noProjectsFoundLabel || "No projects found."}
|
||||||
|
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"
|
||||||
|
disabled={isStartingTimer}
|
||||||
|
/>
|
||||||
|
|
||||||
<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">
|
||||||
{runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"}
|
{runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"}
|
||||||
@@ -2625,13 +2695,14 @@ export default function Timesheet() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<TimesheetFilterBar
|
<TimesheetFilterBar
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onApply={handleApplyFilters}
|
onSearchChange={setSearchQuery}
|
||||||
onClearFilters={handleClearFilters}
|
onApply={handleApplyFilters}
|
||||||
projects={projects}
|
onClearFilters={handleClearFilters}
|
||||||
tags={tags}
|
projects={projects}
|
||||||
|
tags={tags}
|
||||||
searchPlaceholder={t.timesheet?.searchPlaceholder || "Search time entries..."}
|
searchPlaceholder={t.timesheet?.searchPlaceholder || "Search time entries..."}
|
||||||
labels={{
|
labels={{
|
||||||
project: t.timesheet?.projectLabel || "Project",
|
project: t.timesheet?.projectLabel || "Project",
|
||||||
@@ -2648,12 +2719,14 @@ export default function Timesheet() {
|
|||||||
apply: extendedTimesheet.applyFiltersLabel || "Apply",
|
apply: extendedTimesheet.applyFiltersLabel || "Apply",
|
||||||
clientPrefix: extendedTimesheet.clientFilterPrefix || "Client",
|
clientPrefix: extendedTimesheet.clientFilterPrefix || "Client",
|
||||||
projectPrefix: extendedTimesheet.projectFilterPrefix || "Project",
|
projectPrefix: extendedTimesheet.projectFilterPrefix || "Project",
|
||||||
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>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center p-12 text-slate-500">{t.loading || "Loading..."}</div>
|
<div className="flex justify-center p-12 text-slate-500">{t.loading || "Loading..."}</div>
|
||||||
@@ -2682,9 +2755,9 @@ export default function Timesheet() {
|
|||||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">
|
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">
|
||||||
{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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -2760,10 +2833,10 @@ export default function Timesheet() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{deleteModal.entry && (
|
{deleteModal.entry && (
|
||||||
<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={
|
||||||
<>
|
<>
|
||||||
@@ -2777,9 +2850,9 @@ 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">
|
||||||
{deleteModal.entry.description || t.timesheet?.emptyDescription || "No description"}
|
{deleteModal.entry.description || t.timesheet?.emptyDescription || "No description"}
|
||||||
@@ -2811,9 +2884,9 @@ 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">
|
||||||
{restartModal.entry.description || t.timesheet?.emptyDescription || "No description"}
|
{restartModal.entry.description || t.timesheet?.emptyDescription || "No description"}
|
||||||
@@ -2845,9 +2918,9 @@ 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">
|
||||||
{discardTimerModal.entry.description || t.timesheet?.emptyDescription || "No description"}
|
{discardTimerModal.entry.description || t.timesheet?.emptyDescription || "No description"}
|
||||||
|
|||||||
Reference in New Issue
Block a user