feat(pricing): manage workspace member rates in edit flows
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react";
|
import { CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { getProjects, type Project } from "../api/projects";
|
import { getProjects, type Project } from "../api/projects";
|
||||||
@@ -531,10 +531,10 @@ function TagMultiSelect({
|
|||||||
title: string;
|
title: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
portalOwnerId?: string;
|
portalOwnerId?: string;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||||
@@ -586,21 +586,21 @@ function TagMultiSelect({
|
|||||||
window.removeEventListener("resize", closeOnViewportChange);
|
window.removeEventListener("resize", closeOnViewportChange);
|
||||||
window.removeEventListener("scroll", closeOnViewportChange, true);
|
window.removeEventListener("scroll", closeOnViewportChange, true);
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name);
|
const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name);
|
||||||
const joinedSelectedLabels = selectedLabels.join(" | ");
|
const joinedSelectedLabels = selectedLabels.join(" | ");
|
||||||
const normalizedSearch = searchQuery.trim().toLowerCase();
|
const normalizedSearch = searchQuery.trim().toLowerCase();
|
||||||
const filteredTags = normalizedSearch
|
const filteredTags = normalizedSearch
|
||||||
? tags.filter((tag) => tag.name.toLowerCase().includes(normalizedSearch))
|
? tags.filter((tag) => tag.name.toLowerCase().includes(normalizedSearch))
|
||||||
: tags;
|
: tags;
|
||||||
const buttonLabel = compact
|
const buttonLabel = compact
|
||||||
? selectedTags.length > 0
|
? selectedTags.length > 0
|
||||||
? joinedSelectedLabels
|
? joinedSelectedLabels
|
||||||
: ""
|
: ""
|
||||||
@@ -640,32 +640,32 @@ function TagMultiSelect({
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
style={dropdownStyle}
|
style={dropdownStyle}
|
||||||
data-entry-editor-owner={portalOwnerId}
|
data-entry-editor-owner={portalOwnerId}
|
||||||
className="rounded-2xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-800"
|
className="rounded-2xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-800"
|
||||||
>
|
>
|
||||||
{tags.length === 0 ? (
|
{tags.length === 0 ? (
|
||||||
<p className="px-2 py-2 text-sm text-slate-500 dark:text-slate-400">{emptyHint}</p>
|
<p className="px-2 py-2 text-sm text-slate-500 dark:text-slate-400">{emptyHint}</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="border-b border-slate-200 p-2 dark:border-slate-700">
|
<div className="border-b border-slate-200 p-2 dark:border-slate-700">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(event) => setSearchQuery(event.target.value)}
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
placeholder="Search tags..."
|
placeholder="Search tags..."
|
||||||
className="h-8 w-full rounded-md border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800"
|
className="h-8 w-full rounded-md border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-72 space-y-1 overflow-y-auto p-2">
|
<div className="max-h-72 space-y-1 overflow-y-auto p-2">
|
||||||
{filteredTags.map((tag) => {
|
{filteredTags.map((tag) => {
|
||||||
const selected = selectedTags.includes(tag.id);
|
const selected = selectedTags.includes(tag.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -682,16 +682,16 @@ function TagMultiSelect({
|
|||||||
{selected && <Check className="h-4 w-4 shrink-0" />}
|
{selected && <Check className="h-4 w-4 shrink-0" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{filteredTags.length === 0 && (
|
{filteredTags.length === 0 && (
|
||||||
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
|
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
No tags found.
|
No tags found.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -1557,7 +1557,7 @@ function MobileRecordedEntryCard({
|
|||||||
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
|
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
<span>{formatTimeOnly(entry.start_time)} - {formatTimeOnly(entry.end_time)}</span>
|
<span>{formatTimeOnly(entry.start_time)} - {formatTimeOnly(entry.end_time)}</span>
|
||||||
<span>{formatDateTime(entry.start_time, "en")}</span>
|
{/* <span>{formatDateTime(entry.start_time, "en")}</span> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1676,8 +1676,12 @@ export default function Timesheet() {
|
|||||||
|
|
||||||
const [timerDraft, setTimerDraft] = useState<TimerDraftState>(EMPTY_TIMER_DRAFT);
|
const [timerDraft, setTimerDraft] = useState<TimerDraftState>(EMPTY_TIMER_DRAFT);
|
||||||
const [isStartingTimer, setIsStartingTimer] = useState(false);
|
const [isStartingTimer, setIsStartingTimer] = useState(false);
|
||||||
|
const desktopTimerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mobileTimerRef = useRef<HTMLDivElement>(null);
|
||||||
const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT));
|
const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT));
|
||||||
const timerSaveTimeoutRef = useRef<number | null>(null);
|
const pendingTimerSignatureRef = useRef<string | null>(serializeTimerDraft(EMPTY_TIMER_DRAFT));
|
||||||
|
const isTimerSavingRef = useRef(false);
|
||||||
|
const timerEditorOwnerId = "running-timer-editor";
|
||||||
|
|
||||||
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
|
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
@@ -1823,62 +1827,87 @@ export default function Timesheet() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runningEntry) {
|
if (!runningEntry) {
|
||||||
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
|
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
|
||||||
|
pendingTimerSignatureRef.current = timerDraftSignatureRef.current;
|
||||||
setTimerDraft(EMPTY_TIMER_DRAFT);
|
setTimerDraft(EMPTY_TIMER_DRAFT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextDraft = buildTimerDraftState(runningEntry);
|
const nextDraft = buildTimerDraftState(runningEntry);
|
||||||
timerDraftSignatureRef.current = serializeTimerDraft(nextDraft);
|
timerDraftSignatureRef.current = serializeTimerDraft(nextDraft);
|
||||||
|
pendingTimerSignatureRef.current = timerDraftSignatureRef.current;
|
||||||
setTimerDraft(nextDraft);
|
setTimerDraft(nextDraft);
|
||||||
}, [runningEntry]);
|
}, [runningEntry]);
|
||||||
|
|
||||||
useEffect(() => {
|
const isInsideTimerEditorContext = useCallback((target: EventTarget | null) => {
|
||||||
|
if (!(target instanceof Node)) return false;
|
||||||
|
if (desktopTimerRef.current?.contains(target) || mobileTimerRef.current?.contains(target)) return true;
|
||||||
|
return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${timerEditorOwnerId}"]`));
|
||||||
|
}, [timerEditorOwnerId]);
|
||||||
|
|
||||||
|
const commitTimerDraft = useCallback(async () => {
|
||||||
const saveErrorText = extendedTimesheet.saveError || "Failed to save time entry";
|
const saveErrorText = extendedTimesheet.saveError || "Failed to save time entry";
|
||||||
const saveSuccessText = extendedTimesheet.saveSuccess || "Time entry saved";
|
const saveSuccessText = extendedTimesheet.saveSuccess || "Time entry saved";
|
||||||
|
|
||||||
if (!runningEntry) return;
|
if (!runningEntry) return false;
|
||||||
|
|
||||||
const currentSignature = serializeTimerDraft(timerDraft);
|
const currentSignature = serializeTimerDraft(timerDraft);
|
||||||
if (currentSignature === timerDraftSignatureRef.current) return;
|
if (currentSignature === timerDraftSignatureRef.current) {
|
||||||
|
return false;
|
||||||
if (timerSaveTimeoutRef.current) {
|
|
||||||
window.clearTimeout(timerSaveTimeoutRef.current);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
timerSaveTimeoutRef.current = window.setTimeout(async () => {
|
if (isTimerSavingRef.current || pendingTimerSignatureRef.current === currentSignature) {
|
||||||
try {
|
return false;
|
||||||
const updatedEntry = await updateTimeEntry(runningEntry.id, {
|
}
|
||||||
description: timerDraft.description.trim(),
|
|
||||||
project_id: timerDraft.projectId || null,
|
|
||||||
tags: timerDraft.tags,
|
|
||||||
is_billable: timerDraft.isBillable,
|
|
||||||
});
|
|
||||||
|
|
||||||
const syncedDraft = buildTimerDraftState(updatedEntry);
|
isTimerSavingRef.current = true;
|
||||||
timerDraftSignatureRef.current = serializeTimerDraft(syncedDraft);
|
pendingTimerSignatureRef.current = currentSignature;
|
||||||
setTimerDraft(syncedDraft);
|
|
||||||
setActiveRunningEntry(updatedEntry);
|
|
||||||
toast.success(saveSuccessText);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error(saveErrorText);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
return () => {
|
try {
|
||||||
if (timerSaveTimeoutRef.current) {
|
const updatedEntry = await updateTimeEntry(runningEntry.id, {
|
||||||
window.clearTimeout(timerSaveTimeoutRef.current);
|
description: timerDraft.description.trim(),
|
||||||
}
|
project_id: timerDraft.projectId || null,
|
||||||
};
|
tags: timerDraft.tags,
|
||||||
|
is_billable: timerDraft.isBillable,
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncedDraft = buildTimerDraftState(updatedEntry);
|
||||||
|
const syncedSignature = serializeTimerDraft(syncedDraft);
|
||||||
|
timerDraftSignatureRef.current = syncedSignature;
|
||||||
|
pendingTimerSignatureRef.current = syncedSignature;
|
||||||
|
setTimerDraft(syncedDraft);
|
||||||
|
setActiveRunningEntry(updatedEntry);
|
||||||
|
toast.success(saveSuccessText);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
pendingTimerSignatureRef.current = null;
|
||||||
|
toast.error(saveErrorText);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isTimerSavingRef.current = false;
|
||||||
|
}
|
||||||
}, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry, timerDraft]);
|
}, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry, timerDraft]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
if (!runningEntry) return;
|
||||||
if (timerSaveTimeoutRef.current) {
|
|
||||||
window.clearTimeout(timerSaveTimeoutRef.current);
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
}
|
if (isInsideTimerEditorContext(event.target)) return;
|
||||||
|
void commitTimerDraft();
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
document.addEventListener("mousedown", handlePointerDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handlePointerDown);
|
||||||
|
};
|
||||||
|
}, [commitTimerDraft, isInsideTimerEditorContext, runningEntry]);
|
||||||
|
|
||||||
|
const handleTimerBlurCapture = () => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (isInsideTimerEditorContext(document.activeElement)) return;
|
||||||
|
void commitTimerDraft();
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
const closeCreateModal = () => {
|
const closeCreateModal = () => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
@@ -2096,10 +2125,6 @@ export default function Timesheet() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsDiscardingTimer(true);
|
setIsDiscardingTimer(true);
|
||||||
if (timerSaveTimeoutRef.current) {
|
|
||||||
window.clearTimeout(timerSaveTimeoutRef.current);
|
|
||||||
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);
|
||||||
@@ -2129,7 +2154,11 @@ export default function Timesheet() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 hidden overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 md:block">
|
<div
|
||||||
|
ref={desktopTimerRef}
|
||||||
|
onBlurCapture={handleTimerBlurCapture}
|
||||||
|
className="mb-4 hidden overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 md:block"
|
||||||
|
>
|
||||||
<div className="flex min-w-[1040px] items-center h-20 px-3">
|
<div className="flex min-w-[1040px] items-center h-20 px-3">
|
||||||
<div className="min-w-[360px] flex-1">
|
<div className="min-w-[360px] flex-1">
|
||||||
<Input
|
<Input
|
||||||
@@ -2152,6 +2181,7 @@ export default function Timesheet() {
|
|||||||
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}
|
||||||
|
portalOwnerId={timerEditorOwnerId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2165,6 +2195,7 @@ export default function Timesheet() {
|
|||||||
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
||||||
title={t.tags?.title || "Tags"}
|
title={t.tags?.title || "Tags"}
|
||||||
compact
|
compact
|
||||||
|
portalOwnerId={timerEditorOwnerId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2185,46 +2216,50 @@ export default function Timesheet() {
|
|||||||
<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
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => void handleStop(runningEntry)}
|
onClick={() => void handleStop(runningEntry)}
|
||||||
className="h-12 w-12 rounded-md"
|
className="h-12 w-12 rounded-md"
|
||||||
title={t.timesheet?.stopTimer || "Stop"}
|
title={t.timesheet?.stopTimer || "Stop"}
|
||||||
aria-label={t.timesheet?.stopTimer || "Stop"}
|
aria-label={t.timesheet?.stopTimer || "Stop"}
|
||||||
>
|
>
|
||||||
<Square className="h-4 w-4 fill-current" />
|
<Square className="h-4 w-4 fill-current" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={openDiscardTimerModal}
|
onClick={openDiscardTimerModal}
|
||||||
disabled={isDiscardingTimer}
|
disabled={isDiscardingTimer}
|
||||||
className="h-12 w-12 rounded-md"
|
className="h-12 w-12 rounded-md"
|
||||||
title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||||||
aria-label={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
aria-label={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||||||
>
|
>
|
||||||
{isDiscardingTimer ? "..." : <Trash2 className="h-4 w-4" />}
|
{isDiscardingTimer ? "..." : <Trash2 className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void handleStartTimer()}
|
onClick={() => void handleStartTimer()}
|
||||||
disabled={isStartingTimer}
|
disabled={isStartingTimer}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-12 w-12 rounded-md"
|
className="h-12 w-12 rounded-md"
|
||||||
title={t.timesheet?.startTimer || "Start"}
|
title={t.timesheet?.startTimer || "Start"}
|
||||||
aria-label={t.timesheet?.startTimer || "Start"}
|
aria-label={t.timesheet?.startTimer || "Start"}
|
||||||
>
|
>
|
||||||
{isStartingTimer ? "..." : <Play className="h-4 w-4 fill-current" />}
|
{isStartingTimer ? "..." : <Play className="h-4 w-4 fill-current" />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 rounded-xl border border-slate-200 bg-white p-3 shadow-sm dark:border-slate-800 dark:bg-slate-950 md:hidden">
|
<div
|
||||||
|
ref={mobileTimerRef}
|
||||||
|
onBlurCapture={handleTimerBlurCapture}
|
||||||
|
className="mb-4 rounded-xl border border-slate-200 bg-white p-3 shadow-sm dark:border-slate-800 dark:bg-slate-950 md:hidden"
|
||||||
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Input
|
<Input
|
||||||
value={timerDraft.description}
|
value={timerDraft.description}
|
||||||
@@ -2245,6 +2280,7 @@ export default function Timesheet() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
buttonClassName="h-10 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
buttonClassName="h-10 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
disabled={isStartingTimer}
|
disabled={isStartingTimer}
|
||||||
|
portalOwnerId={timerEditorOwnerId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex h-10 min-w-[110px] items-center justify-center rounded-md border border-slate-200 bg-slate-50 px-3 text-sm font-semibold text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white">
|
<div className="flex h-10 min-w-[110px] items-center justify-center rounded-md border border-slate-200 bg-slate-50 px-3 text-sm font-semibold text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white">
|
||||||
@@ -2263,6 +2299,7 @@ export default function Timesheet() {
|
|||||||
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
||||||
title={t.tags?.title || "Tags"}
|
title={t.tags?.title || "Tags"}
|
||||||
compact
|
compact
|
||||||
|
portalOwnerId={timerEditorOwnerId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BillableIconButton
|
<BillableIconButton
|
||||||
@@ -2277,40 +2314,40 @@ export default function Timesheet() {
|
|||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{runningEntry ? (
|
{runningEntry ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => void handleStop(runningEntry)}
|
onClick={() => void handleStop(runningEntry)}
|
||||||
className="h-10 w-10 rounded-md"
|
className="h-10 w-10 rounded-md"
|
||||||
title={t.timesheet?.stopTimer || "Stop"}
|
title={t.timesheet?.stopTimer || "Stop"}
|
||||||
aria-label={t.timesheet?.stopTimer || "Stop"}
|
aria-label={t.timesheet?.stopTimer || "Stop"}
|
||||||
>
|
>
|
||||||
<Square className="h-4 w-4 fill-current" />
|
<Square className="h-4 w-4 fill-current" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={openDiscardTimerModal}
|
onClick={openDiscardTimerModal}
|
||||||
disabled={isDiscardingTimer}
|
disabled={isDiscardingTimer}
|
||||||
className="h-10 w-10 rounded-md"
|
className="h-10 w-10 rounded-md"
|
||||||
title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||||||
aria-label={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
aria-label={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||||||
>
|
>
|
||||||
{isDiscardingTimer ? "..." : <Trash2 className="h-4 w-4" />}
|
{isDiscardingTimer ? "..." : <Trash2 className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void handleStartTimer()}
|
onClick={() => void handleStartTimer()}
|
||||||
disabled={isStartingTimer}
|
disabled={isStartingTimer}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-10 w-10 rounded-md"
|
className="h-10 w-10 rounded-md"
|
||||||
title={t.timesheet?.startTimer || "Start"}
|
title={t.timesheet?.startTimer || "Start"}
|
||||||
aria-label={t.timesheet?.startTimer || "Start"}
|
aria-label={t.timesheet?.startTimer || "Start"}
|
||||||
>
|
>
|
||||||
{isStartingTimer ? "..." : <Play className="h-4 w-4 fill-current" />}
|
{isStartingTimer ? "..." : <Play className="h-4 w-4 fill-current" />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2330,8 +2367,8 @@ export default function Timesheet() {
|
|||||||
client: t.projects?.clientLabel || "Client",
|
client: t.projects?.clientLabel || "Client",
|
||||||
tags: t.tags?.title || "Tags",
|
tags: t.tags?.title || "Tags",
|
||||||
clear: extendedTimesheet.clearFilters || "Clear filters",
|
clear: extendedTimesheet.clearFilters || "Clear filters",
|
||||||
customFrom: extendedTimesheet.customFromLabel || "From date",
|
customFrom: extendedTimesheet.customFromLabel || "From date",
|
||||||
customTo: extendedTimesheet.customToLabel || "To date",
|
customTo: extendedTimesheet.customToLabel || "To date",
|
||||||
allClients: extendedTimesheet.allClientsLabel || "All clients",
|
allClients: extendedTimesheet.allClientsLabel || "All clients",
|
||||||
allProjects: extendedTimesheet.allProjectsLabel || "All projects",
|
allProjects: extendedTimesheet.allProjectsLabel || "All projects",
|
||||||
allTags: extendedTimesheet.allTagsLabel || "All tags",
|
allTags: extendedTimesheet.allTagsLabel || "All tags",
|
||||||
|
|||||||
Reference in New Issue
Block a user