2592 lines
94 KiB
TypeScript
2592 lines
94 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
import { getProjects, type Project } from "../api/projects";
|
||
import {
|
||
createTimeEntry,
|
||
deleteTimeEntry,
|
||
getTimeEntries,
|
||
stopTimeEntry,
|
||
type TimeEntryGroupWeek,
|
||
type TimeEntry,
|
||
type TimeEntryListParams,
|
||
type TimeEntryPayload,
|
||
updateTimeEntry,
|
||
} from "../api/timeEntries";
|
||
import { getTags, type Tag } from "../api/tags";
|
||
import { Modal } from "../components/Modal";
|
||
import { InfiniteScroll } from "../components/InfiniteScroll";
|
||
import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timesheet/TimesheetFilterBar";
|
||
import JalaliDatePicker from "../components/ui/JalaliDatePicker";
|
||
import { Button } from "../components/ui/button";
|
||
import { Input } from "../components/ui/input";
|
||
import { Select } from "../components/ui/Select";
|
||
import { useWorkspace } from "../context/WorkspaceContext";
|
||
import { useTranslation } from "../hooks/useTranslation";
|
||
|
||
type EntryModalMode = "manual" | "edit" | null;
|
||
|
||
interface EntryFormState {
|
||
description: string;
|
||
projectId: string;
|
||
startDate: string;
|
||
startTime: string;
|
||
endDate: string;
|
||
endTime: string;
|
||
isBillable: boolean;
|
||
tags: string[];
|
||
}
|
||
|
||
interface TimerDraftState {
|
||
description: string;
|
||
projectId: string;
|
||
isBillable: boolean;
|
||
tags: string[];
|
||
}
|
||
|
||
const EMPTY_FORM: EntryFormState = {
|
||
description: "",
|
||
projectId: "",
|
||
startDate: "",
|
||
startTime: "",
|
||
endDate: "",
|
||
endTime: "",
|
||
isBillable: false,
|
||
tags: [],
|
||
};
|
||
|
||
const EMPTY_TIMER_DRAFT: TimerDraftState = {
|
||
description: "",
|
||
projectId: "",
|
||
isBillable: false,
|
||
tags: [],
|
||
};
|
||
|
||
const DEFAULT_ENTRY_FILTERS: TimeEntryFilters = {
|
||
projectId: "",
|
||
clientId: "",
|
||
tagIds: [],
|
||
startedAfter: "",
|
||
startedBefore: "",
|
||
};
|
||
|
||
const pad = (value: number) => String(value).padStart(2, "0");
|
||
|
||
const normalizeDigits = (value: string) =>
|
||
value
|
||
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
|
||
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)));
|
||
|
||
const parseApiDateTime = (value?: string | null) => {
|
||
if (!value) return null;
|
||
|
||
const normalized = normalizeDigits(String(value).trim());
|
||
const candidates = Array.from(new Set([normalized, normalized.replace(" ", "T")]));
|
||
|
||
for (const candidate of candidates) {
|
||
const parsed = new Date(candidate);
|
||
if (!Number.isNaN(parsed.getTime())) {
|
||
return parsed;
|
||
}
|
||
}
|
||
|
||
const match = normalized.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/);
|
||
if (!match) return null;
|
||
|
||
const [, year, month, day, hours, minutes, seconds] = match;
|
||
return new Date(
|
||
Number(year),
|
||
Number(month) - 1,
|
||
Number(day),
|
||
Number(hours),
|
||
Number(minutes),
|
||
Number(seconds || 0),
|
||
0,
|
||
);
|
||
};
|
||
|
||
const formatTimeInputValue = (value: string) => {
|
||
const digits = normalizeDigits(value).replace(/\D/g, "").slice(0, 6);
|
||
if (digits.length <= 2) return digits;
|
||
if (digits.length <= 4) return `${digits.slice(0, 2)}:${digits.slice(2)}`;
|
||
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}:${digits.slice(4)}`;
|
||
};
|
||
|
||
const getTimeCursorPosition = (digitCount: number) => {
|
||
if (digitCount <= 2) return digitCount;
|
||
if (digitCount <= 4) return digitCount + 1;
|
||
return Math.min(digitCount + 2, 8);
|
||
};
|
||
|
||
const handleFormattedTimeInputChange = (
|
||
event: React.ChangeEvent<HTMLInputElement>,
|
||
onChange: (value: string) => void,
|
||
) => {
|
||
const input = event.target;
|
||
const selectionStart = input.selectionStart ?? input.value.length;
|
||
const digitsBeforeCursor = normalizeDigits(input.value.slice(0, selectionStart)).replace(/\D/g, "").slice(0, 6);
|
||
const formattedValue = formatTimeInputValue(input.value);
|
||
const nextCursor = getTimeCursorPosition(digitsBeforeCursor.length);
|
||
|
||
onChange(formattedValue);
|
||
|
||
window.requestAnimationFrame(() => {
|
||
if (document.activeElement !== input) return;
|
||
input.setSelectionRange(nextCursor, nextCursor);
|
||
});
|
||
};
|
||
|
||
const isValidTimeValue = (value: string) => {
|
||
if (!/^\d{2}:\d{2}:\d{2}$/.test(value)) return false;
|
||
const [hours, minutes, seconds] = value.split(":").map(Number);
|
||
return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59 && seconds >= 0 && seconds <= 59;
|
||
};
|
||
|
||
const getLocalDateParts = (value?: string | null) => {
|
||
const parsed = parseApiDateTime(value);
|
||
if (!parsed) {
|
||
return {
|
||
date: "",
|
||
time: "",
|
||
};
|
||
}
|
||
|
||
return {
|
||
date: `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}`,
|
||
time: `${pad(parsed.getHours())}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`,
|
||
};
|
||
};
|
||
|
||
const combineDateAndTime = (dateValue: string, timeValue: string) => {
|
||
if (!dateValue || !isValidTimeValue(timeValue)) return null;
|
||
|
||
const [year, month, day] = dateValue.split("-").map(Number);
|
||
const [hours, minutes, seconds] = timeValue.split(":").map(Number);
|
||
|
||
return new Date(year, month - 1, day, hours, minutes, seconds, 0).toISOString();
|
||
};
|
||
|
||
const formatDateTime = (value: string, locale: "en" | "fa") => {
|
||
const parsed = parseApiDateTime(value);
|
||
if (!parsed) return value;
|
||
|
||
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
|
||
dateStyle: "medium",
|
||
timeStyle: "medium",
|
||
}).format(parsed);
|
||
};
|
||
|
||
const formatDuration = (entry: TimeEntry, now = Date.now()) => {
|
||
const start = parseApiDateTime(entry.start_time)?.getTime();
|
||
const end = entry.end_time ? parseApiDateTime(entry.end_time)?.getTime() : now;
|
||
|
||
if (!start || !end) return "00:00:00";
|
||
|
||
const totalSeconds = Math.max(0, Math.floor((end - start) / 1000));
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||
const seconds = totalSeconds % 60;
|
||
|
||
return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
|
||
};
|
||
|
||
const getEntryDurationMs = (entry: TimeEntry, now = Date.now()) => {
|
||
const start = parseApiDateTime(entry.start_time)?.getTime();
|
||
const end = entry.end_time ? parseApiDateTime(entry.end_time)?.getTime() : now;
|
||
if (!start || !end) return 0;
|
||
return Math.max(0, end - start);
|
||
};
|
||
|
||
const formatDurationMs = (durationMs: number) => {
|
||
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||
const seconds = totalSeconds % 60;
|
||
return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
|
||
};
|
||
|
||
const formatTimeOnly = (value?: string | null, locale: "en" | "fa" = "en") => {
|
||
const parsed = parseApiDateTime(value);
|
||
if (!parsed) return "--:--:--";
|
||
|
||
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
hour12: false,
|
||
}).format(parsed);
|
||
};
|
||
|
||
const getWeekStart = (date: Date) => {
|
||
const value = new Date(date);
|
||
value.setHours(0, 0, 0, 0);
|
||
value.setDate(value.getDate() - value.getDay());
|
||
return value;
|
||
};
|
||
|
||
const formatWeekRange = (date: Date, locale: "en" | "fa") => {
|
||
const start = getWeekStart(date);
|
||
const end = new Date(start);
|
||
end.setDate(start.getDate() + 6);
|
||
|
||
const formatter = new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
});
|
||
|
||
return `${formatter.format(start)} - ${formatter.format(end)}`;
|
||
};
|
||
|
||
const formatDayLabel = (date: Date, locale: "en" | "fa") =>
|
||
new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
|
||
weekday: "short",
|
||
month: "short",
|
||
day: "numeric",
|
||
}).format(date);
|
||
|
||
const mergeGroupedHistory = (currentGroups: TimeEntryGroupWeek[], nextGroups: TimeEntryGroupWeek[]) => {
|
||
const merged = currentGroups.map((week) => ({
|
||
...week,
|
||
days: week.days.map((day) => ({ ...day, entries: [...day.entries] })),
|
||
}));
|
||
|
||
nextGroups.forEach((incomingWeek) => {
|
||
const existingWeek = merged.find((week) => week.key === incomingWeek.key);
|
||
if (!existingWeek) {
|
||
merged.push({
|
||
...incomingWeek,
|
||
days: incomingWeek.days.map((day) => ({ ...day, entries: [...day.entries] })),
|
||
});
|
||
return;
|
||
}
|
||
|
||
existingWeek.total_ms = incomingWeek.total_ms;
|
||
|
||
incomingWeek.days.forEach((incomingDay) => {
|
||
const existingDay = existingWeek.days.find((day) => day.key === incomingDay.key);
|
||
if (!existingDay) {
|
||
existingWeek.days.push({ ...incomingDay, entries: [...incomingDay.entries] });
|
||
return;
|
||
}
|
||
|
||
existingDay.total_ms = incomingDay.total_ms;
|
||
const existingIds = new Set(existingDay.entries.map((entry) => entry.id));
|
||
incomingDay.entries.forEach((entry) => {
|
||
if (!existingIds.has(entry.id)) {
|
||
existingDay.entries.push(entry);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
return merged;
|
||
};
|
||
|
||
const updateGroupedHistoryEntry = (
|
||
groups: TimeEntryGroupWeek[],
|
||
updatedEntry: TimeEntry,
|
||
) => {
|
||
const filteredGroups = groups
|
||
.map((week) => ({
|
||
...week,
|
||
days: week.days
|
||
.map((day) => ({
|
||
...day,
|
||
entries: day.entries.filter((entry) => entry.id !== updatedEntry.id),
|
||
}))
|
||
.filter((day) => day.entries.length > 0),
|
||
}))
|
||
.filter((week) => week.days.length > 0);
|
||
|
||
if (!updatedEntry.end_time) {
|
||
return filteredGroups;
|
||
}
|
||
|
||
const start = parseApiDateTime(updatedEntry.start_time);
|
||
if (!start) {
|
||
return filteredGroups;
|
||
}
|
||
|
||
const weekStart = getWeekStart(start);
|
||
const weekKey = `${weekStart.getFullYear()}-${pad(weekStart.getMonth() + 1)}-${pad(weekStart.getDate())}`;
|
||
const dayKey = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}`;
|
||
const weekEnd = new Date(weekStart);
|
||
weekEnd.setDate(weekStart.getDate() + 6);
|
||
const merged = filteredGroups.map((week) => ({
|
||
...week,
|
||
days: week.days.map((day) => ({ ...day, entries: [...day.entries] })),
|
||
}));
|
||
|
||
let targetWeek = merged.find((week) => week.key === weekKey);
|
||
if (!targetWeek) {
|
||
targetWeek = {
|
||
key: weekKey,
|
||
week_start: `${weekStart.getFullYear()}-${pad(weekStart.getMonth() + 1)}-${pad(weekStart.getDate())}`,
|
||
week_end: `${weekEnd.getFullYear()}-${pad(weekEnd.getMonth() + 1)}-${pad(weekEnd.getDate())}`,
|
||
total_ms: 0,
|
||
days: [],
|
||
};
|
||
merged.push(targetWeek);
|
||
}
|
||
|
||
let targetDay = targetWeek.days.find((day) => day.key === dayKey);
|
||
if (!targetDay) {
|
||
targetDay = {
|
||
key: dayKey,
|
||
date: dayKey,
|
||
total_ms: 0,
|
||
entries: [],
|
||
};
|
||
targetWeek.days.push(targetDay);
|
||
}
|
||
|
||
targetDay.entries.unshift(updatedEntry);
|
||
targetDay.entries.sort((a, b) => {
|
||
const aTime = parseApiDateTime(a.start_time)?.getTime() || 0;
|
||
const bTime = parseApiDateTime(b.start_time)?.getTime() || 0;
|
||
return bTime - aTime;
|
||
});
|
||
targetDay.total_ms = targetDay.entries.reduce((sum, entry) => sum + getEntryDurationMs(entry), 0);
|
||
targetWeek.total_ms = targetWeek.days.reduce((sum, day) => sum + day.total_ms, 0);
|
||
|
||
merged.sort((a, b) => (a.week_start < b.week_start ? 1 : -1));
|
||
merged.forEach((week) => {
|
||
week.days.sort((a, b) => (a.date < b.date ? 1 : -1));
|
||
});
|
||
|
||
return merged;
|
||
};
|
||
|
||
const buildEntryFormState = (entry?: TimeEntry | null): EntryFormState => {
|
||
if (!entry) {
|
||
const now = getLocalDateParts(new Date().toISOString());
|
||
return {
|
||
...EMPTY_FORM,
|
||
startDate: now.date,
|
||
startTime: now.time,
|
||
};
|
||
}
|
||
|
||
const start = getLocalDateParts(entry.start_time);
|
||
const end = getLocalDateParts(entry.end_time);
|
||
|
||
return {
|
||
description: entry.description || "",
|
||
projectId: entry.project || "",
|
||
startDate: start.date,
|
||
startTime: start.time,
|
||
endDate: end.date,
|
||
endTime: end.time,
|
||
isBillable: entry.is_billable,
|
||
tags: entry.tags || [],
|
||
};
|
||
};
|
||
|
||
const buildTimerDraftState = (entry?: TimeEntry | null): TimerDraftState => ({
|
||
description: entry?.description || "",
|
||
projectId: entry?.project || "",
|
||
isBillable: entry?.is_billable || false,
|
||
tags: entry?.tags || [],
|
||
});
|
||
|
||
const serializeTimerDraft = (state: TimerDraftState) =>
|
||
JSON.stringify({
|
||
description: state.description.trim(),
|
||
projectId: state.projectId || "",
|
||
isBillable: state.isBillable,
|
||
tags: [...state.tags].sort(),
|
||
});
|
||
|
||
const serializeEntryDraft = (state: EntryFormState) =>
|
||
JSON.stringify({
|
||
description: state.description.trim(),
|
||
projectId: state.projectId || "",
|
||
startDate: state.startDate || "",
|
||
startTime: state.startTime || "",
|
||
endDate: state.endDate || "",
|
||
endTime: state.endTime || "",
|
||
isBillable: state.isBillable,
|
||
tags: [...state.tags].sort(),
|
||
});
|
||
|
||
const toggleTagId = (currentTags: string[], tagId: string) =>
|
||
currentTags.includes(tagId) ? currentTags.filter((currentId) => currentId !== tagId) : [...currentTags, tagId];
|
||
|
||
const buildPayloadFromState = (
|
||
state: EntryFormState,
|
||
options: { includeWorkspace: boolean; workspaceId?: string },
|
||
): { payload?: TimeEntryPayload; error?: string } => {
|
||
const startDateTime = combineDateAndTime(state.startDate, state.startTime);
|
||
if (!startDateTime) {
|
||
return { error: "Start date and time are required." };
|
||
}
|
||
|
||
let endDateTime: string | null = null;
|
||
const hasEndValue = Boolean(state.endDate || state.endTime);
|
||
if (hasEndValue) {
|
||
if (!state.endDate || !state.endTime) {
|
||
return { error: "End date and time must both be filled." };
|
||
}
|
||
|
||
endDateTime = combineDateAndTime(state.endDate, state.endTime);
|
||
if (!endDateTime) {
|
||
return { error: "End time is invalid." };
|
||
}
|
||
}
|
||
|
||
const payload: TimeEntryPayload = {
|
||
description: state.description.trim(),
|
||
project_id: state.projectId || null,
|
||
start_time: startDateTime,
|
||
end_time: endDateTime,
|
||
tags: state.tags,
|
||
is_billable: state.isBillable,
|
||
};
|
||
|
||
if (options.includeWorkspace && options.workspaceId) {
|
||
payload.workspace_id = options.workspaceId;
|
||
}
|
||
|
||
return { payload };
|
||
};
|
||
|
||
function TimeField({
|
||
label,
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
compact = false,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
placeholder?: string;
|
||
compact?: boolean;
|
||
}) {
|
||
return (
|
||
<div className={compact ? "min-w-[104px]" : ""}>
|
||
<label className={`block text-sm font-medium text-slate-700 dark:text-slate-300 ${compact ? "mb-0.5 text-[11px]" : "mb-1"}`}>{label}</label>
|
||
<Input
|
||
type="text"
|
||
inputMode="numeric"
|
||
dir="ltr"
|
||
value={value}
|
||
maxLength={8}
|
||
placeholder={placeholder || "HH:MM:SS"}
|
||
className={compact ? "h-9 px-2 text-xs" : ""}
|
||
onChange={(event) => handleFormattedTimeInputChange(event, onChange)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BillableIconButton({
|
||
checked,
|
||
onChange,
|
||
label,
|
||
disabled = false,
|
||
compact = false,
|
||
}: {
|
||
checked: boolean;
|
||
onChange: (checked: boolean) => void;
|
||
label: string;
|
||
disabled?: boolean;
|
||
compact?: boolean;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
aria-pressed={checked}
|
||
aria-label={label}
|
||
title={label}
|
||
disabled={disabled}
|
||
onClick={() => onChange(!checked)}
|
||
className={`inline-flex items-center justify-center transition-colors ${
|
||
checked
|
||
? "text-sky-600 dark:text-sky-400"
|
||
: "text-slate-400 dark:text-slate-500"
|
||
} ${compact ? "h-12 w-10 border-s border-slate-200 bg-transparent hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-900/70" : "gap-2 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900"} disabled:cursor-not-allowed disabled:opacity-60`}
|
||
>
|
||
<DollarSign className="h-4 w-4" />
|
||
{!compact && <span className="font-medium">{label}</span>}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function TagMultiSelect({
|
||
tags,
|
||
selectedTags,
|
||
onToggleTag,
|
||
emptyHint,
|
||
title,
|
||
compact = false,
|
||
portalOwnerId,
|
||
}: {
|
||
tags: Tag[];
|
||
selectedTags: string[];
|
||
onToggleTag: (tagId: string) => void;
|
||
emptyHint: string;
|
||
title: string;
|
||
compact?: boolean;
|
||
portalOwnerId?: string;
|
||
}) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
const target = event.target as Node;
|
||
const clickedInsideTrigger = wrapperRef.current?.contains(target);
|
||
const clickedInsideDropdown = dropdownRef.current?.contains(target);
|
||
|
||
if (!clickedInsideTrigger && !clickedInsideDropdown) {
|
||
setIsOpen(false);
|
||
}
|
||
};
|
||
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [isOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen || !buttonRef.current) return;
|
||
|
||
const rect = buttonRef.current.getBoundingClientRect();
|
||
const dropdownWidth = compact ? 256 : Math.max(rect.width, 256);
|
||
const spaceBelow = window.innerHeight - rect.bottom;
|
||
const openUpward = spaceBelow < 280 && rect.top > spaceBelow;
|
||
|
||
setDropdownStyle({
|
||
position: "fixed",
|
||
top: openUpward ? `${rect.top - 8}px` : `${rect.bottom + 8}px`,
|
||
left: `${Math.max(12, rect.right - dropdownWidth)}px`,
|
||
width: `${dropdownWidth}px`,
|
||
transform: openUpward ? "translateY(-100%)" : "none",
|
||
zIndex: 100000,
|
||
});
|
||
}, [compact, isOpen]);
|
||
|
||
useEffect(() => {
|
||
const closeOnViewportChange = () => setIsOpen(false);
|
||
|
||
if (isOpen) {
|
||
window.addEventListener("resize", closeOnViewportChange);
|
||
window.addEventListener("scroll", closeOnViewportChange, true);
|
||
}
|
||
|
||
return () => {
|
||
window.removeEventListener("resize", closeOnViewportChange);
|
||
window.removeEventListener("scroll", closeOnViewportChange, true);
|
||
};
|
||
}, [isOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
setSearchQuery("");
|
||
}
|
||
}, [isOpen]);
|
||
|
||
const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name);
|
||
const joinedSelectedLabels = selectedLabels.join(" | ");
|
||
const normalizedSearch = searchQuery.trim().toLowerCase();
|
||
const filteredTags = normalizedSearch
|
||
? tags.filter((tag) => tag.name.toLowerCase().includes(normalizedSearch))
|
||
: tags;
|
||
const buttonLabel = compact
|
||
? selectedTags.length > 0
|
||
? joinedSelectedLabels
|
||
: ""
|
||
: selectedLabels.length > 0
|
||
? selectedLabels.join(", ")
|
||
: title;
|
||
|
||
return (
|
||
<div ref={wrapperRef} className={compact ? "relative w-fit" : "relative"}>
|
||
{!compact && <p className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">{title}</p>}
|
||
<button
|
||
ref={buttonRef}
|
||
type="button"
|
||
onClick={() => setIsOpen((current) => !current)}
|
||
title={selectedLabels.length > 0 ? selectedLabels.join(", ") : title}
|
||
className={`inline-flex items-center gap-1 text-slate-700 dark:text-slate-200 ${
|
||
compact
|
||
? "h-12 w-fit border-0 bg-transparent px-2 text-xs"
|
||
: "w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||
}`}
|
||
>
|
||
{compact ? (
|
||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||
{buttonLabel ? (
|
||
<span className="truncate rounded-sm bg-sky-50 px-2 py-1 text-[11px] font-medium text-sky-700 dark:bg-sky-500/15 dark:text-sky-300">
|
||
{buttonLabel}
|
||
</span>
|
||
) : (
|
||
<TagIcon className="h-4 w-4 text-slate-400 dark:text-slate-500" />
|
||
)}
|
||
</span>
|
||
) : (
|
||
<span className="truncate">{buttonLabel}</span>
|
||
)}
|
||
{!compact && <ChevronDown className={`h-4 w-4 shrink-0 transition-transform ${isOpen ? "rotate-180" : ""}`} />}
|
||
</button>
|
||
|
||
{isOpen && (
|
||
createPortal(
|
||
<div
|
||
ref={dropdownRef}
|
||
style={dropdownStyle}
|
||
data-entry-editor-owner={portalOwnerId}
|
||
className="rounded-2xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-800"
|
||
>
|
||
{tags.length === 0 ? (
|
||
<p className="px-2 py-2 text-sm text-slate-500 dark:text-slate-400">{emptyHint}</p>
|
||
) : (
|
||
<>
|
||
<div className="border-b border-slate-200 p-2 dark:border-slate-700">
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(event) => setSearchQuery(event.target.value)}
|
||
placeholder="Search tags..."
|
||
className="h-8 w-full rounded-md border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="max-h-72 space-y-1 overflow-y-auto p-2">
|
||
{filteredTags.map((tag) => {
|
||
const selected = selectedTags.includes(tag.id);
|
||
return (
|
||
<button
|
||
key={tag.id}
|
||
type="button"
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => onToggleTag(tag.id)}
|
||
className={`flex w-full items-center justify-between rounded-xl px-2 py-2 text-sm transition-colors ${
|
||
selected ? "bg-slate-100 text-slate-900 dark:bg-slate-700 dark:text-white" : "text-slate-700 dark:text-slate-200"
|
||
}`}
|
||
>
|
||
<span className="inline-flex min-w-0 items-center gap-2 truncate">
|
||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: tag.color || "#94A3B8" }} />
|
||
<span className="truncate">{tag.name}</span>
|
||
</span>
|
||
{selected && <Check className="h-4 w-4 shrink-0" />}
|
||
</button>
|
||
);
|
||
})}
|
||
{filteredTags.length === 0 && (
|
||
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
|
||
No tags found.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>,
|
||
document.body
|
||
)
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProjectInlineSelect({
|
||
projects,
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
portalOwnerId,
|
||
className = "",
|
||
dropdownClassName = "",
|
||
disabled = false,
|
||
}: {
|
||
projects: Project[];
|
||
value: string;
|
||
onChange: (projectId: string) => void;
|
||
placeholder: string;
|
||
portalOwnerId?: string;
|
||
className?: string;
|
||
dropdownClassName?: string;
|
||
disabled?: boolean;
|
||
}) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
const target = event.target as Node;
|
||
const clickedInsideTrigger = wrapperRef.current?.contains(target);
|
||
const clickedInsideDropdown = dropdownRef.current?.contains(target);
|
||
|
||
if (!clickedInsideTrigger && !clickedInsideDropdown) {
|
||
setIsOpen(false);
|
||
}
|
||
};
|
||
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [isOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen || !buttonRef.current) return;
|
||
|
||
const rect = buttonRef.current.getBoundingClientRect();
|
||
const dropdownWidth = 220;
|
||
const spaceBelow = window.innerHeight - rect.bottom;
|
||
const openUpward = spaceBelow < 280 && rect.top > spaceBelow;
|
||
|
||
setDropdownStyle({
|
||
position: "fixed",
|
||
top: openUpward ? `${rect.top - 8}px` : `${rect.bottom + 8}px`,
|
||
left: `${Math.max(12, rect.left)}px`,
|
||
width: `${dropdownWidth}px`,
|
||
transform: openUpward ? "translateY(-100%)" : "none",
|
||
zIndex: 100000,
|
||
});
|
||
}, [isOpen]);
|
||
|
||
useEffect(() => {
|
||
const closeOnViewportChange = () => setIsOpen(false);
|
||
|
||
if (isOpen) {
|
||
window.addEventListener("resize", closeOnViewportChange);
|
||
window.addEventListener("scroll", closeOnViewportChange, true);
|
||
}
|
||
|
||
return () => {
|
||
window.removeEventListener("resize", closeOnViewportChange);
|
||
window.removeEventListener("scroll", closeOnViewportChange, true);
|
||
};
|
||
}, [isOpen]);
|
||
|
||
const selectedProject = projects.find((project) => project.id === value);
|
||
const label = selectedProject?.name || placeholder;
|
||
|
||
return (
|
||
<div ref={wrapperRef} className={`relative shrink-0 ${className}`}>
|
||
<button
|
||
ref={buttonRef}
|
||
type="button"
|
||
onClick={() => !disabled && setIsOpen((current) => !current)}
|
||
disabled={disabled}
|
||
className={`inline-flex max-w-full items-center rounded-sm bg-transparent py-0 text-sm transition-colors ${
|
||
selectedProject
|
||
? "text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
|
||
: "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||
} disabled:cursor-not-allowed disabled:opacity-60`}
|
||
title={label}
|
||
>
|
||
<span className="truncate">{label}</span>
|
||
</button>
|
||
|
||
{isOpen && !disabled &&
|
||
createPortal(
|
||
<div
|
||
ref={dropdownRef}
|
||
style={dropdownStyle}
|
||
data-entry-editor-owner={portalOwnerId}
|
||
className={`rounded-2xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-800 ${dropdownClassName}`}
|
||
>
|
||
<div className="max-h-64 space-y-1 overflow-y-auto">
|
||
<button
|
||
type="button"
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => {
|
||
onChange("");
|
||
setIsOpen(false);
|
||
}}
|
||
className={`flex w-full items-center rounded-xl px-3 py-2 text-sm transition-colors ${
|
||
value === ""
|
||
? "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
|
||
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700/70"
|
||
}`}
|
||
>
|
||
{placeholder}
|
||
</button>
|
||
|
||
{projects.map((project) => {
|
||
const selected = project.id === value;
|
||
return (
|
||
<button
|
||
key={project.id}
|
||
type="button"
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => {
|
||
onChange(project.id);
|
||
setIsOpen(false);
|
||
}}
|
||
className={`flex w-full items-center rounded-xl px-3 py-2 text-sm transition-colors ${
|
||
selected
|
||
? "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
|
||
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700/70"
|
||
}`}
|
||
>
|
||
<span className="truncate">{project.name}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CompactDateTimeField({
|
||
label,
|
||
dateValue,
|
||
timeValue,
|
||
onDateChange,
|
||
onTimeChange,
|
||
}: {
|
||
label: string;
|
||
dateValue: string;
|
||
timeValue: string;
|
||
onDateChange: (value: string) => void;
|
||
onTimeChange: (value: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="min-w-[208px]">
|
||
<label className="mb-0.5 block text-[11px] font-medium text-slate-700 dark:text-slate-300">{label}</label>
|
||
<div className="flex h-9 items-center overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
|
||
<div className="min-w-0 flex-1">
|
||
<JalaliDatePicker
|
||
value={dateValue}
|
||
onChange={onDateChange}
|
||
inputClassName="h-9 rounded-none border-0 bg-transparent px-2 text-xs shadow-none focus:ring-0 focus:outline-none"
|
||
/>
|
||
</div>
|
||
<div className="h-5 w-px bg-slate-200 dark:bg-slate-700" />
|
||
<Input
|
||
type="text"
|
||
inputMode="numeric"
|
||
dir="ltr"
|
||
value={timeValue}
|
||
maxLength={8}
|
||
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"
|
||
onChange={(event) => onTimeChange(formatTimeInputValue(event.target.value))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function InlineTimeRangeField({
|
||
startTime,
|
||
endTime,
|
||
onStartTimeChange,
|
||
onEndTimeChange,
|
||
}: {
|
||
startTime: string;
|
||
endTime: string;
|
||
onStartTimeChange: (value: string) => void;
|
||
onEndTimeChange: (value: string) => void;
|
||
}) {
|
||
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">
|
||
<Input
|
||
type="text"
|
||
inputMode="numeric"
|
||
dir="ltr"
|
||
value={startTime}
|
||
maxLength={8}
|
||
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"
|
||
onChange={(event) => handleFormattedTimeInputChange(event, onStartTimeChange)}
|
||
/>
|
||
<span className="px-1 text-slate-400">-</span>
|
||
<Input
|
||
type="text"
|
||
inputMode="numeric"
|
||
dir="ltr"
|
||
value={endTime}
|
||
maxLength={8}
|
||
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"
|
||
onChange={(event) => handleFormattedTimeInputChange(event, onEndTimeChange)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DateRangePopover({
|
||
startDate,
|
||
endDate,
|
||
onStartDateChange,
|
||
onEndDateChange,
|
||
portalOwnerId,
|
||
}: {
|
||
startDate: string;
|
||
endDate: string;
|
||
onStartDateChange: (value: string) => void;
|
||
onEndDateChange: (value: string) => void;
|
||
portalOwnerId?: string;
|
||
}) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
const target = event.target as Node;
|
||
const clickedInsideTrigger = wrapperRef.current?.contains(target);
|
||
const clickedInsideDropdown = dropdownRef.current?.contains(target);
|
||
|
||
if (!clickedInsideTrigger && !clickedInsideDropdown) {
|
||
setIsOpen(false);
|
||
}
|
||
};
|
||
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [isOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen || !buttonRef.current) return;
|
||
|
||
const rect = buttonRef.current.getBoundingClientRect();
|
||
const dropdownWidth = 280;
|
||
const spaceBelow = window.innerHeight - rect.bottom;
|
||
const openUpward = spaceBelow < 240 && rect.top > spaceBelow;
|
||
|
||
setDropdownStyle({
|
||
position: "fixed",
|
||
top: openUpward ? `${rect.top - 8}px` : `${rect.bottom + 8}px`,
|
||
left: `${Math.max(12, rect.right - dropdownWidth)}px`,
|
||
width: `${dropdownWidth}px`,
|
||
transform: openUpward ? "translateY(-100%)" : "none",
|
||
zIndex: 100000,
|
||
});
|
||
}, [isOpen]);
|
||
|
||
return (
|
||
<div ref={wrapperRef} className="relative">
|
||
<button
|
||
ref={buttonRef}
|
||
type="button"
|
||
onClick={() => setIsOpen((current) => !current)}
|
||
className="inline-flex h-12 w-10 items-center justify-center border-s border-slate-200 bg-transparent text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700 dark:border-slate-800 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white"
|
||
title="Edit dates"
|
||
>
|
||
<CalendarDays className="h-4 w-4" />
|
||
</button>
|
||
|
||
{isOpen &&
|
||
createPortal(
|
||
<div
|
||
ref={dropdownRef}
|
||
style={dropdownStyle}
|
||
data-entry-editor-owner={portalOwnerId}
|
||
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">
|
||
<JalaliDatePicker label="Start date" value={startDate} onChange={onStartDateChange} />
|
||
<JalaliDatePicker label="End date" value={endDate} onChange={onEndDateChange} />
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DeleteEntryButton({
|
||
onDelete,
|
||
}: {
|
||
onDelete: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onDelete}
|
||
className="inline-flex h-12 w-10 items-center justify-center rounded-none border-0 bg-transparent text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:text-slate-500 dark:hover:bg-red-500/10 dark:hover:text-red-400"
|
||
title="Delete"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function EntryEditorFields({
|
||
state,
|
||
onChange,
|
||
onToggleTag,
|
||
onProjectChange,
|
||
projects,
|
||
tags,
|
||
t,
|
||
isRtl,
|
||
compact = false,
|
||
portalOwnerId,
|
||
}: {
|
||
state: EntryFormState;
|
||
onChange: (patch: Partial<EntryFormState>) => void;
|
||
onToggleTag: (tagId: string) => void;
|
||
onProjectChange?: (projectId: string) => void;
|
||
projects: Project[];
|
||
tags: Tag[];
|
||
t: any;
|
||
isRtl: boolean;
|
||
compact?: boolean;
|
||
portalOwnerId?: string;
|
||
}) {
|
||
if (compact) {
|
||
const selectedProject = projects.find((project) => project.id === state.projectId);
|
||
return (
|
||
<div className="grid min-w-0 flex-1 grid-cols-[minmax(420px,1fr)_40px_188px_40px] items-center">
|
||
<div className="flex min-w-0 items-center">
|
||
<Input
|
||
value={state.description}
|
||
onChange={(event) => onChange({ description: event.target.value })}
|
||
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||
className="h-12 w-[220px] shrink-0 rounded-none border-0 bg-transparent px-0 pe-3 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 truncate dark:bg-transparent dark:text-slate-100"
|
||
/>
|
||
|
||
<span className="me-2 shrink-0 text-sm font-semibold leading-none text-sky-600 dark:text-sky-400">•</span>
|
||
|
||
<ProjectInlineSelect
|
||
projects={projects}
|
||
value={state.projectId}
|
||
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))}
|
||
placeholder={t.timesheet?.projectLabel || "Project"}
|
||
portalOwnerId={portalOwnerId}
|
||
className="max-w-[180px]"
|
||
/>
|
||
|
||
{selectedProject && (
|
||
<span className="ms-2 shrink-0 truncate text-sm text-slate-400 dark:text-slate-500">
|
||
- {selectedProject.client?.name || ""}
|
||
</span>
|
||
)}
|
||
|
||
<div className="min-w-[24px] flex-1" />
|
||
|
||
<div className="shrink-0">
|
||
<TagMultiSelect
|
||
tags={tags}
|
||
selectedTags={state.tags}
|
||
onToggleTag={onToggleTag}
|
||
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
||
title={t.tags?.title || "Tags"}
|
||
compact
|
||
portalOwnerId={portalOwnerId}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="w-10">
|
||
<BillableIconButton
|
||
checked={state.isBillable}
|
||
onChange={(checked) => onChange({ isBillable: checked })}
|
||
label={t.timesheet?.billable || "Billable"}
|
||
compact
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<InlineTimeRangeField
|
||
startTime={state.startTime}
|
||
endTime={state.endTime}
|
||
onStartTimeChange={(value) => onChange({ startTime: value })}
|
||
onEndTimeChange={(value) => onChange({ endTime: value })}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<DateRangePopover
|
||
startDate={state.startDate}
|
||
endDate={state.endDate}
|
||
onStartDateChange={(value) => onChange({ startDate: value })}
|
||
onEndDateChange={(value) => onChange({ endDate: value })}
|
||
portalOwnerId={portalOwnerId}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<div>
|
||
<label className={`block font-medium text-slate-700 dark:text-slate-300 ${compact ? "mb-0.5 text-[11px]" : "mb-1 text-sm"}`}>
|
||
{t.timesheet?.descriptionLabel || "Description"}
|
||
</label>
|
||
<Input
|
||
value={state.description}
|
||
onChange={(event) => onChange({ description: event.target.value })}
|
||
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||
className={compact ? "h-9 px-2 text-xs" : ""}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className={`block font-medium text-slate-700 dark:text-slate-300 ${compact ? "mb-0.5 text-[11px]" : "mb-1 text-sm"}`}>
|
||
{t.timesheet?.projectLabel || "Project"}
|
||
</label>
|
||
<Select
|
||
value={state.projectId}
|
||
onChange={(value) => onChange({ projectId: String(value) })}
|
||
options={[
|
||
{ value: "", label: t.timesheet?.noProject || "No project" },
|
||
...projects.map((project) => ({ value: project.id, label: project.name })),
|
||
]}
|
||
className="w-full"
|
||
buttonClassName={compact ? "w-full h-9 px-2 text-xs" : "w-full"}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className={compact ? "" : "space-y-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60"}>
|
||
<JalaliDatePicker
|
||
label={t.timesheet?.startLabel || "Start"}
|
||
value={state.startDate}
|
||
onChange={(date) => onChange({ startDate: date })}
|
||
inputClassName={compact ? "h-9 px-2 text-xs" : undefined}
|
||
/>
|
||
<TimeField
|
||
label={t.timesheet?.timeLabel || "Time"}
|
||
value={state.startTime}
|
||
onChange={(value) => onChange({ startTime: value })}
|
||
compact={compact}
|
||
/>
|
||
</div>
|
||
|
||
<div className={compact ? "" : "space-y-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60"}>
|
||
<JalaliDatePicker
|
||
label={t.timesheet?.endLabel || "End"}
|
||
value={state.endDate}
|
||
onChange={(date) => onChange({ endDate: date })}
|
||
inputClassName={compact ? "h-9 px-2 text-xs" : undefined}
|
||
/>
|
||
<TimeField
|
||
label={t.timesheet?.timeLabel || "Time"}
|
||
value={state.endTime}
|
||
onChange={(value) => onChange({ endTime: value })}
|
||
compact={compact}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<BillableIconButton
|
||
checked={state.isBillable}
|
||
onChange={(checked) => onChange({ isBillable: checked })}
|
||
label={t.timesheet?.billable || "Billable"}
|
||
/>
|
||
</div>
|
||
|
||
<TagMultiSelect
|
||
tags={tags}
|
||
selectedTags={state.tags}
|
||
onToggleTag={onToggleTag}
|
||
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
||
title={t.tags?.title || "Tags"}
|
||
compact={compact}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RecordedEntryCard({
|
||
entry,
|
||
t,
|
||
projects,
|
||
tags,
|
||
onDelete,
|
||
onRestart,
|
||
onEntryUpdated,
|
||
}: {
|
||
entry: TimeEntry;
|
||
t: any;
|
||
projects: Project[];
|
||
tags: Tag[];
|
||
onDelete: (entry: TimeEntry) => void;
|
||
onRestart: (entry: TimeEntry) => void;
|
||
onEntryUpdated: (entry: TimeEntry) => void;
|
||
}) {
|
||
const [draft, setDraft] = useState<EntryFormState>(() => buildEntryFormState(entry));
|
||
const [validationMessage, setValidationMessage] = useState("");
|
||
const syncedSignatureRef = useRef(serializeEntryDraft(buildEntryFormState(entry)));
|
||
const rowRef = useRef<HTMLDivElement>(null);
|
||
const isSavingRef = useRef(false);
|
||
const pendingSignatureRef = useRef<string | null>(null);
|
||
const editorOwnerId = `time-entry-editor-${entry.id}`;
|
||
const timesheetCopy = (t.timesheet as { saveError?: string; saveSuccess?: string }) || {};
|
||
const saveErrorText = timesheetCopy.saveError || "Failed to save time entry";
|
||
const saveSuccessText = timesheetCopy.saveSuccess || "Time entry saved";
|
||
|
||
useEffect(() => {
|
||
const nextDraft = buildEntryFormState(entry);
|
||
const nextSignature = serializeEntryDraft(nextDraft);
|
||
syncedSignatureRef.current = nextSignature;
|
||
pendingSignatureRef.current = nextSignature;
|
||
setDraft(nextDraft);
|
||
setValidationMessage("");
|
||
}, [entry]);
|
||
|
||
const isInsideEditorContext = useCallback((target: EventTarget | null) => {
|
||
if (!(target instanceof Node)) return false;
|
||
if (rowRef.current?.contains(target)) return true;
|
||
return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${editorOwnerId}"]`));
|
||
}, [editorOwnerId]);
|
||
|
||
const commitDraft = useCallback(async () => {
|
||
const currentSignature = serializeEntryDraft(draft);
|
||
if (currentSignature === syncedSignatureRef.current) {
|
||
setValidationMessage("");
|
||
return false;
|
||
}
|
||
|
||
if (isSavingRef.current || pendingSignatureRef.current === currentSignature) {
|
||
return false;
|
||
}
|
||
|
||
const { payload, error } = buildPayloadFromState(draft, { includeWorkspace: false });
|
||
if (!payload) {
|
||
setValidationMessage(error || "");
|
||
return false;
|
||
}
|
||
|
||
setValidationMessage("");
|
||
isSavingRef.current = true;
|
||
pendingSignatureRef.current = currentSignature;
|
||
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]);
|
||
|
||
const commitPatchedDraft = useCallback(async (patch: Partial<EntryFormState>) => {
|
||
const nextDraft = { ...draft, ...patch };
|
||
const nextSignature = serializeEntryDraft(nextDraft);
|
||
setDraft(nextDraft);
|
||
|
||
if (nextSignature === syncedSignatureRef.current) {
|
||
setValidationMessage("");
|
||
return false;
|
||
}
|
||
|
||
if (isSavingRef.current || pendingSignatureRef.current === nextSignature) {
|
||
return false;
|
||
}
|
||
|
||
const { payload, error } = buildPayloadFromState(nextDraft, { includeWorkspace: false });
|
||
if (!payload) {
|
||
setValidationMessage(error || "");
|
||
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(() => {
|
||
const handlePointerDown = (event: MouseEvent) => {
|
||
if (isInsideEditorContext(event.target)) return;
|
||
void commitDraft();
|
||
};
|
||
|
||
document.addEventListener("mousedown", handlePointerDown);
|
||
return () => {
|
||
document.removeEventListener("mousedown", handlePointerDown);
|
||
};
|
||
}, [commitDraft, isInsideEditorContext]);
|
||
|
||
const handleBlurCapture = () => {
|
||
window.setTimeout(() => {
|
||
if (isInsideEditorContext(document.activeElement)) return;
|
||
void commitDraft();
|
||
}, 0);
|
||
};
|
||
|
||
return (
|
||
<div ref={rowRef} onBlurCapture={handleBlurCapture} className="border-b border-slate-200 bg-white px-4 py-4 dark:border-slate-800 dark:bg-slate-950">
|
||
<div className="flex min-w-[1040px] items-center">
|
||
<EntryEditorFields
|
||
state={draft}
|
||
onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
|
||
onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
||
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
|
||
projects={projects}
|
||
tags={tags}
|
||
t={t}
|
||
isRtl={false}
|
||
compact
|
||
portalOwnerId={editorOwnerId}
|
||
/>
|
||
|
||
<div className="flex h-12 shrink-0 items-center border-s border-slate-200 px-5 text-sm font-semibold text-slate-700 dark:border-slate-800 dark:text-slate-200">
|
||
{formatDuration(entry)}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => onRestart(entry)}
|
||
className="inline-flex h-12 w-10 shrink-0 items-center justify-center border-s border-slate-200 bg-transparent text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700 dark:border-slate-800 dark:text-slate-500 dark:hover:bg-slate-900 dark:hover:text-white"
|
||
title="Start from this entry"
|
||
>
|
||
<Play className="h-4 w-4" />
|
||
</button>
|
||
|
||
<div className="border-s border-slate-200 dark:border-slate-800">
|
||
<DeleteEntryButton onDelete={() => onDelete(entry)} />
|
||
</div>
|
||
</div>
|
||
|
||
{validationMessage && (
|
||
<div className="px-1 pb-3 pt-2">
|
||
<p className="text-xs font-medium text-amber-600 dark:text-amber-400">{validationMessage}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MobileRecordedEntryCard({
|
||
entry,
|
||
t,
|
||
projects,
|
||
tags,
|
||
onEdit,
|
||
onDelete,
|
||
onRequestRestart,
|
||
}: {
|
||
entry: TimeEntry;
|
||
t: any;
|
||
projects: Project[];
|
||
tags: Tag[];
|
||
onEdit: (entry: TimeEntry) => void;
|
||
onDelete: (entry: TimeEntry) => void;
|
||
onRequestRestart: (entry: TimeEntry) => void;
|
||
}) {
|
||
const project = projects.find((item) => item.id === entry.project);
|
||
const entryTags = tags.filter((tag) => entry.tags.includes(tag.id));
|
||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||
const touchStartXRef = useRef<number | null>(null);
|
||
const [menuOpen, setMenuOpen] = useState(false);
|
||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({});
|
||
|
||
useEffect(() => {
|
||
if (!menuOpen) return;
|
||
|
||
const handlePointerDown = (event: MouseEvent) => {
|
||
if (
|
||
wrapperRef.current?.contains(event.target as Node) ||
|
||
dropdownRef.current?.contains(event.target as Node)
|
||
) {
|
||
return;
|
||
}
|
||
setMenuOpen(false);
|
||
};
|
||
|
||
document.addEventListener("mousedown", handlePointerDown);
|
||
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||
}, [menuOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!menuOpen || !buttonRef.current) return;
|
||
|
||
const rect = buttonRef.current.getBoundingClientRect();
|
||
const dropdownWidth = 168;
|
||
const spaceBelow = window.innerHeight - rect.bottom;
|
||
const openUpward = spaceBelow < 180 && rect.top > spaceBelow;
|
||
|
||
setMenuStyle({
|
||
position: "fixed",
|
||
top: openUpward ? `${rect.top - 8}px` : `${rect.bottom + 8}px`,
|
||
left: `${Math.max(12, rect.right - dropdownWidth)}px`,
|
||
width: `${dropdownWidth}px`,
|
||
transform: openUpward ? "translateY(-100%)" : "none",
|
||
zIndex: 100000,
|
||
});
|
||
}, [menuOpen]);
|
||
|
||
useEffect(() => {
|
||
const closeMenu = () => setMenuOpen(false);
|
||
|
||
if (menuOpen) {
|
||
window.addEventListener("resize", closeMenu);
|
||
window.addEventListener("scroll", closeMenu, true);
|
||
}
|
||
|
||
return () => {
|
||
window.removeEventListener("resize", closeMenu);
|
||
window.removeEventListener("scroll", closeMenu, true);
|
||
};
|
||
}, [menuOpen]);
|
||
|
||
const closeSwipe = () => {
|
||
touchStartXRef.current = null;
|
||
setSwipeOffset(0);
|
||
};
|
||
|
||
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
|
||
if (menuOpen) {
|
||
setMenuOpen(false);
|
||
}
|
||
touchStartXRef.current = event.touches[0]?.clientX ?? null;
|
||
};
|
||
|
||
const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => {
|
||
if (touchStartXRef.current === null) return;
|
||
const delta = (event.touches[0]?.clientX ?? 0) - touchStartXRef.current;
|
||
setSwipeOffset(Math.max(-88, Math.min(88, delta)));
|
||
};
|
||
|
||
const handleTouchEnd = () => {
|
||
if (swipeOffset <= -72) {
|
||
closeSwipe();
|
||
onDelete(entry);
|
||
return;
|
||
}
|
||
|
||
if (swipeOffset >= 72) {
|
||
closeSwipe();
|
||
onRequestRestart(entry);
|
||
return;
|
||
}
|
||
|
||
closeSwipe();
|
||
};
|
||
|
||
return (
|
||
<div ref={wrapperRef} className="relative overflow-hidden border-b border-slate-200 bg-slate-100/70 dark:border-slate-800 dark:bg-slate-900/70 md:hidden">
|
||
<div className="pointer-events-none absolute inset-y-0 left-0 flex w-24 items-center justify-start bg-emerald-500/12 ps-4 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
|
||
<Play className="h-4 w-4" />
|
||
</div>
|
||
<div className="pointer-events-none absolute inset-y-0 right-0 flex w-24 items-center justify-end bg-red-500/12 pe-4 text-red-700 dark:bg-red-500/10 dark:text-red-300">
|
||
<Trash2 className="h-4 w-4" />
|
||
</div>
|
||
|
||
<div
|
||
className="relative bg-white px-4 py-5 transition-transform duration-150 ease-out dark:bg-slate-950"
|
||
style={{ transform: `translateX(${swipeOffset}px)` }}
|
||
onTouchStart={handleTouchStart}
|
||
onTouchMove={handleTouchMove}
|
||
onTouchEnd={handleTouchEnd}
|
||
onTouchCancel={closeSwipe}
|
||
>
|
||
<div className="absolute end-3 top-3">
|
||
<button
|
||
ref={buttonRef}
|
||
type="button"
|
||
onClick={() => setMenuOpen((current) => !current)}
|
||
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-100"
|
||
title={t.actions?.more || "More"}
|
||
>
|
||
<MoreVertical className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="pe-10">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||
<p className="max-w-[14rem] truncate text-sm font-medium text-slate-800 dark:text-slate-100">
|
||
{entry.description || t.timesheet?.emptyDescription || "No description"}
|
||
</p>
|
||
{project && (
|
||
<span className="inline-flex min-w-0 items-center gap-2 text-xs">
|
||
<span className="shrink-0 text-sky-600 dark:text-sky-400">{"\u2022"}</span>
|
||
<span className="max-w-[10rem] truncate font-medium text-sky-600 dark:text-sky-400">{project.name}</span>
|
||
</span>
|
||
)}
|
||
{project?.client?.name && (
|
||
<span className="max-w-[8rem] truncate text-xs text-slate-500 dark:text-slate-400">
|
||
- {project.client.name}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
|
||
<span>{formatTimeOnly(entry.start_time)} - {formatTimeOnly(entry.end_time)}</span>
|
||
{/* <span>{formatDateTime(entry.start_time, "en")}</span> */}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="shrink-0 text-right">
|
||
<p className="text-base font-semibold text-slate-900 dark:text-white">{formatDuration(entry)}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{(entryTags.length > 0 || entry.is_billable) && (
|
||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||
{entryTags.length > 0 && (
|
||
<span className="inline-flex min-w-0 items-center rounded-md bg-sky-50 px-2 py-1 text-[11px] font-medium text-sky-700 dark:bg-sky-500/15 dark:text-sky-300">
|
||
{entryTags.map((tag) => tag.name).join(" | ")}
|
||
</span>
|
||
)}
|
||
{entry.is_billable && (
|
||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-sky-50 text-sky-600 dark:bg-sky-500/15 dark:text-sky-300">
|
||
<DollarSign className="h-3.5 w-3.5" />
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{menuOpen &&
|
||
createPortal(
|
||
<div
|
||
ref={dropdownRef}
|
||
style={menuStyle}
|
||
className="rounded-xl border border-slate-200 bg-white p-1 shadow-xl dark:border-slate-700 dark:bg-slate-900"
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMenuOpen(false);
|
||
onEdit(entry);
|
||
}}
|
||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||
>
|
||
<Pencil className="h-4 w-4" />
|
||
{t.actions?.edit || "Edit"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMenuOpen(false);
|
||
onRequestRestart(entry);
|
||
}}
|
||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||
>
|
||
<Play className="h-4 w-4" />
|
||
{t.timesheet?.startTimer || "Start"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMenuOpen(false);
|
||
onDelete(entry);
|
||
}}
|
||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-500/10"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
{t.actions?.delete || "Delete"}
|
||
</button>
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function Timesheet() {
|
||
const { t, lang } = useTranslation();
|
||
const { activeWorkspace } = useWorkspace();
|
||
const isRtl = lang === "fa";
|
||
const extendedTimesheet = (t.timesheet as {
|
||
deleteTitle?: string;
|
||
deleteConfirmMessage?: string;
|
||
saveSuccess?: string;
|
||
saveError?: string;
|
||
clearFilters?: string;
|
||
customFromLabel?: string;
|
||
customToLabel?: string;
|
||
allClientsLabel?: string;
|
||
allProjectsLabel?: string;
|
||
allTagsLabel?: string;
|
||
showFiltersLabel?: string;
|
||
hideFiltersLabel?: string;
|
||
applyFiltersLabel?: string;
|
||
clientFilterPrefix?: string;
|
||
projectFilterPrefix?: string;
|
||
tagFilterPrefix?: string;
|
||
fromFilterPrefix?: string;
|
||
toFilterPrefix?: string;
|
||
restartConfirmMessage?: string;
|
||
}) || {};
|
||
|
||
const [projects, setProjects] = useState<Project[]>([]);
|
||
const [tags, setTags] = useState<Tag[]>([]);
|
||
const [groupedHistory, setGroupedHistory] = useState<TimeEntryGroupWeek[]>([]);
|
||
const [activeRunningEntry, setActiveRunningEntry] = useState<TimeEntry | null>(null);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [filters, setFilters] = useState<TimeEntryFilters>(DEFAULT_ENTRY_FILTERS);
|
||
const [hasMoreHistory, setHasMoreHistory] = useState(false);
|
||
const [nextOffset, setNextOffset] = useState<number | null>(0);
|
||
const [limit] = useState(20);
|
||
const [ticker, setTicker] = useState(Date.now());
|
||
|
||
const [modalMode, setModalMode] = useState<EntryModalMode>(null);
|
||
const [formState, setFormState] = useState<EntryFormState>(EMPTY_FORM);
|
||
const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
|
||
const [timerDraft, setTimerDraft] = useState<TimerDraftState>(EMPTY_TIMER_DRAFT);
|
||
const [isStartingTimer, setIsStartingTimer] = useState(false);
|
||
const desktopTimerRef = useRef<HTMLDivElement>(null);
|
||
const mobileTimerRef = useRef<HTMLDivElement>(null);
|
||
const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT));
|
||
const pendingTimerSignatureRef = useRef<string | null>(serializeTimerDraft(EMPTY_TIMER_DRAFT));
|
||
const isTimerSavingRef = useRef(false);
|
||
const timerEditorOwnerId = "running-timer-editor";
|
||
|
||
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
|
||
isOpen: false,
|
||
entry: null,
|
||
});
|
||
const [isDeleting, setIsDeleting] = useState(false);
|
||
const [restartModal, setRestartModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
|
||
isOpen: false,
|
||
entry: null,
|
||
});
|
||
const [isRestarting, setIsRestarting] = useState(false);
|
||
const [discardTimerModal, setDiscardTimerModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
|
||
isOpen: false,
|
||
entry: null,
|
||
});
|
||
const [isDiscardingTimer, setIsDiscardingTimer] = useState(false);
|
||
|
||
const runningEntry = activeRunningEntry;
|
||
|
||
useEffect(() => {
|
||
if (!runningEntry) return;
|
||
|
||
const intervalId = window.setInterval(() => setTicker(Date.now()), 1000);
|
||
return () => window.clearInterval(intervalId);
|
||
}, [runningEntry]);
|
||
|
||
useEffect(() => {
|
||
if (!activeWorkspace?.id) return;
|
||
|
||
const loadOptions = async () => {
|
||
try {
|
||
const [projectsData, tagsData] = await Promise.all([
|
||
getProjects(activeWorkspace.id, { limit: 100, offset: 0, ordering: "name" }),
|
||
getTags(activeWorkspace.id, { limit: 100, offset: 0, ordering: "name" }),
|
||
]);
|
||
|
||
setProjects(projectsData.results || []);
|
||
setTags(tagsData.results || []);
|
||
} catch (error) {
|
||
console.error(error);
|
||
toast.error(t.timesheet?.optionsError || "Failed to load projects and tags");
|
||
}
|
||
};
|
||
|
||
void loadOptions();
|
||
}, [activeWorkspace?.id, t.timesheet?.optionsError]);
|
||
|
||
useEffect(() => {
|
||
setSearchQuery("");
|
||
setFilters(DEFAULT_ENTRY_FILTERS);
|
||
setGroupedHistory([]);
|
||
setNextOffset(0);
|
||
setHasMoreHistory(false);
|
||
}, [activeWorkspace?.id]);
|
||
|
||
useEffect(() => {
|
||
setGroupedHistory([]);
|
||
setNextOffset(0);
|
||
setHasMoreHistory(false);
|
||
}, [filters, searchQuery]);
|
||
|
||
useEffect(() => {
|
||
if (!filters.clientId || !filters.projectId) return;
|
||
|
||
const projectStillMatchesClient = projects.some(
|
||
(project) => project.id === filters.projectId && project.client?.id === filters.clientId,
|
||
);
|
||
|
||
if (!projectStillMatchesClient) {
|
||
setFilters((current) => ({ ...current, projectId: "" }));
|
||
}
|
||
}, [filters.clientId, filters.projectId, projects]);
|
||
|
||
const loadHistory = useCallback(async ({ offset = 0, append = false }: { offset?: number; append?: boolean } = {}) => {
|
||
if (!activeWorkspace?.id) return;
|
||
|
||
try {
|
||
if (append) {
|
||
setIsLoadingMore(true);
|
||
} else {
|
||
setIsLoading(true);
|
||
}
|
||
const params: TimeEntryListParams = {
|
||
limit,
|
||
offset,
|
||
search: searchQuery,
|
||
status: "ended",
|
||
project: filters.projectId || undefined,
|
||
client: filters.clientId || undefined,
|
||
tags: filters.tagIds,
|
||
started_after: filters.startedAfter || undefined,
|
||
started_before: filters.startedBefore || undefined,
|
||
};
|
||
const data = await getTimeEntries(activeWorkspace.id, params);
|
||
setGroupedHistory((current) => (append ? mergeGroupedHistory(current, data.groups || []) : (data.groups || [])));
|
||
setHasMoreHistory(Boolean(data.has_more));
|
||
setNextOffset(data.next_offset ?? null);
|
||
} catch (error) {
|
||
console.error(error);
|
||
toast.error(t.timesheet?.fetchError || "Failed to load time entries");
|
||
} finally {
|
||
if (append) {
|
||
setIsLoadingMore(false);
|
||
} else {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
}, [activeWorkspace?.id, filters, limit, searchQuery, t.timesheet?.fetchError]);
|
||
|
||
const loadRunningEntry = useCallback(async () => {
|
||
if (!activeWorkspace?.id) {
|
||
setActiveRunningEntry(null);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const data = await getTimeEntries(activeWorkspace.id, {
|
||
limit: 1,
|
||
offset: 0,
|
||
status: "running",
|
||
});
|
||
const entry = data.groups?.[0]?.days?.[0]?.entries?.[0] || null;
|
||
setActiveRunningEntry(entry);
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
}, [activeWorkspace?.id]);
|
||
|
||
useEffect(() => {
|
||
if (!activeWorkspace?.id) return;
|
||
|
||
const timeoutId = window.setTimeout(() => {
|
||
void loadHistory();
|
||
}, 250);
|
||
|
||
return () => window.clearTimeout(timeoutId);
|
||
}, [activeWorkspace?.id, limit, loadHistory, filters, searchQuery]);
|
||
|
||
useEffect(() => {
|
||
void loadRunningEntry();
|
||
}, [loadRunningEntry]);
|
||
|
||
useEffect(() => {
|
||
if (!runningEntry) {
|
||
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
|
||
pendingTimerSignatureRef.current = timerDraftSignatureRef.current;
|
||
setTimerDraft(EMPTY_TIMER_DRAFT);
|
||
return;
|
||
}
|
||
|
||
const nextDraft = buildTimerDraftState(runningEntry);
|
||
timerDraftSignatureRef.current = serializeTimerDraft(nextDraft);
|
||
pendingTimerSignatureRef.current = timerDraftSignatureRef.current;
|
||
setTimerDraft(nextDraft);
|
||
}, [runningEntry]);
|
||
|
||
const isInsideTimerEditorContext = useCallback((target: EventTarget | null) => {
|
||
if (!(target instanceof Node)) return false;
|
||
if (desktopTimerRef.current?.contains(target) || mobileTimerRef.current?.contains(target)) return true;
|
||
return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${timerEditorOwnerId}"]`));
|
||
}, [timerEditorOwnerId]);
|
||
|
||
const commitTimerDraft = useCallback(async () => {
|
||
const saveErrorText = extendedTimesheet.saveError || "Failed to save time entry";
|
||
const saveSuccessText = extendedTimesheet.saveSuccess || "Time entry saved";
|
||
|
||
if (!runningEntry) return false;
|
||
|
||
const currentSignature = serializeTimerDraft(timerDraft);
|
||
if (currentSignature === timerDraftSignatureRef.current) {
|
||
return false;
|
||
}
|
||
|
||
if (isTimerSavingRef.current || pendingTimerSignatureRef.current === currentSignature) {
|
||
return false;
|
||
}
|
||
|
||
isTimerSavingRef.current = true;
|
||
pendingTimerSignatureRef.current = currentSignature;
|
||
|
||
try {
|
||
const updatedEntry = await updateTimeEntry(runningEntry.id, {
|
||
description: timerDraft.description.trim(),
|
||
project_id: timerDraft.projectId || null,
|
||
tags: timerDraft.tags,
|
||
is_billable: timerDraft.isBillable,
|
||
});
|
||
|
||
const syncedDraft = buildTimerDraftState(updatedEntry);
|
||
const syncedSignature = serializeTimerDraft(syncedDraft);
|
||
timerDraftSignatureRef.current = syncedSignature;
|
||
pendingTimerSignatureRef.current = syncedSignature;
|
||
setTimerDraft(syncedDraft);
|
||
setActiveRunningEntry(updatedEntry);
|
||
toast.success(saveSuccessText);
|
||
return true;
|
||
} catch (error) {
|
||
console.error(error);
|
||
pendingTimerSignatureRef.current = null;
|
||
toast.error(saveErrorText);
|
||
return false;
|
||
} finally {
|
||
isTimerSavingRef.current = false;
|
||
}
|
||
}, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry, timerDraft]);
|
||
|
||
useEffect(() => {
|
||
if (!runningEntry) return;
|
||
|
||
const handlePointerDown = (event: MouseEvent) => {
|
||
if (isInsideTimerEditorContext(event.target)) return;
|
||
void commitTimerDraft();
|
||
};
|
||
|
||
document.addEventListener("mousedown", handlePointerDown);
|
||
return () => {
|
||
document.removeEventListener("mousedown", handlePointerDown);
|
||
};
|
||
}, [commitTimerDraft, isInsideTimerEditorContext, runningEntry]);
|
||
|
||
const handleTimerBlurCapture = () => {
|
||
window.setTimeout(() => {
|
||
if (isInsideTimerEditorContext(document.activeElement)) return;
|
||
void commitTimerDraft();
|
||
}, 0);
|
||
};
|
||
|
||
const closeCreateModal = () => {
|
||
if (isSaving) return;
|
||
setModalMode(null);
|
||
setEditingEntry(null);
|
||
setFormState(EMPTY_FORM);
|
||
};
|
||
|
||
const openCreateModal = () => {
|
||
setModalMode("manual");
|
||
setEditingEntry(null);
|
||
setFormState(buildEntryFormState());
|
||
};
|
||
|
||
const openEditModal = (entry: TimeEntry) => {
|
||
setEditingEntry(entry);
|
||
setModalMode("edit");
|
||
setFormState(buildEntryFormState(entry));
|
||
};
|
||
|
||
const handleSaveEntryModal = async () => {
|
||
if (modalMode === "manual" && !activeWorkspace?.id) return;
|
||
|
||
const { payload, error } = buildPayloadFromState(formState, {
|
||
includeWorkspace: modalMode === "manual",
|
||
workspaceId: activeWorkspace?.id,
|
||
});
|
||
|
||
if (!payload) {
|
||
toast.error(error || (t.timesheet?.saveError || "Failed to save time entry"));
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setIsSaving(true);
|
||
if (modalMode === "edit" && editingEntry) {
|
||
const updatedEntry = await updateTimeEntry(editingEntry.id, payload);
|
||
setGroupedHistory((current) => updateGroupedHistoryEntry(current, updatedEntry));
|
||
toast.success(t.timesheet?.updateSuccess || "Time entry updated successfully.");
|
||
} else {
|
||
if (!activeWorkspace?.id) return;
|
||
await createTimeEntry(payload);
|
||
toast.success(t.timesheet?.createSuccess || "Time entry created");
|
||
await loadHistory();
|
||
await loadRunningEntry();
|
||
}
|
||
setModalMode(null);
|
||
setEditingEntry(null);
|
||
setFormState(EMPTY_FORM);
|
||
} catch (error) {
|
||
console.error(error);
|
||
toast.error(t.timesheet?.saveError || "Failed to save time entry");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleStartTimer = async () => {
|
||
if (!activeWorkspace?.id || runningEntry) return;
|
||
|
||
try {
|
||
setIsStartingTimer(true);
|
||
await createTimeEntry({
|
||
workspace_id: activeWorkspace.id,
|
||
description: timerDraft.description.trim(),
|
||
project_id: timerDraft.projectId || null,
|
||
start_time: new Date().toISOString(),
|
||
tags: timerDraft.tags,
|
||
is_billable: timerDraft.isBillable,
|
||
});
|
||
|
||
toast.success(t.timesheet?.startSuccess || "Timer started");
|
||
await loadHistory();
|
||
await loadRunningEntry();
|
||
} catch (error) {
|
||
console.error(error);
|
||
toast.error(t.timesheet?.saveError || "Failed to save time entry");
|
||
} finally {
|
||
setIsStartingTimer(false);
|
||
}
|
||
};
|
||
|
||
const handleStop = async (entry: TimeEntry) => {
|
||
try {
|
||
await stopTimeEntry(entry.id);
|
||
toast.success(t.timesheet?.stopSuccess || "Timer stopped");
|
||
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
|
||
setTimerDraft(EMPTY_TIMER_DRAFT);
|
||
await loadHistory();
|
||
await loadRunningEntry();
|
||
} catch (error) {
|
||
console.error(error);
|
||
toast.error(t.timesheet?.stopError || "Failed to stop timer");
|
||
}
|
||
};
|
||
|
||
const handleRestartFromEntry = async (entry: TimeEntry) => {
|
||
if (!activeWorkspace?.id || runningEntry) return;
|
||
|
||
try {
|
||
await createTimeEntry({
|
||
workspace_id: activeWorkspace.id,
|
||
description: entry.description,
|
||
project_id: entry.project,
|
||
tags: entry.tags,
|
||
is_billable: entry.is_billable,
|
||
start_time: new Date().toISOString(),
|
||
});
|
||
|
||
toast.success(t.timesheet?.startSuccess || "Timer started");
|
||
await loadHistory();
|
||
await loadRunningEntry();
|
||
} catch (error) {
|
||
console.error(error);
|
||
toast.error(t.timesheet?.saveError || "Failed to save time entry");
|
||
}
|
||
};
|
||
|
||
const openDeleteModal = (entry: TimeEntry) => {
|
||
setDeleteModal({ isOpen: true, entry });
|
||
};
|
||
|
||
const openRestartModal = (entry: TimeEntry) => {
|
||
setRestartModal({ isOpen: true, entry });
|
||
};
|
||
|
||
const closeDeleteModal = () => {
|
||
if (isDeleting) return;
|
||
setDeleteModal({ isOpen: false, entry: null });
|
||
};
|
||
|
||
const closeRestartModal = () => {
|
||
if (isRestarting) return;
|
||
setRestartModal({ isOpen: false, entry: null });
|
||
};
|
||
|
||
const openDiscardTimerModal = () => {
|
||
if (!runningEntry || isDiscardingTimer) return;
|
||
setDiscardTimerModal({ isOpen: true, entry: runningEntry });
|
||
};
|
||
|
||
const closeDiscardTimerModal = () => {
|
||
if (isDiscardingTimer) return;
|
||
setDiscardTimerModal({ isOpen: false, entry: null });
|
||
};
|
||
|
||
const confirmDelete = async () => {
|
||
if (!deleteModal.entry) return;
|
||
|
||
try {
|
||
setIsDeleting(true);
|
||
await deleteTimeEntry(deleteModal.entry.id);
|
||
toast.success(t.timesheet?.deleteSuccess || "Time entry deleted");
|
||
setDeleteModal({ isOpen: false, entry: null });
|
||
setGroupedHistory((current) =>
|
||
current
|
||
.map((week) => ({
|
||
...week,
|
||
days: week.days
|
||
.map((day) => ({
|
||
...day,
|
||
entries: day.entries.filter((entry) => entry.id !== deleteModal.entry!.id),
|
||
}))
|
||
.filter((day) => day.entries.length > 0)
|
||
.map((day) => ({ ...day, total_ms: day.entries.reduce((sum, item) => sum + getEntryDurationMs(item), 0) })),
|
||
}))
|
||
.filter((week) => week.days.length > 0)
|
||
.map((week) => ({ ...week, total_ms: week.days.reduce((sum, day) => sum + day.total_ms, 0) })),
|
||
);
|
||
await loadRunningEntry();
|
||
} catch (error) {
|
||
console.error(error);
|
||
toast.error(t.timesheet?.deleteError || "Failed to delete time entry");
|
||
} finally {
|
||
setIsDeleting(false);
|
||
}
|
||
};
|
||
|
||
const confirmRestart = async () => {
|
||
if (!restartModal.entry) return;
|
||
|
||
try {
|
||
setIsRestarting(true);
|
||
await handleRestartFromEntry(restartModal.entry);
|
||
setRestartModal({ isOpen: false, entry: null });
|
||
} finally {
|
||
setIsRestarting(false);
|
||
}
|
||
};
|
||
|
||
const handleEntryUpdated = useCallback((updatedEntry: TimeEntry) => {
|
||
setGroupedHistory((current) => updateGroupedHistoryEntry(current, updatedEntry));
|
||
if (!updatedEntry.end_time) {
|
||
setActiveRunningEntry(updatedEntry);
|
||
}
|
||
}, []);
|
||
|
||
const handleApplyFilters = useCallback((nextSearchQuery: string, nextFilters: TimeEntryFilters) => {
|
||
setSearchQuery(nextSearchQuery);
|
||
setFilters(nextFilters);
|
||
}, []);
|
||
|
||
const handleClearFilters = useCallback(() => {
|
||
setSearchQuery("");
|
||
setFilters(DEFAULT_ENTRY_FILTERS);
|
||
}, []);
|
||
|
||
const handleLoadMore = useCallback(() => {
|
||
if (!hasMoreHistory || nextOffset === null || isLoadingMore) return;
|
||
void loadHistory({ offset: nextOffset, append: true });
|
||
}, [hasMoreHistory, isLoadingMore, loadHistory, nextOffset]);
|
||
|
||
const handleDiscardTimerDraft = useCallback(async () => {
|
||
if (!discardTimerModal.entry || isDiscardingTimer) return;
|
||
|
||
try {
|
||
setIsDiscardingTimer(true);
|
||
|
||
await deleteTimeEntry(discardTimerModal.entry.id);
|
||
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
|
||
setTimerDraft(EMPTY_TIMER_DRAFT);
|
||
setActiveRunningEntry(null);
|
||
setDiscardTimerModal({ isOpen: false, entry: null });
|
||
toast.success(t.timesheet?.deleteSuccess || "Time entry deleted");
|
||
await loadHistory();
|
||
await loadRunningEntry();
|
||
} catch (error) {
|
||
console.error(error);
|
||
toast.error(t.timesheet?.deleteError || "Failed to delete time entry");
|
||
} finally {
|
||
setIsDiscardingTimer(false);
|
||
}
|
||
}, [discardTimerModal.entry, isDiscardingTimer, loadHistory, loadRunningEntry, t.timesheet?.deleteError, t.timesheet?.deleteSuccess]);
|
||
|
||
if (!activeWorkspace) {
|
||
return <div className="p-6 text-center text-slate-500">{t.timesheet?.selectWorkspace || t.clients.selectWorkspace}</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="flex min-h-[calc(100vh-73px)] flex-col bg-slate-100/70 p-4 dark:bg-slate-900">
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h1 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400">
|
||
{t.timesheet?.title || "Timesheet"}
|
||
</h1>
|
||
</div>
|
||
|
||
<div
|
||
ref={desktopTimerRef}
|
||
onBlurCapture={handleTimerBlurCapture}
|
||
className="mb-4 hidden overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 md:block"
|
||
>
|
||
<div className="flex min-w-[1040px] items-center h-20 px-3">
|
||
<div className="min-w-[360px] flex-1">
|
||
<Input
|
||
value={timerDraft.description}
|
||
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||
onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))}
|
||
disabled={isStartingTimer}
|
||
className="h-12 rounded-none border-0 bg-transparent dark:bg-transparent px-5 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex shrink-0 items-center">
|
||
<Select
|
||
value={timerDraft.projectId}
|
||
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
||
options={[
|
||
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
||
...projects.map((project) => ({ value: project.id, label: project.name })),
|
||
]}
|
||
className="min-w-[170px]"
|
||
buttonClassName="h-12 w-full rounded-none border-0 bg-transparent px-3 text-sm text-sky-600 shadow-none outline-none dark:bg-transparent dark:text-sky-400 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||
disabled={isStartingTimer}
|
||
portalOwnerId={timerEditorOwnerId}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex shrink-0 items-center">
|
||
<TagMultiSelect
|
||
tags={tags}
|
||
selectedTags={timerDraft.tags}
|
||
onToggleTag={(tagId) =>
|
||
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
||
}
|
||
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
||
title={t.tags?.title || "Tags"}
|
||
compact
|
||
portalOwnerId={timerEditorOwnerId}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex shrink-0 items-center">
|
||
<BillableIconButton
|
||
checked={timerDraft.isBillable}
|
||
onChange={(checked) => setTimerDraft((current) => ({ ...current, isBillable: checked }))}
|
||
label={t.timesheet?.billable || "Billable"}
|
||
disabled={isStartingTimer}
|
||
compact
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex h-12 shrink-0 items-center px-5 text-lg font-semibold text-slate-900 dark:text-white">
|
||
{runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"}
|
||
</div>
|
||
|
||
<div className="ms-2 flex shrink-0 items-center gap-2">
|
||
{runningEntry ? (
|
||
<>
|
||
<Button
|
||
variant="destructive"
|
||
size="icon"
|
||
onClick={() => void handleStop(runningEntry)}
|
||
className="h-12 w-12 rounded-md"
|
||
title={t.timesheet?.stopTimer || "Stop"}
|
||
aria-label={t.timesheet?.stopTimer || "Stop"}
|
||
>
|
||
<Square className="h-4 w-4 fill-current" />
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="icon"
|
||
onClick={openDiscardTimerModal}
|
||
disabled={isDiscardingTimer}
|
||
className="h-12 w-12 rounded-md"
|
||
title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||
aria-label={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||
>
|
||
{isDiscardingTimer ? "..." : <Trash2 className="h-4 w-4" />}
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<Button
|
||
onClick={() => void handleStartTimer()}
|
||
disabled={isStartingTimer}
|
||
size="icon"
|
||
className="h-12 w-12 rounded-md"
|
||
title={t.timesheet?.startTimer || "Start"}
|
||
aria-label={t.timesheet?.startTimer || "Start"}
|
||
>
|
||
{isStartingTimer ? "..." : <Play className="h-4 w-4 fill-current" />}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
ref={mobileTimerRef}
|
||
onBlurCapture={handleTimerBlurCapture}
|
||
className="mb-4 rounded-xl border border-slate-200 bg-white p-3 shadow-sm dark:border-slate-800 dark:bg-slate-950 md:hidden"
|
||
>
|
||
<div className="space-y-3">
|
||
<Input
|
||
value={timerDraft.description}
|
||
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||
onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))}
|
||
disabled={isStartingTimer}
|
||
className="h-10 border-slate-200 bg-slate-50 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||
/>
|
||
|
||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||
<Select
|
||
value={timerDraft.projectId}
|
||
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
||
options={[
|
||
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
||
...projects.map((project) => ({ value: project.id, label: project.name })),
|
||
]}
|
||
className="w-full"
|
||
buttonClassName="h-10 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||
disabled={isStartingTimer}
|
||
portalOwnerId={timerEditorOwnerId}
|
||
/>
|
||
|
||
<div className="flex h-10 min-w-[110px] items-center justify-center rounded-md border border-slate-200 bg-slate-50 px-3 text-sm font-semibold text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white">
|
||
{runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<TagMultiSelect
|
||
tags={tags}
|
||
selectedTags={timerDraft.tags}
|
||
onToggleTag={(tagId) =>
|
||
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
||
}
|
||
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
||
title={t.tags?.title || "Tags"}
|
||
compact
|
||
portalOwnerId={timerEditorOwnerId}
|
||
/>
|
||
|
||
<BillableIconButton
|
||
checked={timerDraft.isBillable}
|
||
onChange={(checked) => setTimerDraft((current) => ({ ...current, isBillable: checked }))}
|
||
label={t.timesheet?.billable || "Billable"}
|
||
disabled={isStartingTimer}
|
||
compact
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
{runningEntry ? (
|
||
<>
|
||
<Button
|
||
variant="destructive"
|
||
size="icon"
|
||
onClick={() => void handleStop(runningEntry)}
|
||
className="h-10 w-10 rounded-md"
|
||
title={t.timesheet?.stopTimer || "Stop"}
|
||
aria-label={t.timesheet?.stopTimer || "Stop"}
|
||
>
|
||
<Square className="h-4 w-4 fill-current" />
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="icon"
|
||
onClick={openDiscardTimerModal}
|
||
disabled={isDiscardingTimer}
|
||
className="h-10 w-10 rounded-md"
|
||
title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||
aria-label={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||
>
|
||
{isDiscardingTimer ? "..." : <Trash2 className="h-4 w-4" />}
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<Button
|
||
onClick={() => void handleStartTimer()}
|
||
disabled={isStartingTimer}
|
||
size="icon"
|
||
className="h-10 w-10 rounded-md"
|
||
title={t.timesheet?.startTimer || "Start"}
|
||
aria-label={t.timesheet?.startTimer || "Start"}
|
||
>
|
||
{isStartingTimer ? "..." : <Play className="h-4 w-4 fill-current" />}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<TimesheetFilterBar
|
||
searchQuery={searchQuery}
|
||
filters={filters}
|
||
onApply={handleApplyFilters}
|
||
onClearFilters={handleClearFilters}
|
||
projects={projects}
|
||
tags={tags}
|
||
searchPlaceholder={t.timesheet?.searchPlaceholder || "Search time entries..."}
|
||
labels={{
|
||
project: t.timesheet?.projectLabel || "Project",
|
||
client: t.projects?.clientLabel || "Client",
|
||
tags: t.tags?.title || "Tags",
|
||
clear: extendedTimesheet.clearFilters || "Clear filters",
|
||
customFrom: extendedTimesheet.customFromLabel || "From date",
|
||
customTo: extendedTimesheet.customToLabel || "To date",
|
||
allClients: extendedTimesheet.allClientsLabel || "All clients",
|
||
allProjects: extendedTimesheet.allProjectsLabel || "All projects",
|
||
allTags: extendedTimesheet.allTagsLabel || "All tags",
|
||
showFilters: extendedTimesheet.showFiltersLabel || "Show filters",
|
||
hideFilters: extendedTimesheet.hideFiltersLabel || "Hide filters",
|
||
apply: extendedTimesheet.applyFiltersLabel || "Apply",
|
||
clientPrefix: extendedTimesheet.clientFilterPrefix || "Client",
|
||
projectPrefix: extendedTimesheet.projectFilterPrefix || "Project",
|
||
tagPrefix: extendedTimesheet.tagFilterPrefix || "Tag",
|
||
fromPrefix: extendedTimesheet.fromFilterPrefix || "From",
|
||
toPrefix: extendedTimesheet.toFilterPrefix || "To",
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="flex justify-center p-12 text-slate-500">{t.loading || "Loading..."}</div>
|
||
) : (
|
||
<InfiniteScroll
|
||
className="flex flex-1 flex-col"
|
||
onLoadMore={handleLoadMore}
|
||
hasMore={hasMoreHistory}
|
||
isLoading={isLoadingMore}
|
||
>
|
||
<div className="mb-6 space-y-4">
|
||
{groupedHistory.map((week) => (
|
||
<div key={week.key} className="space-y-2">
|
||
<div className="flex items-center justify-between px-1">
|
||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||
{formatWeekRange(new Date(`${week.week_start}T00:00:00`), lang)}
|
||
</p>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||
Week total: <span className="font-semibold text-slate-800 dark:text-slate-100">{formatDurationMs(week.total_ms)}</span>
|
||
</p>
|
||
</div>
|
||
|
||
{week.days.map((day) => (
|
||
<div key={day.key} className="border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
||
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-100/80 px-4 py-2 dark:border-slate-800 dark:bg-slate-900">
|
||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">
|
||
{formatDayLabel(new Date(`${day.date}T00:00:00`), lang)}
|
||
</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Total: <span className="font-semibold text-slate-700 dark:text-slate-200">{formatDurationMs(day.total_ms)}</span>
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
{day.entries.map((entry) => (
|
||
<div key={entry.id}>
|
||
<div className="hidden md:block">
|
||
<RecordedEntryCard
|
||
entry={entry}
|
||
t={t}
|
||
projects={projects}
|
||
tags={tags}
|
||
onDelete={openDeleteModal}
|
||
onRestart={handleRestartFromEntry}
|
||
onEntryUpdated={handleEntryUpdated}
|
||
/>
|
||
</div>
|
||
<div className="md:hidden">
|
||
<MobileRecordedEntryCard
|
||
entry={entry}
|
||
t={t}
|
||
projects={projects}
|
||
tags={tags}
|
||
onEdit={openEditModal}
|
||
onDelete={openDeleteModal}
|
||
onRequestRestart={openRestartModal}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
|
||
{groupedHistory.length === 0 && (
|
||
<div className="flex flex-col items-center justify-center border-2 border-dashed border-slate-200 py-16 text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||
<Clock3 className="mb-3 h-10 w-10" />
|
||
<p>{t.timesheet?.emptyState || "No time entries found"}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</InfiniteScroll>
|
||
)}
|
||
|
||
<Modal
|
||
isOpen={modalMode === "manual" || modalMode === "edit"}
|
||
onClose={closeCreateModal}
|
||
title={modalMode === "edit" ? (t.timesheet?.editTitle || "Edit Time Entry") : (t.timesheet?.createTitle || "Add Time Entry")}
|
||
maxWidth="max-w-2xl"
|
||
footer={
|
||
<>
|
||
<Button variant="secondary" onClick={closeCreateModal}>
|
||
{t.actions?.cancel || "Cancel"}
|
||
</Button>
|
||
<Button onClick={() => void handleSaveEntryModal()} disabled={isSaving}>
|
||
{isSaving ? "..." : (modalMode === "edit" ? (t.save || "Save") : (t.create || "Create"))}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<EntryEditorFields
|
||
state={formState}
|
||
onChange={(patch) => setFormState((current) => ({ ...current, ...patch }))}
|
||
onToggleTag={(tagId) => setFormState((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
||
projects={projects}
|
||
tags={tags}
|
||
t={t}
|
||
isRtl={isRtl}
|
||
/>
|
||
</Modal>
|
||
|
||
{deleteModal.entry && (
|
||
<Modal
|
||
isOpen={deleteModal.isOpen}
|
||
onClose={closeDeleteModal}
|
||
title={extendedTimesheet.deleteTitle || "Delete Time Entry"}
|
||
maxWidth="max-w-md"
|
||
footer={
|
||
<>
|
||
<Button variant="secondary" onClick={closeDeleteModal}>
|
||
{t.actions?.cancel || "Cancel"}
|
||
</Button>
|
||
<Button variant="destructive" onClick={confirmDelete} disabled={isDeleting}>
|
||
{isDeleting ? "..." : (t.actions?.delete || "Delete")}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-3">
|
||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||
{extendedTimesheet.deleteConfirmMessage || "Are you sure you want to delete this time entry?"}
|
||
</p>
|
||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60">
|
||
<p className="font-medium text-slate-900 dark:text-white">
|
||
{deleteModal.entry.description || t.timesheet?.emptyDescription || "No description"}
|
||
</p>
|
||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||
{formatDateTime(deleteModal.entry.start_time, lang)}
|
||
{deleteModal.entry.end_time ? ` - ${formatDateTime(deleteModal.entry.end_time, lang)}` : ""}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
|
||
{restartModal.entry && (
|
||
<Modal
|
||
isOpen={restartModal.isOpen}
|
||
onClose={closeRestartModal}
|
||
title={t.timesheet?.startTimer || "Start"}
|
||
maxWidth="max-w-md"
|
||
footer={
|
||
<>
|
||
<Button variant="secondary" onClick={closeRestartModal}>
|
||
{t.actions?.cancel || "Cancel"}
|
||
</Button>
|
||
<Button onClick={() => void confirmRestart()} disabled={isRestarting}>
|
||
{isRestarting ? "..." : (t.timesheet?.startTimer || "Start")}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-3">
|
||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||
{(extendedTimesheet.restartConfirmMessage || "Start a new running timer from this entry?")}
|
||
</p>
|
||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60">
|
||
<p className="font-medium text-slate-900 dark:text-white">
|
||
{restartModal.entry.description || t.timesheet?.emptyDescription || "No description"}
|
||
</p>
|
||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||
{formatDateTime(restartModal.entry.start_time, lang)}
|
||
{restartModal.entry.end_time ? ` - ${formatDateTime(restartModal.entry.end_time, lang)}` : ""}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
|
||
{discardTimerModal.entry && (
|
||
<Modal
|
||
isOpen={discardTimerModal.isOpen}
|
||
onClose={closeDiscardTimerModal}
|
||
title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||
maxWidth="max-w-md"
|
||
footer={
|
||
<>
|
||
<Button variant="secondary" onClick={closeDiscardTimerModal}>
|
||
{t.actions?.cancel || "Cancel"}
|
||
</Button>
|
||
<Button variant="destructive" onClick={() => void handleDiscardTimerDraft()} disabled={isDiscardingTimer}>
|
||
{isDiscardingTimer ? "..." : ((t.actions as { discard?: string } | undefined)?.discard || "Discard")}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-3">
|
||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||
{extendedTimesheet.deleteConfirmMessage || "Are you sure you want to delete this time entry?"}
|
||
</p>
|
||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60">
|
||
<p className="font-medium text-slate-900 dark:text-white">
|
||
{discardTimerModal.entry.description || t.timesheet?.emptyDescription || "No description"}
|
||
</p>
|
||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||
{formatDateTime(discardTimerModal.entry.start_time, lang)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|