feat(pricing): manage workspace member rates in edit flows

This commit is contained in:
2026-04-26 10:19:25 +03:30
parent 846668add9
commit f9dfd8826e

View File

@@ -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",