feat(timesheet): improve inline edit autosave
This commit is contained in:
@@ -701,6 +701,10 @@ export const en = {
|
|||||||
noProjectsFoundLabel: "No projects found.",
|
noProjectsFoundLabel: "No projects found.",
|
||||||
deletedProjectLabel: "Deleted project",
|
deletedProjectLabel: "Deleted project",
|
||||||
deletedTagLabel: "Deleted tag",
|
deletedTagLabel: "Deleted tag",
|
||||||
|
startRequiredError: "Start date and time are required.",
|
||||||
|
endRequiredError: "End date and time must both be filled.",
|
||||||
|
invalidEndTimeError: "End time is invalid.",
|
||||||
|
endBeforeStartError: "End must be after start.",
|
||||||
},
|
},
|
||||||
|
|
||||||
reports: {
|
reports: {
|
||||||
|
|||||||
@@ -698,6 +698,10 @@ export const fa = {
|
|||||||
noProjectsFoundLabel: "پروژهای پیدا نشد.",
|
noProjectsFoundLabel: "پروژهای پیدا نشد.",
|
||||||
deletedProjectLabel: "پروژه حذفشده",
|
deletedProjectLabel: "پروژه حذفشده",
|
||||||
deletedTagLabel: "تگ حذفشده",
|
deletedTagLabel: "تگ حذفشده",
|
||||||
|
startRequiredError: "تاریخ و زمان شروع الزامی است.",
|
||||||
|
endRequiredError: "تاریخ و زمان پایان باید هر دو وارد شوند.",
|
||||||
|
invalidEndTimeError: "زمان پایان معتبر نیست.",
|
||||||
|
endBeforeStartError: "پایان باید بعد از شروع باشد.",
|
||||||
},
|
},
|
||||||
reports: {
|
reports: {
|
||||||
title: "گزارشها",
|
title: "گزارشها",
|
||||||
|
|||||||
@@ -152,6 +152,18 @@ const handleFormattedTimeInputChange = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectTimeSegment = (input: HTMLInputElement) => {
|
||||||
|
const cursor = input.selectionStart ?? 0;
|
||||||
|
const start = cursor <= 2 ? 0 : cursor <= 5 ? 3 : 6;
|
||||||
|
const end = start + 2;
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (document.activeElement === input) {
|
||||||
|
input.setSelectionRange(start, end);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const isValidTimeValue = (value: string) => {
|
const isValidTimeValue = (value: string) => {
|
||||||
if (!/^\d{2}:\d{2}:\d{2}$/.test(value)) return false;
|
if (!/^\d{2}:\d{2}:\d{2}$/.test(value)) return false;
|
||||||
const [hours, minutes, seconds] = value.split(":").map(Number);
|
const [hours, minutes, seconds] = value.split(":").map(Number);
|
||||||
@@ -447,23 +459,34 @@ const toggleTagId = (currentTags: string[], tagId: string) =>
|
|||||||
|
|
||||||
const buildPayloadFromState = (
|
const buildPayloadFromState = (
|
||||||
state: EntryFormState,
|
state: EntryFormState,
|
||||||
options: { includeWorkspace: boolean; workspaceId?: string },
|
options: { includeWorkspace: boolean; workspaceId?: string; messages?: Partial<Record<"startRequired" | "endRequired" | "invalidEndTime" | "endBeforeStart", string>> },
|
||||||
): { payload?: TimeEntryPayload; error?: string } => {
|
): { payload?: TimeEntryPayload; error?: string } => {
|
||||||
|
const messages = {
|
||||||
|
startRequired: "Start date and time are required.",
|
||||||
|
endRequired: "End date and time must both be filled.",
|
||||||
|
invalidEndTime: "End time is invalid.",
|
||||||
|
endBeforeStart: "End must be after start.",
|
||||||
|
...options.messages,
|
||||||
|
};
|
||||||
const startDateTime = combineDateAndTime(state.startDate, state.startTime);
|
const startDateTime = combineDateAndTime(state.startDate, state.startTime);
|
||||||
if (!startDateTime) {
|
if (!startDateTime) {
|
||||||
return { error: "Start date and time are required." };
|
return { error: messages.startRequired };
|
||||||
}
|
}
|
||||||
|
|
||||||
let endDateTime: string | null = null;
|
let endDateTime: string | null = null;
|
||||||
const hasEndValue = Boolean(state.endDate || state.endTime);
|
const hasEndValue = Boolean(state.endDate || state.endTime);
|
||||||
if (hasEndValue) {
|
if (hasEndValue) {
|
||||||
if (!state.endDate || !state.endTime) {
|
if (!state.endDate || !state.endTime) {
|
||||||
return { error: "End date and time must both be filled." };
|
return { error: messages.endRequired };
|
||||||
}
|
}
|
||||||
|
|
||||||
endDateTime = combineDateAndTime(state.endDate, state.endTime);
|
endDateTime = combineDateAndTime(state.endDate, state.endTime);
|
||||||
if (!endDateTime) {
|
if (!endDateTime) {
|
||||||
return { error: "End time is invalid." };
|
return { error: messages.invalidEndTime };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date(endDateTime).getTime() <= new Date(startDateTime).getTime()) {
|
||||||
|
return { error: messages.endBeforeStart };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,12 +628,14 @@ function TimeField({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
onBlur,
|
||||||
placeholder,
|
placeholder,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -626,6 +651,9 @@ function TimeField({
|
|||||||
placeholder={placeholder || "HH:MM:SS"}
|
placeholder={placeholder || "HH:MM:SS"}
|
||||||
className={compact ? "h-9 px-2 text-xs" : ""}
|
className={compact ? "h-9 px-2 text-xs" : ""}
|
||||||
onChange={(event) => handleFormattedTimeInputChange(event, onChange)}
|
onChange={(event) => handleFormattedTimeInputChange(event, onChange)}
|
||||||
|
onFocus={(event) => selectTimeSegment(event.currentTarget)}
|
||||||
|
onClick={(event) => selectTimeSegment(event.currentTarget)}
|
||||||
|
onBlur={onBlur}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -670,6 +698,7 @@ function TagMultiSelect({
|
|||||||
onToggleTag,
|
onToggleTag,
|
||||||
emptyHint,
|
emptyHint,
|
||||||
title,
|
title,
|
||||||
|
onDropdownClose,
|
||||||
compact = false,
|
compact = false,
|
||||||
portalOwnerId,
|
portalOwnerId,
|
||||||
className = "",
|
className = "",
|
||||||
@@ -681,6 +710,7 @@ function TagMultiSelect({
|
|||||||
onToggleTag: (tagId: string) => void;
|
onToggleTag: (tagId: string) => void;
|
||||||
emptyHint: string;
|
emptyHint: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
onDropdownClose?: () => void;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
portalOwnerId?: string;
|
portalOwnerId?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -692,6 +722,7 @@ function TagMultiSelect({
|
|||||||
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 wasOpenRef = useRef(false);
|
||||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -741,6 +772,13 @@ function TagMultiSelect({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasOpenRef.current && !isOpen) {
|
||||||
|
onDropdownClose?.();
|
||||||
|
}
|
||||||
|
wasOpenRef.current = isOpen;
|
||||||
|
}, [isOpen, onDropdownClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
@@ -1072,12 +1110,14 @@ function CompactDateTimeField({
|
|||||||
timeValue,
|
timeValue,
|
||||||
onDateChange,
|
onDateChange,
|
||||||
onTimeChange,
|
onTimeChange,
|
||||||
|
onTimeBlur,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
dateValue: string;
|
dateValue: string;
|
||||||
timeValue: string;
|
timeValue: string;
|
||||||
onDateChange: (value: string) => void;
|
onDateChange: (value: string) => void;
|
||||||
onTimeChange: (value: string) => void;
|
onTimeChange: (value: string) => void;
|
||||||
|
onTimeBlur?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-[208px]">
|
<div className="min-w-[208px]">
|
||||||
@@ -1100,6 +1140,9 @@ function CompactDateTimeField({
|
|||||||
placeholder="HH:MM:SS"
|
placeholder="HH:MM:SS"
|
||||||
className="h-9 min-w-[88px] border-0 bg-transparent px-2 text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
className="h-9 min-w-[88px] border-0 bg-transparent px-2 text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
onChange={(event) => onTimeChange(formatTimeInputValue(event.target.value))}
|
onChange={(event) => onTimeChange(formatTimeInputValue(event.target.value))}
|
||||||
|
onFocus={(event) => selectTimeSegment(event.currentTarget)}
|
||||||
|
onClick={(event) => selectTimeSegment(event.currentTarget)}
|
||||||
|
onBlur={onTimeBlur}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1111,11 +1154,15 @@ function InlineTimeRangeField({
|
|||||||
endTime,
|
endTime,
|
||||||
onStartTimeChange,
|
onStartTimeChange,
|
||||||
onEndTimeChange,
|
onEndTimeChange,
|
||||||
|
onStartTimeBlur,
|
||||||
|
onEndTimeBlur,
|
||||||
}: {
|
}: {
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
onStartTimeChange: (value: string) => void;
|
onStartTimeChange: (value: string) => void;
|
||||||
onEndTimeChange: (value: string) => void;
|
onEndTimeChange: (value: string) => void;
|
||||||
|
onStartTimeBlur?: () => void;
|
||||||
|
onEndTimeBlur?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-12 items-center justify-center border-s border-slate-200 px-2 text-xs text-slate-600 dark:border-slate-800 dark:text-slate-200">
|
<div className="flex h-12 items-center justify-center border-s border-slate-200 px-2 text-xs text-slate-600 dark:border-slate-800 dark:text-slate-200">
|
||||||
@@ -1128,6 +1175,9 @@ function InlineTimeRangeField({
|
|||||||
placeholder="HH:MM:SS"
|
placeholder="HH:MM:SS"
|
||||||
className="h-9 min-w-[68px] border-0 bg-transparent dark:bg-transparent px-0 text-center text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
className="h-9 min-w-[68px] border-0 bg-transparent dark:bg-transparent px-0 text-center text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
onChange={(event) => handleFormattedTimeInputChange(event, onStartTimeChange)}
|
onChange={(event) => handleFormattedTimeInputChange(event, onStartTimeChange)}
|
||||||
|
onFocus={(event) => selectTimeSegment(event.currentTarget)}
|
||||||
|
onClick={(event) => selectTimeSegment(event.currentTarget)}
|
||||||
|
onBlur={onStartTimeBlur}
|
||||||
/>
|
/>
|
||||||
<span className="px-1 text-slate-400">-</span>
|
<span className="px-1 text-slate-400">-</span>
|
||||||
<Input
|
<Input
|
||||||
@@ -1139,6 +1189,9 @@ function InlineTimeRangeField({
|
|||||||
placeholder="HH:MM:SS"
|
placeholder="HH:MM:SS"
|
||||||
className="h-9 min-w-[68px] border-0 bg-transparent dark:bg-transparent px-0 text-center text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
className="h-9 min-w-[68px] border-0 bg-transparent dark:bg-transparent px-0 text-center text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
onChange={(event) => handleFormattedTimeInputChange(event, onEndTimeChange)}
|
onChange={(event) => handleFormattedTimeInputChange(event, onEndTimeChange)}
|
||||||
|
onFocus={(event) => selectTimeSegment(event.currentTarget)}
|
||||||
|
onClick={(event) => selectTimeSegment(event.currentTarget)}
|
||||||
|
onBlur={onEndTimeBlur}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1149,12 +1202,14 @@ function DateRangePopover({
|
|||||||
endDate,
|
endDate,
|
||||||
onStartDateChange,
|
onStartDateChange,
|
||||||
onEndDateChange,
|
onEndDateChange,
|
||||||
|
onCommit,
|
||||||
portalOwnerId,
|
portalOwnerId,
|
||||||
}: {
|
}: {
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
onStartDateChange: (value: string) => void;
|
onStartDateChange: (value: string) => void;
|
||||||
onEndDateChange: (value: string) => void;
|
onEndDateChange: (value: string) => void;
|
||||||
|
onCommit?: () => void;
|
||||||
portalOwnerId?: string;
|
portalOwnerId?: string;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -1219,8 +1274,22 @@ function DateRangePopover({
|
|||||||
className="rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-700 dark:bg-slate-950"
|
className="rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-700 dark:bg-slate-950"
|
||||||
>
|
>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<JalaliDatePicker label="Start date" value={startDate} onChange={onStartDateChange} />
|
<JalaliDatePicker
|
||||||
<JalaliDatePicker label="End date" value={endDate} onChange={onEndDateChange} />
|
label="Start date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(value) => {
|
||||||
|
onStartDateChange(value);
|
||||||
|
window.setTimeout(() => onCommit?.(), 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<JalaliDatePicker
|
||||||
|
label="End date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(value) => {
|
||||||
|
onEndDateChange(value);
|
||||||
|
window.setTimeout(() => onCommit?.(), 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
@@ -1251,6 +1320,7 @@ function EntryEditorFields({
|
|||||||
onChange,
|
onChange,
|
||||||
onToggleTag,
|
onToggleTag,
|
||||||
onProjectChange,
|
onProjectChange,
|
||||||
|
onCommit,
|
||||||
projects,
|
projects,
|
||||||
tags,
|
tags,
|
||||||
t,
|
t,
|
||||||
@@ -1259,9 +1329,10 @@ function EntryEditorFields({
|
|||||||
portalOwnerId,
|
portalOwnerId,
|
||||||
}: {
|
}: {
|
||||||
state: EntryFormState;
|
state: EntryFormState;
|
||||||
onChange: (patch: Partial<EntryFormState>) => void;
|
onChange: (patch: Partial<EntryFormState>, saveMode?: "none" | "immediate" | "debounce") => void;
|
||||||
onToggleTag: (tagId: string) => void;
|
onToggleTag: (tagId: string) => void;
|
||||||
onProjectChange?: (projectId: string) => void;
|
onProjectChange?: (projectId: string) => void;
|
||||||
|
onCommit?: () => void;
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
t: any;
|
t: any;
|
||||||
@@ -1276,7 +1347,9 @@ function EntryEditorFields({
|
|||||||
<div className="flex min-w-0 items-center">
|
<div className="flex min-w-0 items-center">
|
||||||
<Input
|
<Input
|
||||||
value={state.description}
|
value={state.description}
|
||||||
onChange={(event) => onChange({ description: event.target.value })}
|
onChange={(event) => {
|
||||||
|
onChange({ description: event.target.value }, "debounce");
|
||||||
|
}}
|
||||||
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||||||
className="h-12 w-[200px] 2xl:w-[400px] shrink-0 rounded-none border-0 bg-transparent px-0 pe-3 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 truncate dark:bg-transparent dark:text-slate-100 dark:placeholder:text-slate-600"
|
className="h-12 w-[200px] 2xl:w-[400px] shrink-0 rounded-none border-0 bg-transparent px-0 pe-3 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 truncate dark:bg-transparent dark:text-slate-100 dark:placeholder:text-slate-600"
|
||||||
/>
|
/>
|
||||||
@@ -1288,7 +1361,7 @@ function EntryEditorFields({
|
|||||||
<ProjectInlineSelect
|
<ProjectInlineSelect
|
||||||
projects={projects}
|
projects={projects}
|
||||||
value={state.projectId}
|
value={state.projectId}
|
||||||
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))}
|
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }, "immediate"))}
|
||||||
placeholder={t.timesheet?.projectLabel || "Project"}
|
placeholder={t.timesheet?.projectLabel || "Project"}
|
||||||
searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
|
searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
|
||||||
emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."}
|
emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."}
|
||||||
@@ -1317,13 +1390,14 @@ function EntryEditorFields({
|
|||||||
portalOwnerId={portalOwnerId}
|
portalOwnerId={portalOwnerId}
|
||||||
className="w-full min-w-0 overflow-hidden 2xl:w-auto 2xl:max-w-none"
|
className="w-full min-w-0 overflow-hidden 2xl:w-auto 2xl:max-w-none"
|
||||||
buttonClassName="w-full min-w-0 max-w-full justify-start overflow-hidden 2xl:w-auto 2xl:max-w-none"
|
buttonClassName="w-full min-w-0 max-w-full justify-start overflow-hidden 2xl:w-auto 2xl:max-w-none"
|
||||||
|
onDropdownClose={onCommit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-10">
|
<div className="w-10">
|
||||||
<BillableIconButton
|
<BillableIconButton
|
||||||
checked={state.isBillable}
|
checked={state.isBillable}
|
||||||
onChange={(checked) => onChange({ isBillable: checked })}
|
onChange={(checked) => onChange({ isBillable: checked }, "immediate")}
|
||||||
label={t.timesheet?.billable || "Billable"}
|
label={t.timesheet?.billable || "Billable"}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
@@ -1335,6 +1409,8 @@ function EntryEditorFields({
|
|||||||
endTime={state.endTime}
|
endTime={state.endTime}
|
||||||
onStartTimeChange={(value) => onChange({ startTime: value })}
|
onStartTimeChange={(value) => onChange({ startTime: value })}
|
||||||
onEndTimeChange={(value) => onChange({ endTime: value })}
|
onEndTimeChange={(value) => onChange({ endTime: value })}
|
||||||
|
onStartTimeBlur={onCommit}
|
||||||
|
onEndTimeBlur={onCommit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1342,8 +1418,8 @@ function EntryEditorFields({
|
|||||||
<DateRangePopover
|
<DateRangePopover
|
||||||
startDate={state.startDate}
|
startDate={state.startDate}
|
||||||
endDate={state.endDate}
|
endDate={state.endDate}
|
||||||
onStartDateChange={(value) => onChange({ startDate: value })}
|
onStartDateChange={(value) => onChange({ startDate: value }, "immediate")}
|
||||||
onEndDateChange={(value) => onChange({ endDate: value })}
|
onEndDateChange={(value) => onChange({ endDate: value }, "immediate")}
|
||||||
portalOwnerId={portalOwnerId}
|
portalOwnerId={portalOwnerId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1358,8 +1434,10 @@ function EntryEditorFields({
|
|||||||
{t.timesheet?.descriptionLabel || "Description"}
|
{t.timesheet?.descriptionLabel || "Description"}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={state.description}
|
value={state.description}
|
||||||
onChange={(event) => onChange({ description: event.target.value })}
|
onChange={(event) => {
|
||||||
|
onChange({ description: event.target.value }, "debounce");
|
||||||
|
}}
|
||||||
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||||||
className={compact ? "h-9 px-2 text-xs placeholder:text-slate-300 dark:placeholder:text-slate-600" : "placeholder:text-slate-300 dark:placeholder:text-slate-600"}
|
className={compact ? "h-9 px-2 text-xs placeholder:text-slate-300 dark:placeholder:text-slate-600" : "placeholder:text-slate-300 dark:placeholder:text-slate-600"}
|
||||||
/>
|
/>
|
||||||
@@ -1370,8 +1448,10 @@ function EntryEditorFields({
|
|||||||
{t.timesheet?.projectLabel || "Project"}
|
{t.timesheet?.projectLabel || "Project"}
|
||||||
</label>
|
</label>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
value={state.projectId}
|
value={state.projectId}
|
||||||
onChange={(value) => onChange({ projectId: String(value) })}
|
onChange={(value) => {
|
||||||
|
onChange({ projectId: String(value) }, "immediate");
|
||||||
|
}}
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: t.timesheet?.noProject || "No project" },
|
{ value: "", label: t.timesheet?.noProject || "No project" },
|
||||||
...projects.map((project) => ({
|
...projects.map((project) => ({
|
||||||
@@ -1393,13 +1473,16 @@ function EntryEditorFields({
|
|||||||
<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 }, "immediate");
|
||||||
|
}}
|
||||||
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 })}
|
||||||
|
onBlur={onCommit}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1408,13 +1491,16 @@ function EntryEditorFields({
|
|||||||
<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 }, "immediate");
|
||||||
|
}}
|
||||||
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 })}
|
||||||
|
onBlur={onCommit}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1423,7 +1509,9 @@ function EntryEditorFields({
|
|||||||
<div>
|
<div>
|
||||||
<BillableIconButton
|
<BillableIconButton
|
||||||
checked={state.isBillable}
|
checked={state.isBillable}
|
||||||
onChange={(checked) => onChange({ isBillable: checked })}
|
onChange={(checked) => {
|
||||||
|
onChange({ isBillable: checked }, "immediate");
|
||||||
|
}}
|
||||||
label={t.timesheet?.billable || "Billable"}
|
label={t.timesheet?.billable || "Billable"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1432,6 +1520,7 @@ function EntryEditorFields({
|
|||||||
tags={tags}
|
tags={tags}
|
||||||
selectedTags={state.tags}
|
selectedTags={state.tags}
|
||||||
onToggleTag={onToggleTag}
|
onToggleTag={onToggleTag}
|
||||||
|
onDropdownClose={onCommit}
|
||||||
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}
|
compact={compact}
|
||||||
@@ -1467,6 +1556,7 @@ function RecordedEntryCard({
|
|||||||
const rowRef = useRef<HTMLDivElement>(null);
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
const isSavingRef = useRef(false);
|
const isSavingRef = useRef(false);
|
||||||
const pendingSignatureRef = useRef<string | null>(null);
|
const pendingSignatureRef = useRef<string | null>(null);
|
||||||
|
const descriptionSaveTimeoutRef = useRef<number | null>(null);
|
||||||
const editorOwnerId = `time-entry-editor-${entry.id}`;
|
const editorOwnerId = `time-entry-editor-${entry.id}`;
|
||||||
const timesheetCopy = (t.timesheet as { saveError?: string; saveSuccess?: string }) || {};
|
const timesheetCopy = (t.timesheet as { saveError?: string; saveSuccess?: string }) || {};
|
||||||
const saveErrorText = timesheetCopy.saveError || "Failed to save time entry";
|
const saveErrorText = timesheetCopy.saveError || "Failed to save time entry";
|
||||||
@@ -1483,6 +1573,10 @@ function RecordedEntryCard({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (descriptionSaveTimeoutRef.current) {
|
||||||
|
window.clearTimeout(descriptionSaveTimeoutRef.current);
|
||||||
|
descriptionSaveTimeoutRef.current = null;
|
||||||
|
}
|
||||||
const nextDraft = buildEntryFormState(entry);
|
const nextDraft = buildEntryFormState(entry);
|
||||||
const nextSignature = serializeEntryDraft(nextDraft);
|
const nextSignature = serializeEntryDraft(nextDraft);
|
||||||
syncedSignatureRef.current = nextSignature;
|
syncedSignatureRef.current = nextSignature;
|
||||||
@@ -1491,14 +1585,27 @@ function RecordedEntryCard({
|
|||||||
setValidationMessage("");
|
setValidationMessage("");
|
||||||
}, [entry]);
|
}, [entry]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (descriptionSaveTimeoutRef.current) {
|
||||||
|
window.clearTimeout(descriptionSaveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isInsideEditorContext = useCallback((target: EventTarget | null) => {
|
const isInsideEditorContext = useCallback((target: EventTarget | null) => {
|
||||||
if (!(target instanceof Node)) return false;
|
if (!(target instanceof Node)) return false;
|
||||||
if (rowRef.current?.contains(target)) return true;
|
if (rowRef.current?.contains(target)) return true;
|
||||||
return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${editorOwnerId}"]`));
|
return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${editorOwnerId}"]`));
|
||||||
}, [editorOwnerId]);
|
}, [editorOwnerId]);
|
||||||
|
|
||||||
const commitDraft = useCallback(async () => {
|
const validationMessages = useMemo(() => ({
|
||||||
const currentSignature = serializeEntryDraft(draft);
|
startRequired: t.timesheet?.startRequiredError || "Start date and time are required.",
|
||||||
|
endRequired: t.timesheet?.endRequiredError || "End date and time must both be filled.",
|
||||||
|
invalidEndTime: t.timesheet?.invalidEndTimeError || "End time is invalid.",
|
||||||
|
endBeforeStart: t.timesheet?.endBeforeStartError || "End must be after start.",
|
||||||
|
}), [t.timesheet]);
|
||||||
|
|
||||||
|
const commitDraftState = useCallback(async (nextDraft: EntryFormState) => {
|
||||||
|
const currentSignature = serializeEntryDraft(nextDraft);
|
||||||
if (currentSignature === syncedSignatureRef.current) {
|
if (currentSignature === syncedSignatureRef.current) {
|
||||||
setValidationMessage("");
|
setValidationMessage("");
|
||||||
return false;
|
return false;
|
||||||
@@ -1508,7 +1615,7 @@ function RecordedEntryCard({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { payload, error } = buildPayloadFromState(draft, { includeWorkspace: false });
|
const { payload, error } = buildPayloadFromState(nextDraft, { includeWorkspace: false, messages: validationMessages });
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
setValidationMessage(error || "");
|
setValidationMessage(error || "");
|
||||||
return false;
|
return false;
|
||||||
@@ -1530,56 +1637,40 @@ function RecordedEntryCard({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
pendingSignatureRef.current = null;
|
pendingSignatureRef.current = null;
|
||||||
toast.error(saveErrorText);
|
toast.error(error instanceof Error ? error.message : saveErrorText);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
isSavingRef.current = false;
|
isSavingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [draft, entry.id, onEntryUpdated, saveErrorText, saveSuccessText]);
|
}, [entry.id, onEntryUpdated, saveErrorText, saveSuccessText, validationMessages]);
|
||||||
|
|
||||||
|
const commitDraft = useCallback(async () => commitDraftState(draft), [commitDraftState, draft]);
|
||||||
|
|
||||||
const commitPatchedDraft = useCallback(async (patch: Partial<EntryFormState>) => {
|
const commitPatchedDraft = useCallback(async (patch: Partial<EntryFormState>) => {
|
||||||
const nextDraft = { ...draft, ...patch };
|
const nextDraft = { ...draft, ...patch };
|
||||||
const nextSignature = serializeEntryDraft(nextDraft);
|
|
||||||
setDraft(nextDraft);
|
setDraft(nextDraft);
|
||||||
|
return commitDraftState(nextDraft);
|
||||||
|
}, [commitDraftState, draft]);
|
||||||
|
|
||||||
if (nextSignature === syncedSignatureRef.current) {
|
const handleDraftChange = useCallback((patch: Partial<EntryFormState>, saveMode: "none" | "immediate" | "debounce" = "none") => {
|
||||||
setValidationMessage("");
|
setDraft((current) => {
|
||||||
return false;
|
const nextDraft = { ...current, ...patch };
|
||||||
}
|
if (descriptionSaveTimeoutRef.current) {
|
||||||
|
window.clearTimeout(descriptionSaveTimeoutRef.current);
|
||||||
|
descriptionSaveTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (isSavingRef.current || pendingSignatureRef.current === nextSignature) {
|
if (saveMode === "immediate") {
|
||||||
return false;
|
window.setTimeout(() => void commitDraftState(nextDraft), 0);
|
||||||
}
|
} else if (saveMode === "debounce") {
|
||||||
|
descriptionSaveTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
void commitDraftState(nextDraft);
|
||||||
|
}, 700);
|
||||||
|
}
|
||||||
|
|
||||||
const { payload, error } = buildPayloadFromState(nextDraft, { includeWorkspace: false });
|
return nextDraft;
|
||||||
if (!payload) {
|
});
|
||||||
setValidationMessage(error || "");
|
}, [commitDraftState]);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setValidationMessage("");
|
|
||||||
isSavingRef.current = true;
|
|
||||||
pendingSignatureRef.current = nextSignature;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedEntry = await updateTimeEntry(entry.id, payload);
|
|
||||||
const updatedDraft = buildEntryFormState(updatedEntry);
|
|
||||||
const updatedSignature = serializeEntryDraft(updatedDraft);
|
|
||||||
syncedSignatureRef.current = updatedSignature;
|
|
||||||
pendingSignatureRef.current = updatedSignature;
|
|
||||||
setDraft(updatedDraft);
|
|
||||||
onEntryUpdated(updatedEntry);
|
|
||||||
toast.success(saveSuccessText);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
pendingSignatureRef.current = null;
|
|
||||||
toast.error(saveErrorText);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
isSavingRef.current = false;
|
|
||||||
}
|
|
||||||
}, [draft, entry.id, onEntryUpdated, saveErrorText, saveSuccessText]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePointerDown = (event: MouseEvent) => {
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
@@ -1610,8 +1701,9 @@ function RecordedEntryCard({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<EntryEditorFields
|
<EntryEditorFields
|
||||||
state={draft}
|
state={draft}
|
||||||
onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
|
onChange={handleDraftChange}
|
||||||
onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
onCommit={commitDraft}
|
||||||
|
onToggleTag={(tagId) => handleDraftChange({ tags: toggleTagId(draft.tags, tagId) })}
|
||||||
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
|
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
|
||||||
projects={editorProjects}
|
projects={editorProjects}
|
||||||
tags={editorTags}
|
tags={editorTags}
|
||||||
@@ -1660,8 +1752,9 @@ function RecordedEntryCard({
|
|||||||
<div className="flex min-w-0 items-center">
|
<div className="flex min-w-0 items-center">
|
||||||
<EntryEditorFields
|
<EntryEditorFields
|
||||||
state={draft}
|
state={draft}
|
||||||
onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
|
onChange={handleDraftChange}
|
||||||
onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
onCommit={commitDraft}
|
||||||
|
onToggleTag={(tagId) => handleDraftChange({ tags: toggleTagId(draft.tags, tagId) })}
|
||||||
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
|
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
|
||||||
projects={editorProjects}
|
projects={editorProjects}
|
||||||
tags={editorTags}
|
tags={editorTags}
|
||||||
@@ -1725,8 +1818,11 @@ function MobileRecordedEntryCard({
|
|||||||
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 touchStartYRef = useRef<number | null>(null);
|
||||||
|
const touchGestureRef = useRef<"pending" | "swipe" | "scroll">("pending");
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||||
|
const [touchGesture, setTouchGesture] = useState<"pending" | "swipe" | "scroll">("pending");
|
||||||
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({});
|
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1780,6 +1876,9 @@ function MobileRecordedEntryCard({
|
|||||||
|
|
||||||
const closeSwipe = () => {
|
const closeSwipe = () => {
|
||||||
touchStartXRef.current = null;
|
touchStartXRef.current = null;
|
||||||
|
touchStartYRef.current = null;
|
||||||
|
touchGestureRef.current = "pending";
|
||||||
|
setTouchGesture("pending");
|
||||||
setSwipeOffset(0);
|
setSwipeOffset(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1788,15 +1887,40 @@ function MobileRecordedEntryCard({
|
|||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
}
|
}
|
||||||
touchStartXRef.current = event.touches[0]?.clientX ?? null;
|
touchStartXRef.current = event.touches[0]?.clientX ?? null;
|
||||||
|
touchStartYRef.current = event.touches[0]?.clientY ?? null;
|
||||||
|
touchGestureRef.current = "pending";
|
||||||
|
setTouchGesture("pending");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => {
|
const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||||
if (touchStartXRef.current === null) return;
|
if (touchStartXRef.current === null || touchStartYRef.current === null) return;
|
||||||
const delta = (event.touches[0]?.clientX ?? 0) - touchStartXRef.current;
|
const deltaX = (event.touches[0]?.clientX ?? 0) - touchStartXRef.current;
|
||||||
setSwipeOffset(Math.max(-88, Math.min(88, delta)));
|
const deltaY = (event.touches[0]?.clientY ?? 0) - touchStartYRef.current;
|
||||||
|
const absX = Math.abs(deltaX);
|
||||||
|
const absY = Math.abs(deltaY);
|
||||||
|
|
||||||
|
if (touchGestureRef.current === "pending" && (absX > 8 || absY > 8)) {
|
||||||
|
touchGestureRef.current = absX > absY + 8 ? "swipe" : "scroll";
|
||||||
|
setTouchGesture(touchGestureRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touchGestureRef.current === "scroll") {
|
||||||
|
setSwipeOffset(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touchGestureRef.current === "swipe") {
|
||||||
|
event.preventDefault();
|
||||||
|
setSwipeOffset(Math.max(-88, Math.min(88, deltaX)));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = () => {
|
||||||
|
if (touchGestureRef.current !== "swipe") {
|
||||||
|
closeSwipe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (swipeOffset <= -72) {
|
if (swipeOffset <= -72) {
|
||||||
closeSwipe();
|
closeSwipe();
|
||||||
onDelete(entry);
|
onDelete(entry);
|
||||||
@@ -1823,7 +1947,7 @@ function MobileRecordedEntryCard({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className="relative bg-white px-4 py-5 transition-transform duration-150 ease-out dark:bg-slate-900/95"
|
className="relative bg-white px-4 py-5 transition-transform duration-150 ease-out dark:bg-slate-900/95"
|
||||||
style={{ transform: `translateX(${swipeOffset}px)` }}
|
style={{ transform: `translateX(${swipeOffset}px)`, touchAction: touchGesture === "swipe" ? "none" : "pan-y" }}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
@@ -2065,6 +2189,10 @@ export default function Timesheet() {
|
|||||||
noTagsFoundLabel?: string;
|
noTagsFoundLabel?: string;
|
||||||
searchProjectsLabel?: string;
|
searchProjectsLabel?: string;
|
||||||
noProjectsFoundLabel?: string;
|
noProjectsFoundLabel?: string;
|
||||||
|
startRequiredError?: string;
|
||||||
|
endRequiredError?: string;
|
||||||
|
invalidEndTimeError?: string;
|
||||||
|
endBeforeStartError?: string;
|
||||||
}) || {};
|
}) || {};
|
||||||
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
@@ -2111,6 +2239,7 @@ export default function Timesheet() {
|
|||||||
const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT));
|
const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT));
|
||||||
const pendingTimerSignatureRef = useRef<string | null>(serializeTimerDraft(EMPTY_TIMER_DRAFT));
|
const pendingTimerSignatureRef = useRef<string | null>(serializeTimerDraft(EMPTY_TIMER_DRAFT));
|
||||||
const isTimerSavingRef = useRef(false);
|
const isTimerSavingRef = useRef(false);
|
||||||
|
const timerDescriptionSaveTimeoutRef = useRef<number | null>(null);
|
||||||
const timerEditorOwnerId = "running-timer-editor";
|
const timerEditorOwnerId = "running-timer-editor";
|
||||||
|
|
||||||
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
|
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
|
||||||
@@ -2149,6 +2278,17 @@ export default function Timesheet() {
|
|||||||
() => buildTagOptionsForEntry(tags, editingEntry, formState.tags),
|
() => buildTagOptionsForEntry(tags, editingEntry, formState.tags),
|
||||||
[editingEntry, formState.tags, tags],
|
[editingEntry, formState.tags, tags],
|
||||||
);
|
);
|
||||||
|
const entryValidationMessages = useMemo(() => ({
|
||||||
|
startRequired: extendedTimesheet.startRequiredError || "Start date and time are required.",
|
||||||
|
endRequired: extendedTimesheet.endRequiredError || "End date and time must both be filled.",
|
||||||
|
invalidEndTime: extendedTimesheet.invalidEndTimeError || "End time is invalid.",
|
||||||
|
endBeforeStart: extendedTimesheet.endBeforeStartError || "End must be after start.",
|
||||||
|
}), [
|
||||||
|
extendedTimesheet.endBeforeStartError,
|
||||||
|
extendedTimesheet.endRequiredError,
|
||||||
|
extendedTimesheet.invalidEndTimeError,
|
||||||
|
extendedTimesheet.startRequiredError,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runningEntry) return;
|
if (!runningEntry) return;
|
||||||
@@ -2299,6 +2439,11 @@ export default function Timesheet() {
|
|||||||
}, [loadRunningEntry]);
|
}, [loadRunningEntry]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (timerDescriptionSaveTimeoutRef.current) {
|
||||||
|
window.clearTimeout(timerDescriptionSaveTimeoutRef.current);
|
||||||
|
timerDescriptionSaveTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!runningEntry) {
|
if (!runningEntry) {
|
||||||
setTimerClockAnchor(null);
|
setTimerClockAnchor(null);
|
||||||
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
|
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
|
||||||
@@ -2313,19 +2458,25 @@ export default function Timesheet() {
|
|||||||
setTimerDraft(nextDraft);
|
setTimerDraft(nextDraft);
|
||||||
}, [runningEntry]);
|
}, [runningEntry]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (timerDescriptionSaveTimeoutRef.current) {
|
||||||
|
window.clearTimeout(timerDescriptionSaveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isInsideTimerEditorContext = useCallback((target: EventTarget | null) => {
|
const isInsideTimerEditorContext = useCallback((target: EventTarget | null) => {
|
||||||
if (!(target instanceof Node)) return false;
|
if (!(target instanceof Node)) return false;
|
||||||
if (desktopTimerRef.current?.contains(target) || mobileTimerRef.current?.contains(target)) return true;
|
if (desktopTimerRef.current?.contains(target) || mobileTimerRef.current?.contains(target)) return true;
|
||||||
return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${timerEditorOwnerId}"]`));
|
return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${timerEditorOwnerId}"]`));
|
||||||
}, [timerEditorOwnerId]);
|
}, [timerEditorOwnerId]);
|
||||||
|
|
||||||
const commitTimerDraft = useCallback(async () => {
|
const commitTimerDraftState = useCallback(async (nextDraft: TimerDraftState) => {
|
||||||
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 false;
|
if (!runningEntry) return false;
|
||||||
|
|
||||||
const currentSignature = serializeTimerDraft(timerDraft);
|
const currentSignature = serializeTimerDraft(nextDraft);
|
||||||
if (currentSignature === timerDraftSignatureRef.current) {
|
if (currentSignature === timerDraftSignatureRef.current) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -2339,10 +2490,10 @@ export default function Timesheet() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedEntry = await updateTimeEntry(runningEntry.id, {
|
const updatedEntry = await updateTimeEntry(runningEntry.id, {
|
||||||
description: timerDraft.description.trim(),
|
description: nextDraft.description.trim(),
|
||||||
project_id: timerDraft.projectId || null,
|
project_id: nextDraft.projectId || null,
|
||||||
tags: timerDraft.tags,
|
tags: nextDraft.tags,
|
||||||
is_billable: timerDraft.isBillable,
|
is_billable: nextDraft.isBillable,
|
||||||
});
|
});
|
||||||
|
|
||||||
const syncedDraft = buildTimerDraftState(updatedEntry);
|
const syncedDraft = buildTimerDraftState(updatedEntry);
|
||||||
@@ -2360,12 +2511,34 @@ export default function Timesheet() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
pendingTimerSignatureRef.current = null;
|
pendingTimerSignatureRef.current = null;
|
||||||
toast.error(saveErrorText);
|
toast.error(error instanceof Error ? error.message : saveErrorText);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
isTimerSavingRef.current = false;
|
isTimerSavingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry, timerDraft]);
|
}, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry]);
|
||||||
|
|
||||||
|
const commitTimerDraft = useCallback(async () => commitTimerDraftState(timerDraft), [commitTimerDraftState, timerDraft]);
|
||||||
|
|
||||||
|
const updateTimerDraft = useCallback((patch: Partial<TimerDraftState>, saveMode: "none" | "immediate" | "debounce" = "none") => {
|
||||||
|
setTimerDraft((current) => {
|
||||||
|
const nextDraft = { ...current, ...patch };
|
||||||
|
if (timerDescriptionSaveTimeoutRef.current) {
|
||||||
|
window.clearTimeout(timerDescriptionSaveTimeoutRef.current);
|
||||||
|
timerDescriptionSaveTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveMode === "immediate") {
|
||||||
|
window.setTimeout(() => void commitTimerDraftState(nextDraft), 0);
|
||||||
|
} else if (saveMode === "debounce") {
|
||||||
|
timerDescriptionSaveTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
void commitTimerDraftState(nextDraft);
|
||||||
|
}, 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextDraft;
|
||||||
|
});
|
||||||
|
}, [commitTimerDraftState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runningEntry) return;
|
if (!runningEntry) return;
|
||||||
@@ -2407,12 +2580,14 @@ export default function Timesheet() {
|
|||||||
setFormState(buildEntryFormState(entry));
|
setFormState(buildEntryFormState(entry));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveEntryModal = async () => {
|
const handleSaveEntryModal = async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event?.preventDefault();
|
||||||
if (modalMode === "manual" && !activeWorkspace?.id) return;
|
if (modalMode === "manual" && !activeWorkspace?.id) return;
|
||||||
|
|
||||||
const { payload, error } = buildPayloadFromState(formState, {
|
const { payload, error } = buildPayloadFromState(formState, {
|
||||||
includeWorkspace: modalMode === "manual",
|
includeWorkspace: modalMode === "manual",
|
||||||
workspaceId: activeWorkspace?.id,
|
workspaceId: activeWorkspace?.id,
|
||||||
|
messages: entryValidationMessages,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
@@ -2438,7 +2613,7 @@ export default function Timesheet() {
|
|||||||
setFormState(EMPTY_FORM);
|
setFormState(EMPTY_FORM);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(t.timesheet?.saveError || "Failed to save time entry");
|
toast.error(error instanceof Error ? error.message : (t.timesheet?.saveError || "Failed to save time entry"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -2535,7 +2710,8 @@ export default function Timesheet() {
|
|||||||
setDiscardTimerModal({ isOpen: false, entry: null });
|
setDiscardTimerModal({ isOpen: false, entry: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event?.preventDefault();
|
||||||
if (!deleteModal.entry) return;
|
if (!deleteModal.entry) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2567,7 +2743,8 @@ export default function Timesheet() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmRestart = async () => {
|
const confirmRestart = async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event?.preventDefault();
|
||||||
if (!restartModal.entry) return;
|
if (!restartModal.entry) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2709,7 +2886,7 @@ export default function Timesheet() {
|
|||||||
<Input
|
<Input
|
||||||
value={timerDraft.description}
|
value={timerDraft.description}
|
||||||
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||||||
onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))}
|
onChange={(event) => updateTimerDraft({ description: event.target.value }, "debounce")}
|
||||||
disabled={isStartingTimer}
|
disabled={isStartingTimer}
|
||||||
className="h-12 rounded-none border-0 bg-transparent px-5 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent dark:placeholder:text-slate-600"
|
className="h-12 rounded-none border-0 bg-transparent px-5 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent dark:placeholder:text-slate-600"
|
||||||
/>
|
/>
|
||||||
@@ -2718,7 +2895,7 @@ export default function Timesheet() {
|
|||||||
<div className="flex shrink-0 items-center">
|
<div className="flex shrink-0 items-center">
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
value={timerDraft.projectId}
|
value={timerDraft.projectId}
|
||||||
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
onChange={(value) => updateTimerDraft({ projectId: String(value) }, "immediate")}
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
||||||
...runningTimerProjects.map((project) => ({
|
...runningTimerProjects.map((project) => ({
|
||||||
@@ -2740,9 +2917,8 @@ export default function Timesheet() {
|
|||||||
<TagMultiSelect
|
<TagMultiSelect
|
||||||
tags={runningTimerTags}
|
tags={runningTimerTags}
|
||||||
selectedTags={timerDraft.tags}
|
selectedTags={timerDraft.tags}
|
||||||
onToggleTag={(tagId) =>
|
onToggleTag={(tagId) => updateTimerDraft({ tags: toggleTagId(timerDraft.tags, tagId) })}
|
||||||
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
onDropdownClose={() => void commitTimerDraft()}
|
||||||
}
|
|
||||||
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
|
||||||
@@ -2755,7 +2931,7 @@ export default function Timesheet() {
|
|||||||
<div className="flex shrink-0 items-center">
|
<div className="flex shrink-0 items-center">
|
||||||
<BillableIconButton
|
<BillableIconButton
|
||||||
checked={timerDraft.isBillable}
|
checked={timerDraft.isBillable}
|
||||||
onChange={(checked) => setTimerDraft((current) => ({ ...current, isBillable: checked }))}
|
onChange={(checked) => updateTimerDraft({ isBillable: checked }, "immediate")}
|
||||||
label={t.timesheet?.billable || "Billable"}
|
label={t.timesheet?.billable || "Billable"}
|
||||||
disabled={isStartingTimer}
|
disabled={isStartingTimer}
|
||||||
compact
|
compact
|
||||||
@@ -2817,7 +2993,7 @@ export default function Timesheet() {
|
|||||||
<Input
|
<Input
|
||||||
value={timerDraft.description}
|
value={timerDraft.description}
|
||||||
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||||||
onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))}
|
onChange={(event) => updateTimerDraft({ description: event.target.value }, "debounce")}
|
||||||
disabled={isStartingTimer}
|
disabled={isStartingTimer}
|
||||||
className="h-10 border-slate-200 bg-slate-50 text-sm placeholder:text-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:placeholder:text-slate-600"
|
className="h-10 border-slate-200 bg-slate-50 text-sm placeholder:text-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:placeholder:text-slate-600"
|
||||||
/>
|
/>
|
||||||
@@ -2825,7 +3001,7 @@ export default function Timesheet() {
|
|||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
value={timerDraft.projectId}
|
value={timerDraft.projectId}
|
||||||
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
onChange={(value) => updateTimerDraft({ projectId: String(value) }, "immediate")}
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
||||||
...runningTimerProjects.map((project) => ({
|
...runningTimerProjects.map((project) => ({
|
||||||
@@ -2852,9 +3028,8 @@ export default function Timesheet() {
|
|||||||
<TagMultiSelect
|
<TagMultiSelect
|
||||||
tags={runningTimerTags}
|
tags={runningTimerTags}
|
||||||
selectedTags={timerDraft.tags}
|
selectedTags={timerDraft.tags}
|
||||||
onToggleTag={(tagId) =>
|
onToggleTag={(tagId) => updateTimerDraft({ tags: toggleTagId(timerDraft.tags, tagId) })}
|
||||||
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
onDropdownClose={() => void commitTimerDraft()}
|
||||||
}
|
|
||||||
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
|
||||||
@@ -2863,7 +3038,7 @@ export default function Timesheet() {
|
|||||||
|
|
||||||
<BillableIconButton
|
<BillableIconButton
|
||||||
checked={timerDraft.isBillable}
|
checked={timerDraft.isBillable}
|
||||||
onChange={(checked) => setTimerDraft((current) => ({ ...current, isBillable: checked }))}
|
onChange={(checked) => updateTimerDraft({ isBillable: checked }, "immediate")}
|
||||||
label={t.timesheet?.billable || "Billable"}
|
label={t.timesheet?.billable || "Billable"}
|
||||||
disabled={isStartingTimer}
|
disabled={isStartingTimer}
|
||||||
compact
|
compact
|
||||||
@@ -3045,21 +3220,23 @@ export default function Timesheet() {
|
|||||||
<Button variant="secondary" onClick={closeCreateModal}>
|
<Button variant="secondary" onClick={closeCreateModal}>
|
||||||
{t.actions?.cancel || "Cancel"}
|
{t.actions?.cancel || "Cancel"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void handleSaveEntryModal()} disabled={isSaving}>
|
<Button type="submit" form="time-entry-modal-form" disabled={isSaving}>
|
||||||
{isSaving ? "..." : (modalMode === "edit" ? (t.save || "Save") : (t.create || "Create"))}
|
{isSaving ? "..." : (modalMode === "edit" ? (t.save || "Save") : (t.create || "Create"))}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<EntryEditorFields
|
<form id="time-entry-modal-form" onSubmit={handleSaveEntryModal}>
|
||||||
state={formState}
|
<EntryEditorFields
|
||||||
onChange={(patch) => setFormState((current) => ({ ...current, ...patch }))}
|
state={formState}
|
||||||
onToggleTag={(tagId) => setFormState((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
onChange={(patch) => setFormState((current) => ({ ...current, ...patch }))}
|
||||||
projects={modalProjects}
|
onToggleTag={(tagId) => setFormState((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
||||||
tags={modalTags}
|
projects={modalProjects}
|
||||||
t={t}
|
tags={modalTags}
|
||||||
isRtl={isRtl}
|
t={t}
|
||||||
/>
|
isRtl={isRtl}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{deleteModal.entry && (
|
{deleteModal.entry && (
|
||||||
@@ -3073,13 +3250,13 @@ export default function Timesheet() {
|
|||||||
<Button variant="secondary" onClick={closeDeleteModal}>
|
<Button variant="secondary" onClick={closeDeleteModal}>
|
||||||
{t.actions?.cancel || "Cancel"}
|
{t.actions?.cancel || "Cancel"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={confirmDelete} disabled={isDeleting}>
|
<Button type="submit" form="delete-time-entry-form" variant="destructive" disabled={isDeleting}>
|
||||||
{isDeleting ? "..." : (t.actions?.delete || "Delete")}
|
{isDeleting ? "..." : (t.actions?.delete || "Delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<form id="delete-time-entry-form" onSubmit={confirmDelete} 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 || t.timesheet?.deleteConfirmMessage || "Are you sure you want to delete this time entry?"}
|
{extendedTimesheet.deleteConfirmMessage || t.timesheet?.deleteConfirmMessage || "Are you sure you want to delete this time entry?"}
|
||||||
</p>
|
</p>
|
||||||
@@ -3092,7 +3269,7 @@ export default function Timesheet() {
|
|||||||
{deleteModal.entry.end_time ? ` - ${formatDateTime(deleteModal.entry.end_time, lang)}` : ""}
|
{deleteModal.entry.end_time ? ` - ${formatDateTime(deleteModal.entry.end_time, lang)}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -3107,13 +3284,13 @@ export default function Timesheet() {
|
|||||||
<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 type="submit" form="restart-time-entry-form" disabled={isRestarting}>
|
||||||
{isRestarting ? "..." : (t.timesheet?.startTimer || "Start")}
|
{isRestarting ? "..." : (t.timesheet?.startTimer || "Start")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<form id="restart-time-entry-form" onSubmit={confirmRestart} 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 || t.timesheet?.restartConfirmMessage || "Start a new running timer from this entry?"}
|
{extendedTimesheet.restartConfirmMessage || t.timesheet?.restartConfirmMessage || "Start a new running timer from this entry?"}
|
||||||
</p>
|
</p>
|
||||||
@@ -3126,7 +3303,7 @@ export default function Timesheet() {
|
|||||||
{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>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -3141,13 +3318,20 @@ export default function Timesheet() {
|
|||||||
<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 type="submit" form="discard-timer-form" variant="destructive" 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">
|
<form
|
||||||
|
id="discard-timer-form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleDiscardTimerDraft();
|
||||||
|
}}
|
||||||
|
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.discardConfirmMessage || t.timesheet?.discardConfirmMessage || "Are you sure you want to discard this running timer?"}
|
{extendedTimesheet.discardConfirmMessage || t.timesheet?.discardConfirmMessage || "Are you sure you want to discard this running timer?"}
|
||||||
</p>
|
</p>
|
||||||
@@ -3159,7 +3343,7 @@ export default function Timesheet() {
|
|||||||
{formatDateTime(discardTimerModal.entry.start_time, lang)}
|
{formatDateTime(discardTimerModal.entry.start_time, lang)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user