fix(timesheet): refine responsive filter bar and timer actions
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user