feat(timesheet): improve inline edit autosave
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-06-07 15:37:40 +03:30
parent 03c7c07a9f
commit 29cadb83e6
3 changed files with 514 additions and 322 deletions

View File

@@ -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: {

View File

@@ -698,6 +698,10 @@ export const fa = {
noProjectsFoundLabel: "پروژه‌ای پیدا نشد.", noProjectsFoundLabel: "پروژه‌ای پیدا نشد.",
deletedProjectLabel: "پروژه حذف‌شده", deletedProjectLabel: "پروژه حذف‌شده",
deletedTagLabel: "تگ حذف‌شده", deletedTagLabel: "تگ حذف‌شده",
startRequiredError: "تاریخ و زمان شروع الزامی است.",
endRequiredError: "تاریخ و زمان پایان باید هر دو وارد شوند.",
invalidEndTimeError: "زمان پایان معتبر نیست.",
endBeforeStartError: "پایان باید بعد از شروع باشد.",
}, },
reports: { reports: {
title: "گزارش‌ها", title: "گزارش‌ها",

View File

@@ -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>
)} )}