fix(timesheet): refine responsive filter bar and timer actions

This commit is contained in:
2026-04-24 23:04:27 +03:30
parent 71103b9d8e
commit 441cc0c008
2 changed files with 484 additions and 491 deletions

View File

@@ -242,7 +242,7 @@ export default function TimesheetFilterBar({
return ( return (
<div className="rounded-lg border border-slate-200 bg-white p-2.5 shadow-sm dark:border-slate-800 dark:bg-slate-900"> <div className="rounded-lg border border-slate-200 bg-white p-2.5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1"> <div className="relative min-w-0 flex-1">
<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
@@ -254,24 +254,25 @@ export default function TimesheetFilterBar({
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<button <button
type="button" type="button"
onClick={() => setIsExpanded((current) => !current)} onClick={() => setIsExpanded((current) => !current)}
className={`inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm transition-colors ${ aria-label={isExpanded ? (labels?.hideFilters || "Hide filters") : (labels?.showFilters || "Filters")}
className={`relative inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition-colors sm:w-auto sm:gap-2 sm:px-3 ${
isExpanded || hasActiveFilters isExpanded || hasActiveFilters
? "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-300" ? "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-300"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:text-white" : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:text-white"
}`} }`}
> >
<SlidersHorizontal className="h-4 w-4" /> <SlidersHorizontal className="h-4 w-4" />
<span>{isExpanded ? (labels?.hideFilters || "Hide filters") : (labels?.showFilters || "Filters")}</span> <span className="hidden sm:inline">{isExpanded ? (labels?.hideFilters || "Hide filters") : (labels?.showFilters || "Filters")}</span>
{hasActiveFilters && ( {hasActiveFilters && (
<span className="inline-flex min-w-5 items-center justify-center rounded-full bg-sky-600 px-1.5 text-[11px] font-semibold text-white dark:bg-sky-500"> <span className="absolute -right-1 -top-1 z-10 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-600 px-1 text-[10px] font-semibold leading-none text-white dark:bg-sky-500 sm:static sm:z-auto sm:h-auto sm:min-w-5 sm:px-1.5 sm:text-[11px] sm:leading-normal">
{activeChips.length} {activeChips.length}
</span> </span>
)} )}
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} /> <ChevronDown className={`hidden h-4 w-4 transition-transform sm:inline ${isExpanded ? "rotate-180" : ""}`} />
</button> </button>
<button <button
@@ -288,97 +289,94 @@ export default function TimesheetFilterBar({
onClearFilters(); onClearFilters();
}} }}
disabled={!hasActiveFilters} disabled={!hasActiveFilters}
className="inline-flex h-9 items-center gap-2 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-600 transition hover:border-slate-300 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white" aria-label={labels?.clear || "Clear"}
className={`inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition sm:w-auto sm:gap-2 sm:px-3 ${
hasActiveFilters
? "border-red-200 bg-red-50 text-red-700 hover:border-red-300 hover:bg-red-100 hover:text-red-800 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300 dark:hover:border-red-400 dark:hover:bg-red-500/20 dark:hover:text-red-200"
: "border-slate-200 bg-white text-slate-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
}`}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
{labels?.clear || "Clear"} <span className="hidden sm:inline">{labels?.clear || "Clear"}</span>
</button> </button>
<button
type="button"
onClick={() => onApply(draftSearchQuery, draftFilters)}
className="inline-flex h-9 items-center gap-2 rounded-md border border-sky-600 bg-sky-600 px-3 text-sm font-medium text-white transition hover:border-sky-700 hover:bg-sky-700 dark:border-sky-500 dark:bg-sky-500 dark:hover:border-sky-400 dark:hover:bg-sky-400"
>
{labels?.apply || "Apply"}
</button>
</div> </div>
</div> </div>
{activeChips.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{activeChips.map((chip) => (
<span
key={chip}
className="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-200"
>
{chip}
</span>
))}
</div>
)}
{isExpanded && ( {isExpanded && (
<div className="grid gap-2 border-t border-slate-200 pt-2 dark:border-slate-800 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)]"> <div className="border-t border-slate-200 pt-2 dark:border-slate-800">
<MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customFrom || "From"}> <div className="grid gap-2 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)]">
<JalaliDatePicker <MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customFrom || "From"}>
value={draftFilters.startedAfter} <JalaliDatePicker
onChange={(value) => setDraftFilters((current) => ({ ...current, startedAfter: value }))} value={draftFilters.startedAfter}
placeholder="YYYY/MM/DD" onChange={(value) => setDraftFilters((current) => ({ ...current, startedAfter: value }))}
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" 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"
</MiniFilterBlock> />
</MiniFilterBlock>
<MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customTo || "To"}> <MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customTo || "To"}>
<JalaliDatePicker <JalaliDatePicker
value={draftFilters.startedBefore} value={draftFilters.startedBefore}
onChange={(value) => setDraftFilters((current) => ({ ...current, startedBefore: value }))} onChange={(value) => setDraftFilters((current) => ({ ...current, startedBefore: value }))}
placeholder="YYYY/MM/DD" 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" 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"
/> />
</MiniFilterBlock> </MiniFilterBlock>
<MiniFilterBlock icon={<BriefcaseBusiness className="h-3.5 w-3.5" />} label={labels?.client || "Client"}> <MiniFilterBlock icon={<BriefcaseBusiness className="h-3.5 w-3.5" />} label={labels?.client || "Client"}>
<Select <Select
value={draftFilters.clientId} value={draftFilters.clientId}
onChange={(clientId) => onChange={(clientId) =>
setDraftFilters((current) => ({ setDraftFilters((current) => ({
...current, ...current,
clientId, clientId,
projectId: projectId:
current.projectId && current.projectId &&
!projects.some((project) => project.id === current.projectId && project.client?.id === clientId) !projects.some((project) => project.id === current.projectId && project.client?.id === clientId)
? "" ? ""
: current.projectId, : current.projectId,
})) }))
} }
options={[{ value: "", label: labels?.allClients || "All clients" }, ...clients]} options={[{ value: "", label: labels?.allClients || "All clients" }, ...clients]}
className="w-full" 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" 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"
/> />
</MiniFilterBlock> </MiniFilterBlock>
<MiniFilterBlock icon={<FolderKanban className="h-3.5 w-3.5" />} label={labels?.project || "Project"}> <MiniFilterBlock icon={<FolderKanban className="h-3.5 w-3.5" />} label={labels?.project || "Project"}>
<Select <Select
value={draftFilters.projectId} value={draftFilters.projectId}
onChange={(projectId) => setDraftFilters((current) => ({ ...current, projectId }))} onChange={(projectId) => setDraftFilters((current) => ({ ...current, projectId }))}
options={[{ value: "", label: labels?.allProjects || "All projects" }, ...( options={[{ value: "", label: labels?.allProjects || "All projects" }, ...(
draftFilters.clientId draftFilters.clientId
? projects.filter((project) => project.client?.id === draftFilters.clientId) ? projects.filter((project) => project.client?.id === draftFilters.clientId)
: projects : projects
).map((project) => ({ value: project.id, label: project.name }))]} ).map((project) => ({ value: project.id, label: project.name }))]}
className="w-full" 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" 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"
/> />
</MiniFilterBlock> </MiniFilterBlock>
<MiniFilterBlock icon={<TagIcon className="h-3.5 w-3.5" />} label={labels?.tags || "Tags"}> <MiniFilterBlock icon={<TagIcon className="h-3.5 w-3.5" />} label={labels?.tags || "Tags"}>
<FilterTagMultiSelect <FilterTagMultiSelect
tags={tags} tags={tags}
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"}
/> />
</MiniFilterBlock> </MiniFilterBlock>
</div>
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={() => onApply(draftSearchQuery, 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"
>
{labels?.apply || "Apply"}
</button>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -1095,8 +1095,8 @@ function EntryEditorFields({
); );
} }
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<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?.descriptionLabel || "Description"} {t.timesheet?.descriptionLabel || "Description"}
@@ -1125,37 +1125,37 @@ function EntryEditorFields({
/> />
</div> </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"}>
<JalaliDatePicker <JalaliDatePicker
label={t.timesheet?.startLabel || "Start"} label={t.timesheet?.startLabel || "Start"}
value={state.startDate} value={state.startDate}
onChange={(date) => onChange({ startDate: date })} onChange={(date) => onChange({ startDate: date })}
inputClassName={compact ? "h-9 px-2 text-xs" : undefined} inputClassName={compact ? "h-9 px-2 text-xs" : undefined}
/> />
<TimeField <TimeField
label={t.timesheet?.timeLabel || "Time"} label={t.timesheet?.timeLabel || "Time"}
value={state.startTime} value={state.startTime}
onChange={(value) => onChange({ startTime: value })} onChange={(value) => onChange({ startTime: value })}
compact={compact} compact={compact}
/> />
</div> </div>
<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"}>
<JalaliDatePicker <JalaliDatePicker
label={t.timesheet?.endLabel || "End"} label={t.timesheet?.endLabel || "End"}
value={state.endDate} value={state.endDate}
onChange={(date) => onChange({ endDate: date })} onChange={(date) => onChange({ endDate: date })}
inputClassName={compact ? "h-9 px-2 text-xs" : undefined} inputClassName={compact ? "h-9 px-2 text-xs" : undefined}
/> />
<TimeField <TimeField
label={t.timesheet?.timeLabel || "Time"} label={t.timesheet?.timeLabel || "Time"}
value={state.endTime} value={state.endTime}
onChange={(value) => onChange({ endTime: value })} onChange={(value) => onChange({ endTime: value })}
compact={compact} compact={compact}
/> />
</div> </div>
</div> </div>
<div> <div>
<BillableIconButton <BillableIconButton
@@ -1177,21 +1177,21 @@ function EntryEditorFields({
); );
} }
function RecordedEntryCard({ function RecordedEntryCard({
entry, entry,
t, t,
projects, projects,
tags, tags,
onDelete, onDelete,
onRestart, onRestart,
onEntryUpdated, onEntryUpdated,
}: { }: {
entry: TimeEntry; entry: TimeEntry;
t: any; t: any;
projects: Project[]; projects: Project[];
tags: Tag[]; tags: Tag[];
onDelete: (entry: TimeEntry) => void; onDelete: (entry: TimeEntry) => void;
onRestart: (entry: TimeEntry) => void; onRestart: (entry: TimeEntry) => void;
onEntryUpdated: (entry: TimeEntry) => void; onEntryUpdated: (entry: TimeEntry) => void;
}) { }) {
const [draft, setDraft] = useState<EntryFormState>(() => buildEntryFormState(entry)); const [draft, setDraft] = useState<EntryFormState>(() => buildEntryFormState(entry));
@@ -1382,65 +1382,65 @@ function MobileRecordedEntryCard({
onEdit: (entry: TimeEntry) => void; onEdit: (entry: TimeEntry) => void;
onDelete: (entry: TimeEntry) => void; onDelete: (entry: TimeEntry) => void;
onRequestRestart: (entry: TimeEntry) => void; onRequestRestart: (entry: TimeEntry) => void;
}) { }) {
const project = projects.find((item) => item.id === entry.project); const project = projects.find((item) => item.id === entry.project);
const entryTags = tags.filter((tag) => entry.tags.includes(tag.id)); const entryTags = tags.filter((tag) => entry.tags.includes(tag.id));
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);
const touchStartXRef = useRef<number | null>(null); const touchStartXRef = useRef<number | null>(null);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [swipeOffset, setSwipeOffset] = useState(0); const [swipeOffset, setSwipeOffset] = useState(0);
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({}); const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({});
useEffect(() => { useEffect(() => {
if (!menuOpen) return; if (!menuOpen) return;
const handlePointerDown = (event: MouseEvent) => { const handlePointerDown = (event: MouseEvent) => {
if ( if (
wrapperRef.current?.contains(event.target as Node) || wrapperRef.current?.contains(event.target as Node) ||
dropdownRef.current?.contains(event.target as Node) dropdownRef.current?.contains(event.target as Node)
) { ) {
return; return;
} }
setMenuOpen(false); setMenuOpen(false);
}; };
document.addEventListener("mousedown", handlePointerDown); document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown); return () => document.removeEventListener("mousedown", handlePointerDown);
}, [menuOpen]); }, [menuOpen]);
useEffect(() => { useEffect(() => {
if (!menuOpen || !buttonRef.current) return; if (!menuOpen || !buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect(); const rect = buttonRef.current.getBoundingClientRect();
const dropdownWidth = 168; const dropdownWidth = 168;
const spaceBelow = window.innerHeight - rect.bottom; const spaceBelow = window.innerHeight - rect.bottom;
const openUpward = spaceBelow < 180 && rect.top > spaceBelow; const openUpward = spaceBelow < 180 && rect.top > spaceBelow;
setMenuStyle({ setMenuStyle({
position: "fixed", position: "fixed",
top: openUpward ? `${rect.top - 8}px` : `${rect.bottom + 8}px`, top: openUpward ? `${rect.top - 8}px` : `${rect.bottom + 8}px`,
left: `${Math.max(12, rect.right - dropdownWidth)}px`, left: `${Math.max(12, rect.right - dropdownWidth)}px`,
width: `${dropdownWidth}px`, width: `${dropdownWidth}px`,
transform: openUpward ? "translateY(-100%)" : "none", transform: openUpward ? "translateY(-100%)" : "none",
zIndex: 100000, zIndex: 100000,
}); });
}, [menuOpen]); }, [menuOpen]);
useEffect(() => { useEffect(() => {
const closeMenu = () => setMenuOpen(false); const closeMenu = () => setMenuOpen(false);
if (menuOpen) { if (menuOpen) {
window.addEventListener("resize", closeMenu); window.addEventListener("resize", closeMenu);
window.addEventListener("scroll", closeMenu, true); window.addEventListener("scroll", closeMenu, true);
} }
return () => { return () => {
window.removeEventListener("resize", closeMenu); window.removeEventListener("resize", closeMenu);
window.removeEventListener("scroll", closeMenu, true); window.removeEventListener("scroll", closeMenu, true);
}; };
}, [menuOpen]); }, [menuOpen]);
const closeSwipe = () => { const closeSwipe = () => {
touchStartXRef.current = null; touchStartXRef.current = null;
@@ -1493,17 +1493,17 @@ function MobileRecordedEntryCard({
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
onTouchCancel={closeSwipe} onTouchCancel={closeSwipe}
> >
<div className="absolute end-3 top-3"> <div className="absolute end-3 top-3">
<button <button
ref={buttonRef} ref={buttonRef}
type="button" type="button"
onClick={() => setMenuOpen((current) => !current)} onClick={() => setMenuOpen((current) => !current)}
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-100" className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-100"
title={t.actions?.more || "More"} title={t.actions?.more || "More"}
> >
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</button> </button>
</div> </div>
<div className="pe-10"> <div className="pe-10">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -1551,54 +1551,54 @@ function MobileRecordedEntryCard({
</div> </div>
)} )}
</div> </div>
</div> </div>
{menuOpen && {menuOpen &&
createPortal( createPortal(
<div <div
ref={dropdownRef} ref={dropdownRef}
style={menuStyle} style={menuStyle}
className="rounded-xl border border-slate-200 bg-white p-1 shadow-xl dark:border-slate-700 dark:bg-slate-900" className="rounded-xl border border-slate-200 bg-white p-1 shadow-xl dark:border-slate-700 dark:bg-slate-900"
> >
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setMenuOpen(false); setMenuOpen(false);
onEdit(entry); onEdit(entry);
}} }}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800" className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
{t.actions?.edit || "Edit"} {t.actions?.edit || "Edit"}
</button> </button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setMenuOpen(false); setMenuOpen(false);
onRequestRestart(entry); onRequestRestart(entry);
}} }}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800" className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
> >
<Play className="h-4 w-4" /> <Play className="h-4 w-4" />
{t.timesheet?.startTimer || "Start"} {t.timesheet?.startTimer || "Start"}
</button> </button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setMenuOpen(false); setMenuOpen(false);
onDelete(entry); onDelete(entry);
}} }}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-500/10" className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-500/10"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
{t.actions?.delete || "Delete"} {t.actions?.delete || "Delete"}
</button> </button>
</div>, </div>,
document.body, document.body,
)} )}
</div> </div>
); );
} }
export default function Timesheet() { export default function Timesheet() {
const { t, lang } = useTranslation(); const { t, lang } = useTranslation();
@@ -1618,13 +1618,13 @@ export default function Timesheet() {
showFiltersLabel?: string; showFiltersLabel?: string;
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;
}) || {}; }) || {};
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<Tag[]>([]);
@@ -1649,21 +1649,21 @@ export default function Timesheet() {
const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT)); const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT));
const timerSaveTimeoutRef = useRef<number | null>(null); const timerSaveTimeoutRef = useRef<number | null>(null);
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({ const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
isOpen: false, isOpen: false,
entry: null, entry: null,
}); });
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [restartModal, setRestartModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({ const [restartModal, setRestartModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
isOpen: false, isOpen: false,
entry: null, entry: null,
}); });
const [isRestarting, setIsRestarting] = useState(false); const [isRestarting, setIsRestarting] = useState(false);
const [discardTimerModal, setDiscardTimerModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({ const [discardTimerModal, setDiscardTimerModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
isOpen: false, isOpen: false,
entry: null, entry: null,
}); });
const [isDiscardingTimer, setIsDiscardingTimer] = useState(false); const [isDiscardingTimer, setIsDiscardingTimer] = useState(false);
const runningEntry = activeRunningEntry; const runningEntry = activeRunningEntry;
@@ -1931,17 +1931,17 @@ export default function Timesheet() {
} }
}; };
const handleStop = async (entry: TimeEntry) => { const handleStop = async (entry: TimeEntry) => {
try { try {
await stopTimeEntry(entry.id); await stopTimeEntry(entry.id);
toast.success(t.timesheet?.stopSuccess || "Timer stopped"); toast.success(t.timesheet?.stopSuccess || "Timer stopped");
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
setTimerDraft(EMPTY_TIMER_DRAFT); setTimerDraft(EMPTY_TIMER_DRAFT);
await loadHistory(); await loadHistory();
await loadRunningEntry(); await loadRunningEntry();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error(t.timesheet?.stopError || "Failed to stop timer"); toast.error(t.timesheet?.stopError || "Failed to stop timer");
} }
}; };
@@ -1967,35 +1967,35 @@ export default function Timesheet() {
} }
}; };
const openDeleteModal = (entry: TimeEntry) => { const openDeleteModal = (entry: TimeEntry) => {
setDeleteModal({ isOpen: true, entry }); setDeleteModal({ isOpen: true, entry });
}; };
const openRestartModal = (entry: TimeEntry) => {
setRestartModal({ isOpen: true, entry });
};
const closeDeleteModal = () => {
if (isDeleting) return;
setDeleteModal({ isOpen: false, entry: null });
};
const closeRestartModal = () => {
if (isRestarting) return;
setRestartModal({ isOpen: false, entry: null });
};
const openDiscardTimerModal = () => {
if (!runningEntry || isDiscardingTimer) return;
setDiscardTimerModal({ isOpen: true, entry: runningEntry });
};
const closeDiscardTimerModal = () => {
if (isDiscardingTimer) return;
setDiscardTimerModal({ isOpen: false, entry: null });
};
const confirmDelete = async () => { const openRestartModal = (entry: TimeEntry) => {
setRestartModal({ isOpen: true, entry });
};
const closeDeleteModal = () => {
if (isDeleting) return;
setDeleteModal({ isOpen: false, entry: null });
};
const closeRestartModal = () => {
if (isRestarting) return;
setRestartModal({ isOpen: false, entry: null });
};
const openDiscardTimerModal = () => {
if (!runningEntry || isDiscardingTimer) return;
setDiscardTimerModal({ isOpen: true, entry: runningEntry });
};
const closeDiscardTimerModal = () => {
if (isDiscardingTimer) return;
setDiscardTimerModal({ isOpen: false, entry: null });
};
const confirmDelete = async () => {
if (!deleteModal.entry) return; if (!deleteModal.entry) return;
try { try {
@@ -2025,19 +2025,19 @@ export default function Timesheet() {
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
} }
}; };
const confirmRestart = async () => { const confirmRestart = async () => {
if (!restartModal.entry) return; if (!restartModal.entry) return;
try { try {
setIsRestarting(true); setIsRestarting(true);
await handleRestartFromEntry(restartModal.entry); await handleRestartFromEntry(restartModal.entry);
setRestartModal({ isOpen: false, entry: null }); setRestartModal({ isOpen: false, entry: null });
} finally { } finally {
setIsRestarting(false); setIsRestarting(false);
} }
}; };
const handleEntryUpdated = useCallback((updatedEntry: TimeEntry) => { const handleEntryUpdated = useCallback((updatedEntry: TimeEntry) => {
setGroupedHistory((current) => updateGroupedHistoryEntry(current, updatedEntry)); setGroupedHistory((current) => updateGroupedHistoryEntry(current, updatedEntry));
@@ -2056,36 +2056,36 @@ export default function Timesheet() {
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;
void loadHistory({ offset: nextOffset, append: true }); void loadHistory({ offset: nextOffset, append: true });
}, [hasMoreHistory, isLoadingMore, loadHistory, nextOffset]); }, [hasMoreHistory, isLoadingMore, loadHistory, nextOffset]);
const handleDiscardTimerDraft = useCallback(async () => { const handleDiscardTimerDraft = useCallback(async () => {
if (!discardTimerModal.entry || isDiscardingTimer) return; if (!discardTimerModal.entry || isDiscardingTimer) return;
try { try {
setIsDiscardingTimer(true); setIsDiscardingTimer(true);
if (timerSaveTimeoutRef.current) { if (timerSaveTimeoutRef.current) {
window.clearTimeout(timerSaveTimeoutRef.current); window.clearTimeout(timerSaveTimeoutRef.current);
timerSaveTimeoutRef.current = null; timerSaveTimeoutRef.current = null;
} }
await deleteTimeEntry(discardTimerModal.entry.id); await deleteTimeEntry(discardTimerModal.entry.id);
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
setTimerDraft(EMPTY_TIMER_DRAFT); setTimerDraft(EMPTY_TIMER_DRAFT);
setActiveRunningEntry(null); setActiveRunningEntry(null);
setDiscardTimerModal({ isOpen: false, entry: null }); setDiscardTimerModal({ isOpen: false, entry: null });
toast.success(t.timesheet?.deleteSuccess || "Time entry deleted"); toast.success(t.timesheet?.deleteSuccess || "Time entry deleted");
await loadHistory(); await loadHistory();
await loadRunningEntry(); await loadRunningEntry();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error(t.timesheet?.deleteError || "Failed to delete time entry"); toast.error(t.timesheet?.deleteError || "Failed to delete time entry");
} finally { } finally {
setIsDiscardingTimer(false); setIsDiscardingTimer(false);
} }
}, [discardTimerModal.entry, isDiscardingTimer, loadHistory, loadRunningEntry, t.timesheet?.deleteError, t.timesheet?.deleteSuccess]); }, [discardTimerModal.entry, isDiscardingTimer, loadHistory, loadRunningEntry, t.timesheet?.deleteError, t.timesheet?.deleteSuccess]);
if (!activeWorkspace) { if (!activeWorkspace) {
return <div className="p-6 text-center text-slate-500">{t.timesheet?.selectWorkspace || t.clients.selectWorkspace}</div>; return <div className="p-6 text-center text-slate-500">{t.timesheet?.selectWorkspace || t.clients.selectWorkspace}</div>;
@@ -2097,11 +2097,6 @@ export default function Timesheet() {
<h1 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400"> <h1 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400">
{t.timesheet?.title || "Timesheet"} {t.timesheet?.title || "Timesheet"}
</h1> </h1>
<Button onClick={openCreateModal} className="h-9 rounded-md px-3 text-xs font-semibold uppercase dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700">
<Plus className="me-2 h-4 w-4" />
{t.timesheet?.addEntry || "Add Entry"}
</Button>
</div> </div>
<div className="mb-4 hidden overflow-x-auto border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 md:block"> <div className="mb-4 hidden overflow-x-auto border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 md:block">
@@ -2125,7 +2120,7 @@ export default function Timesheet() {
...projects.map((project) => ({ value: project.id, label: project.name })), ...projects.map((project) => ({ value: project.id, label: project.name })),
]} ]}
className="min-w-[170px]" className="min-w-[170px]"
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}
/> />
</div> </div>
@@ -2157,27 +2152,27 @@ export default function Timesheet() {
{runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"} {runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"}
</div> </div>
<div className="ms-2 flex shrink-0 items-center gap-2"> <div className="ms-2 flex shrink-0 items-center gap-2">
{runningEntry ? ( {runningEntry ? (
<> <>
<Button variant="destructive" onClick={() => void handleStop(runningEntry)} className="h-12 rounded-md px-5 text-xs font-semibold uppercase"> <Button variant="destructive" onClick={() => void handleStop(runningEntry)} className="h-12 rounded-md px-5 text-xs font-semibold uppercase">
{t.timesheet?.stopTimer || "Stop"} {t.timesheet?.stopTimer || "Stop"}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={openDiscardTimerModal} onClick={openDiscardTimerModal}
disabled={isDiscardingTimer} disabled={isDiscardingTimer}
className="h-12 rounded-md px-5 text-xs font-semibold uppercase" className="h-12 rounded-md px-5 text-xs font-semibold uppercase"
> >
{isDiscardingTimer ? "..." : ((t.actions as { discard?: string } | undefined)?.discard || "Discard")} {isDiscardingTimer ? "..." : ((t.actions as { discard?: string } | undefined)?.discard || "Discard")}
</Button> </Button>
</> </>
) : ( ) : (
<Button onClick={() => void handleStartTimer()} disabled={isStartingTimer} className="h-12 rounded-md px-5 text-xs font-semibold uppercase"> <Button onClick={() => void handleStartTimer()} disabled={isStartingTimer} className="h-12 rounded-md px-5 text-xs font-semibold uppercase">
{isStartingTimer ? "..." : (t.timesheet?.startTimer || "Start")} {isStartingTimer ? "..." : (t.timesheet?.startTimer || "Start")}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
</div> </div>
@@ -2201,7 +2196,7 @@ export default function Timesheet() {
...projects.map((project) => ({ value: project.id, label: project.name })), ...projects.map((project) => ({ value: project.id, label: project.name })),
]} ]}
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}
/> />
@@ -2210,10 +2205,10 @@ export default function Timesheet() {
</div> </div>
</div> </div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<TagMultiSelect <TagMultiSelect
tags={tags} tags={tags}
selectedTags={timerDraft.tags} selectedTags={timerDraft.tags}
onToggleTag={(tagId) => onToggleTag={(tagId) =>
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) })) setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
@@ -2229,33 +2224,33 @@ export default function Timesheet() {
label={t.timesheet?.billable || "Billable"} label={t.timesheet?.billable || "Billable"}
disabled={isStartingTimer} disabled={isStartingTimer}
compact compact
/> />
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{runningEntry ? ( {runningEntry ? (
<> <>
<Button variant="destructive" onClick={() => void handleStop(runningEntry)} className="h-10 rounded-md px-4 text-xs font-semibold uppercase"> <Button variant="destructive" onClick={() => void handleStop(runningEntry)} className="h-10 rounded-md px-4 text-xs font-semibold uppercase">
{t.timesheet?.stopTimer || "Stop"} {t.timesheet?.stopTimer || "Stop"}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={openDiscardTimerModal} onClick={openDiscardTimerModal}
disabled={isDiscardingTimer} disabled={isDiscardingTimer}
className="h-10 rounded-md px-4 text-xs font-semibold uppercase" className="h-10 rounded-md px-4 text-xs font-semibold uppercase"
> >
{isDiscardingTimer ? "..." : ((t.actions as { discard?: string } | undefined)?.discard || "Discard")} {isDiscardingTimer ? "..." : ((t.actions as { discard?: string } | undefined)?.discard || "Discard")}
</Button> </Button>
</> </>
) : ( ) : (
<Button onClick={() => void handleStartTimer()} disabled={isStartingTimer} className="h-10 rounded-md px-4 text-xs font-semibold uppercase"> <Button onClick={() => void handleStartTimer()} disabled={isStartingTimer} className="h-10 rounded-md px-4 text-xs font-semibold uppercase">
{isStartingTimer ? "..." : (t.timesheet?.startTimer || "Start")} {isStartingTimer ? "..." : (t.timesheet?.startTimer || "Start")}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<TimesheetFilterBar <TimesheetFilterBar
@@ -2335,15 +2330,15 @@ export default function Timesheet() {
/> />
</div> </div>
<div className="md:hidden"> <div className="md:hidden">
<MobileRecordedEntryCard <MobileRecordedEntryCard
entry={entry} entry={entry}
t={t} t={t}
projects={projects} projects={projects}
tags={tags} tags={tags}
onEdit={openEditModal} onEdit={openEditModal}
onDelete={openDeleteModal} onDelete={openDeleteModal}
onRequestRestart={openRestartModal} onRequestRestart={openRestartModal}
/> />
</div> </div>
</div> </div>
))} ))}
@@ -2390,8 +2385,8 @@ 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 || "Delete Time Entry"}
@@ -2421,75 +2416,75 @@ export default function Timesheet() {
</p> </p>
</div> </div>
</div> </div>
</Modal> </Modal>
)} )}
{restartModal.entry && ( {restartModal.entry && (
<Modal <Modal
isOpen={restartModal.isOpen} isOpen={restartModal.isOpen}
onClose={closeRestartModal} onClose={closeRestartModal}
title={t.timesheet?.startTimer || "Start"} title={t.timesheet?.startTimer || "Start"}
maxWidth="max-w-md" maxWidth="max-w-md"
footer={ footer={
<> <>
<Button variant="secondary" onClick={closeRestartModal}> <Button variant="secondary" onClick={closeRestartModal}>
{t.actions?.cancel || "Cancel"} {t.actions?.cancel || "Cancel"}
</Button> </Button>
<Button onClick={() => void confirmRestart()} disabled={isRestarting}> <Button onClick={() => void confirmRestart()} disabled={isRestarting}>
{isRestarting ? "..." : (t.timesheet?.startTimer || "Start")} {isRestarting ? "..." : (t.timesheet?.startTimer || "Start")}
</Button> </Button>
</> </>
} }
> >
<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 || "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"}
</p> </p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400"> <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{formatDateTime(restartModal.entry.start_time, lang)} {formatDateTime(restartModal.entry.start_time, lang)}
{restartModal.entry.end_time ? ` - ${formatDateTime(restartModal.entry.end_time, lang)}` : ""} {restartModal.entry.end_time ? ` - ${formatDateTime(restartModal.entry.end_time, lang)}` : ""}
</p> </p>
</div> </div>
</div> </div>
</Modal> </Modal>
)} )}
{discardTimerModal.entry && ( {discardTimerModal.entry && (
<Modal <Modal
isOpen={discardTimerModal.isOpen} isOpen={discardTimerModal.isOpen}
onClose={closeDiscardTimerModal} onClose={closeDiscardTimerModal}
title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"} title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
maxWidth="max-w-md" maxWidth="max-w-md"
footer={ footer={
<> <>
<Button variant="secondary" onClick={closeDiscardTimerModal}> <Button variant="secondary" onClick={closeDiscardTimerModal}>
{t.actions?.cancel || "Cancel"} {t.actions?.cancel || "Cancel"}
</Button> </Button>
<Button variant="destructive" onClick={() => void handleDiscardTimerDraft()} disabled={isDiscardingTimer}> <Button variant="destructive" onClick={() => void handleDiscardTimerDraft()} disabled={isDiscardingTimer}>
{isDiscardingTimer ? "..." : ((t.actions as { discard?: string } | undefined)?.discard || "Discard")} {isDiscardingTimer ? "..." : ((t.actions as { discard?: string } | undefined)?.discard || "Discard")}
</Button> </Button>
</> </>
} }
> >
<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 || "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">
{discardTimerModal.entry.description || t.timesheet?.emptyDescription || "No description"} {discardTimerModal.entry.description || t.timesheet?.emptyDescription || "No description"}
</p> </p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400"> <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{formatDateTime(discardTimerModal.entry.start_time, lang)} {formatDateTime(discardTimerModal.entry.start_time, lang)}
</p> </p>
</div> </div>
</div> </div>
</Modal> </Modal>
)} )}
</div> </div>
); );
} }