|
|
|
@@ -461,6 +461,124 @@ const buildPayloadFromState = (
|
|
|
|
return { payload };
|
|
|
|
return { payload };
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const buildDeletedProjectLabel = (projectName: string, deletedLabel: string) => `${deletedLabel}: ${projectName}`;
|
|
|
|
|
|
|
|
const buildDeletedTagLabel = (tagName: string, deletedLabel: string) => `${deletedLabel}: ${tagName}`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const getEntryProjectOption = (entry?: TimeEntry | null, deletedProjectLabel?: string): Project | null => {
|
|
|
|
|
|
|
|
if (!entry?.project_details) return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
id: entry.project_details.id,
|
|
|
|
|
|
|
|
name: entry.project_details.is_deleted
|
|
|
|
|
|
|
|
? buildDeletedProjectLabel(entry.project_details.name, deletedProjectLabel || "Deleted project")
|
|
|
|
|
|
|
|
: entry.project_details.name,
|
|
|
|
|
|
|
|
description: "",
|
|
|
|
|
|
|
|
color: "",
|
|
|
|
|
|
|
|
is_archived: false,
|
|
|
|
|
|
|
|
is_deleted: entry.project_details.is_deleted,
|
|
|
|
|
|
|
|
workspace: entry.workspace,
|
|
|
|
|
|
|
|
client: entry.project_details.client_name
|
|
|
|
|
|
|
|
? { id: "", name: entry.project_details.client_name }
|
|
|
|
|
|
|
|
: null,
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const buildProjectOptionsForEntry = (
|
|
|
|
|
|
|
|
activeProjects: Project[],
|
|
|
|
|
|
|
|
entry: TimeEntry | null | undefined,
|
|
|
|
|
|
|
|
selectedProjectId: string,
|
|
|
|
|
|
|
|
deletedProjectLabel?: string,
|
|
|
|
|
|
|
|
) => {
|
|
|
|
|
|
|
|
const projectsById = new Map(activeProjects.map((project) => [project.id, project]));
|
|
|
|
|
|
|
|
const currentProject = getEntryProjectOption(entry, deletedProjectLabel);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
|
|
currentProject &&
|
|
|
|
|
|
|
|
currentProject.id === selectedProjectId &&
|
|
|
|
|
|
|
|
!projectsById.has(currentProject.id)
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
projectsById.set(currentProject.id, currentProject);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return Array.from(projectsById.values());
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const buildTagOptionsForEntry = (
|
|
|
|
|
|
|
|
activeTags: Tag[],
|
|
|
|
|
|
|
|
entry: TimeEntry | null | undefined,
|
|
|
|
|
|
|
|
selectedTagIds: string[],
|
|
|
|
|
|
|
|
) => {
|
|
|
|
|
|
|
|
const tagsById = new Map(activeTags.map((tag) => [tag.id, tag]));
|
|
|
|
|
|
|
|
const entryWorkspaceId = entry?.workspace || "";
|
|
|
|
|
|
|
|
const entryCreatedAt = entry?.created_at || "";
|
|
|
|
|
|
|
|
const entryUpdatedAt = entry?.updated_at || "";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
(entry?.tag_details || []).forEach((tag) => {
|
|
|
|
|
|
|
|
if (!selectedTagIds.includes(tag.id) || tagsById.has(tag.id)) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tagsById.set(tag.id, {
|
|
|
|
|
|
|
|
id: tag.id,
|
|
|
|
|
|
|
|
workspace: entryWorkspaceId,
|
|
|
|
|
|
|
|
name: tag.name,
|
|
|
|
|
|
|
|
color: tag.color,
|
|
|
|
|
|
|
|
is_deleted: tag.is_deleted,
|
|
|
|
|
|
|
|
created_at: entryCreatedAt,
|
|
|
|
|
|
|
|
updated_at: entryUpdatedAt,
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return Array.from(tagsById.values());
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const getProjectDisplayDetails = (entry: TimeEntry, activeProjects: Project[]) => {
|
|
|
|
|
|
|
|
const activeProject = activeProjects.find((item) => item.id === entry.project);
|
|
|
|
|
|
|
|
if (activeProject) {
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
name: activeProject.name,
|
|
|
|
|
|
|
|
clientName: activeProject.client?.name || null,
|
|
|
|
|
|
|
|
isDeleted: Boolean(activeProject.is_deleted),
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!entry.project_details) {
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
name: entry.project_details.name,
|
|
|
|
|
|
|
|
clientName: entry.project_details.client_name,
|
|
|
|
|
|
|
|
isDeleted: entry.project_details.is_deleted,
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const getTagDisplayDetails = (entry: TimeEntry, activeTags: Tag[]) => {
|
|
|
|
|
|
|
|
const activeTagsById = new Map(activeTags.map((tag) => [tag.id, tag]));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return entry.tags.map((tagId) => {
|
|
|
|
|
|
|
|
const activeTag = activeTagsById.get(tagId);
|
|
|
|
|
|
|
|
if (activeTag) {
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
id: activeTag.id,
|
|
|
|
|
|
|
|
name: activeTag.name,
|
|
|
|
|
|
|
|
color: activeTag.color,
|
|
|
|
|
|
|
|
isDeleted: Boolean(activeTag.is_deleted),
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const deletedTag = entry.tag_details.find((tag) => tag.id === tagId);
|
|
|
|
|
|
|
|
if (!deletedTag) {
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
id: deletedTag.id,
|
|
|
|
|
|
|
|
name: deletedTag.name,
|
|
|
|
|
|
|
|
color: deletedTag.color,
|
|
|
|
|
|
|
|
isDeleted: deletedTag.is_deleted,
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
}).filter(Boolean) as Array<{ id: string; name: string; color: string; isDeleted: boolean }>;
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function TimeField({
|
|
|
|
function TimeField({
|
|
|
|
label,
|
|
|
|
label,
|
|
|
|
value,
|
|
|
|
value,
|
|
|
|
@@ -532,6 +650,9 @@ function TagMultiSelect({
|
|
|
|
title,
|
|
|
|
title,
|
|
|
|
compact = false,
|
|
|
|
compact = false,
|
|
|
|
portalOwnerId,
|
|
|
|
portalOwnerId,
|
|
|
|
|
|
|
|
className = "",
|
|
|
|
|
|
|
|
buttonClassName = "",
|
|
|
|
|
|
|
|
compactDisplayMode = "summary",
|
|
|
|
}: {
|
|
|
|
}: {
|
|
|
|
tags: Tag[];
|
|
|
|
tags: Tag[];
|
|
|
|
selectedTags: string[];
|
|
|
|
selectedTags: string[];
|
|
|
|
@@ -540,6 +661,9 @@ function TagMultiSelect({
|
|
|
|
title: string;
|
|
|
|
title: string;
|
|
|
|
compact?: boolean;
|
|
|
|
compact?: boolean;
|
|
|
|
portalOwnerId?: string;
|
|
|
|
portalOwnerId?: string;
|
|
|
|
|
|
|
|
className?: string;
|
|
|
|
|
|
|
|
buttonClassName?: string;
|
|
|
|
|
|
|
|
compactDisplayMode?: "summary" | "chips";
|
|
|
|
}) {
|
|
|
|
}) {
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
@@ -588,12 +712,10 @@ function TagMultiSelect({
|
|
|
|
|
|
|
|
|
|
|
|
if (isOpen) {
|
|
|
|
if (isOpen) {
|
|
|
|
window.addEventListener("resize", closeOnViewportChange);
|
|
|
|
window.addEventListener("resize", closeOnViewportChange);
|
|
|
|
window.addEventListener("scroll", closeOnViewportChange, true);
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
return () => {
|
|
|
|
window.removeEventListener("resize", closeOnViewportChange);
|
|
|
|
window.removeEventListener("resize", closeOnViewportChange);
|
|
|
|
window.removeEventListener("scroll", closeOnViewportChange, true);
|
|
|
|
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}, [isOpen]);
|
|
|
|
}, [isOpen]);
|
|
|
|
|
|
|
|
|
|
|
|
@@ -603,7 +725,8 @@ function TagMultiSelect({
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}, [isOpen]);
|
|
|
|
}, [isOpen]);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name);
|
|
|
|
const selectedTagObjects = tags.filter((tag) => selectedTags.includes(tag.id));
|
|
|
|
|
|
|
|
const selectedLabels = selectedTagObjects.map((tag) => tag.name);
|
|
|
|
const joinedSelectedLabels = selectedLabels.join(" | ");
|
|
|
|
const joinedSelectedLabels = selectedLabels.join(" | ");
|
|
|
|
const normalizedSearch = searchQuery.trim().toLowerCase();
|
|
|
|
const normalizedSearch = searchQuery.trim().toLowerCase();
|
|
|
|
const filteredTags = normalizedSearch
|
|
|
|
const filteredTags = normalizedSearch
|
|
|
|
@@ -618,7 +741,7 @@ function TagMultiSelect({
|
|
|
|
: title;
|
|
|
|
: title;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<div ref={wrapperRef} className={compact ? "relative w-fit" : "relative"}>
|
|
|
|
<div ref={wrapperRef} className={`${compact ? "relative min-w-0" : "relative"} ${className}`.trim()}>
|
|
|
|
{!compact && <p className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">{title}</p>}
|
|
|
|
{!compact && <p className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">{title}</p>}
|
|
|
|
<button
|
|
|
|
<button
|
|
|
|
ref={buttonRef}
|
|
|
|
ref={buttonRef}
|
|
|
|
@@ -627,13 +750,29 @@ function TagMultiSelect({
|
|
|
|
title={selectedLabels.length > 0 ? selectedLabels.join(", ") : title}
|
|
|
|
title={selectedLabels.length > 0 ? selectedLabels.join(", ") : title}
|
|
|
|
className={`inline-flex items-center gap-1 text-slate-700 dark:text-slate-200 ${
|
|
|
|
className={`inline-flex items-center gap-1 text-slate-700 dark:text-slate-200 ${
|
|
|
|
compact
|
|
|
|
compact
|
|
|
|
? "h-12 w-fit border-0 bg-transparent px-2 text-xs"
|
|
|
|
? "h-12 max-w-full 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"
|
|
|
|
: "w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
|
|
|
|
}`}
|
|
|
|
} ${buttonClassName}`.trim()}
|
|
|
|
>
|
|
|
|
>
|
|
|
|
{compact ? (
|
|
|
|
{compact ? (
|
|
|
|
<span className="inline-flex min-w-0 items-center gap-1.5">
|
|
|
|
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5 overflow-hidden">
|
|
|
|
{buttonLabel ? (
|
|
|
|
{selectedTagObjects.length > 0 && compactDisplayMode === "chips" ? (
|
|
|
|
|
|
|
|
<span className="flex min-w-0 max-w-full items-center gap-1 overflow-hidden whitespace-nowrap">
|
|
|
|
|
|
|
|
{selectedTagObjects.map((tag) => (
|
|
|
|
|
|
|
|
<span
|
|
|
|
|
|
|
|
key={tag.id}
|
|
|
|
|
|
|
|
title={tag.name}
|
|
|
|
|
|
|
|
className={`rounded-sm px-2 py-1 text-[11px] font-medium ${
|
|
|
|
|
|
|
|
tag.is_deleted
|
|
|
|
|
|
|
|
? "bg-slate-200 text-slate-700 line-through dark:bg-slate-700 dark:text-slate-200"
|
|
|
|
|
|
|
|
: "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
|
|
|
|
|
|
|
|
}`}
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
{tag.name}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
) : 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">
|
|
|
|
<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}
|
|
|
|
{buttonLabel}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
@@ -673,24 +812,33 @@ function TagMultiSelect({
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="max-h-72 space-y-1 overflow-y-auto p-2">
|
|
|
|
<div className="max-h-72 space-y-1 overflow-y-auto p-2">
|
|
|
|
{filteredTags.map((tag) => {
|
|
|
|
{filteredTags.map((tag) => {
|
|
|
|
const selected = selectedTags.includes(tag.id);
|
|
|
|
const selected = selectedTags.includes(tag.id);
|
|
|
|
return (
|
|
|
|
const isUnavailable = Boolean(tag.is_deleted) && !selected;
|
|
|
|
<button
|
|
|
|
return (
|
|
|
|
key={tag.id}
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
key={tag.id}
|
|
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
|
|
type="button"
|
|
|
|
onClick={() => onToggleTag(tag.id)}
|
|
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
|
|
className={`flex w-full items-center justify-between rounded-xl px-2 py-2 text-sm transition-colors ${
|
|
|
|
onClick={() => {
|
|
|
|
selected ? "bg-slate-100 text-slate-900 dark:bg-slate-700 dark:text-white" : "text-slate-700 dark:text-slate-200"
|
|
|
|
if (isUnavailable) return;
|
|
|
|
}`}
|
|
|
|
onToggleTag(tag.id);
|
|
|
|
>
|
|
|
|
}}
|
|
|
|
<span className="inline-flex min-w-0 items-center gap-2 truncate">
|
|
|
|
className={`flex w-full items-center justify-between rounded-xl px-2 py-2 text-sm transition-colors ${
|
|
|
|
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: tag.color || "#94A3B8" }} />
|
|
|
|
selected
|
|
|
|
<span className="truncate">{tag.name}</span>
|
|
|
|
? "bg-slate-100 text-slate-900 dark:bg-slate-700 dark:text-white"
|
|
|
|
</span>
|
|
|
|
: isUnavailable
|
|
|
|
{selected && <Check className="h-4 w-4 shrink-0" />}
|
|
|
|
? "cursor-not-allowed text-slate-400 opacity-70 dark:text-slate-500"
|
|
|
|
</button>
|
|
|
|
: "text-slate-700 dark:text-slate-200"
|
|
|
|
);
|
|
|
|
}`}
|
|
|
|
|
|
|
|
title={tag.name}
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<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.is_deleted ? "line-through text-slate-700 dark:text-slate-300" : ""}`}>{tag.name}</span>
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
{selected && <Check className="h-4 w-4 shrink-0" />}
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
})}
|
|
|
|
{filteredTags.length === 0 && (
|
|
|
|
{filteredTags.length === 0 && (
|
|
|
|
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
|
|
|
|
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
|
|
|
|
@@ -786,13 +934,13 @@ function ProjectInlineSelect({
|
|
|
|
const label = selectedProject?.name || placeholder;
|
|
|
|
const label = selectedProject?.name || placeholder;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<div ref={wrapperRef} className={`relative shrink-0 ${className}`}>
|
|
|
|
<div ref={wrapperRef} className={`relative min-w-0 ${className}`}>
|
|
|
|
<button
|
|
|
|
<button
|
|
|
|
ref={buttonRef}
|
|
|
|
ref={buttonRef}
|
|
|
|
type="button"
|
|
|
|
type="button"
|
|
|
|
onClick={() => !disabled && setIsOpen((current) => !current)}
|
|
|
|
onClick={() => !disabled && setIsOpen((current) => !current)}
|
|
|
|
disabled={disabled}
|
|
|
|
disabled={disabled}
|
|
|
|
className={`inline-flex max-w-full items-center rounded-sm bg-transparent py-0 text-sm transition-colors ${
|
|
|
|
className={`inline-flex w-full max-w-full items-center rounded-sm bg-transparent py-0 text-sm transition-colors ${
|
|
|
|
selectedProject
|
|
|
|
selectedProject
|
|
|
|
? "text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
|
|
|
|
? "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"
|
|
|
|
: "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
|
|
|
@@ -829,22 +977,27 @@ function ProjectInlineSelect({
|
|
|
|
|
|
|
|
|
|
|
|
{projects.map((project) => {
|
|
|
|
{projects.map((project) => {
|
|
|
|
const selected = project.id === value;
|
|
|
|
const selected = project.id === value;
|
|
|
|
|
|
|
|
const unavailable = Boolean(project.is_deleted) && !selected;
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<button
|
|
|
|
<button
|
|
|
|
key={project.id}
|
|
|
|
key={project.id}
|
|
|
|
type="button"
|
|
|
|
type="button"
|
|
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
|
|
onClick={() => {
|
|
|
|
onClick={() => {
|
|
|
|
|
|
|
|
if (unavailable) return;
|
|
|
|
onChange(project.id);
|
|
|
|
onChange(project.id);
|
|
|
|
setIsOpen(false);
|
|
|
|
setIsOpen(false);
|
|
|
|
}}
|
|
|
|
}}
|
|
|
|
className={`flex w-full items-center rounded-xl px-3 py-2 text-sm transition-colors ${
|
|
|
|
className={`flex w-full items-center rounded-xl px-3 py-2 text-sm transition-colors ${
|
|
|
|
selected
|
|
|
|
selected
|
|
|
|
? "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
|
|
|
|
? "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"
|
|
|
|
: unavailable
|
|
|
|
|
|
|
|
? "cursor-not-allowed text-slate-400 opacity-70 dark:text-slate-500"
|
|
|
|
|
|
|
|
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700/70"
|
|
|
|
}`}
|
|
|
|
}`}
|
|
|
|
|
|
|
|
title={project.name}
|
|
|
|
>
|
|
|
|
>
|
|
|
|
<span className="truncate">{project.name}</span>
|
|
|
|
<span className={`truncate ${project.is_deleted ? "italic" : ""}`}>{project.name}</span>
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
);
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
})}
|
|
|
|
@@ -1062,47 +1215,52 @@ function EntryEditorFields({
|
|
|
|
if (compact) {
|
|
|
|
if (compact) {
|
|
|
|
const selectedProject = projects.find((project) => project.id === state.projectId);
|
|
|
|
const selectedProject = projects.find((project) => project.id === state.projectId);
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<div className="grid min-w-0 flex-1 grid-cols-[minmax(420px,1fr)_40px_188px_40px] items-center">
|
|
|
|
<div className="grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_minmax(0,1fr)_40px_188px_40px] items-center">
|
|
|
|
<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 })}
|
|
|
|
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
|
|
|
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"
|
|
|
|
className="h-12 w-[170px] 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>
|
|
|
|
<span className="me-2 shrink-0 text-sm font-semibold leading-none text-sky-600 dark:text-sky-400">•</span>
|
|
|
|
|
|
|
|
|
|
|
|
<ProjectInlineSelect
|
|
|
|
<div className="flex items-center min-w-[170px] ">
|
|
|
|
projects={projects}
|
|
|
|
<div className="flex flex-1 items-center gap-1">
|
|
|
|
value={state.projectId}
|
|
|
|
<ProjectInlineSelect
|
|
|
|
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))}
|
|
|
|
projects={projects}
|
|
|
|
placeholder={t.timesheet?.projectLabel || "Project"}
|
|
|
|
value={state.projectId}
|
|
|
|
portalOwnerId={portalOwnerId}
|
|
|
|
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))}
|
|
|
|
className="max-w-[180px]"
|
|
|
|
placeholder={t.timesheet?.projectLabel || "Project"}
|
|
|
|
/>
|
|
|
|
portalOwnerId={portalOwnerId}
|
|
|
|
|
|
|
|
className=""
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{selectedProject && (
|
|
|
|
{selectedProject?.client?.name && (
|
|
|
|
<span className="ms-2 shrink-0 truncate text-sm text-slate-400 dark:text-slate-500">
|
|
|
|
<span className="max-w-[136px] shrink-0 truncate text-sm text-slate-400 dark:text-slate-500" title={selectedProject.client.name}>
|
|
|
|
- {selectedProject.client?.name || ""}
|
|
|
|
- {selectedProject.client.name}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
<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>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div className="min-w-0 pe-2" dir="ltr">
|
|
|
|
|
|
|
|
<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
|
|
|
|
|
|
|
|
compactDisplayMode="chips"
|
|
|
|
|
|
|
|
portalOwnerId={portalOwnerId}
|
|
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
|
|
buttonClassName="w-full max-w-[220px] justify-start overflow-hidden"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="w-10">
|
|
|
|
<div className="w-10">
|
|
|
|
<BillableIconButton
|
|
|
|
<BillableIconButton
|
|
|
|
checked={state.isBillable}
|
|
|
|
checked={state.isBillable}
|
|
|
|
@@ -1224,6 +1382,8 @@ function RecordedEntryCard({
|
|
|
|
onDelete,
|
|
|
|
onDelete,
|
|
|
|
onRestart,
|
|
|
|
onRestart,
|
|
|
|
onEntryUpdated,
|
|
|
|
onEntryUpdated,
|
|
|
|
|
|
|
|
variant = "desktop",
|
|
|
|
|
|
|
|
lang,
|
|
|
|
}: {
|
|
|
|
}: {
|
|
|
|
entry: TimeEntry;
|
|
|
|
entry: TimeEntry;
|
|
|
|
t: any;
|
|
|
|
t: any;
|
|
|
|
@@ -1232,6 +1392,8 @@ function RecordedEntryCard({
|
|
|
|
onDelete: (entry: TimeEntry) => void;
|
|
|
|
onDelete: (entry: TimeEntry) => void;
|
|
|
|
onRestart: (entry: TimeEntry) => void;
|
|
|
|
onRestart: (entry: TimeEntry) => void;
|
|
|
|
onEntryUpdated: (entry: TimeEntry) => void;
|
|
|
|
onEntryUpdated: (entry: TimeEntry) => void;
|
|
|
|
|
|
|
|
variant?: "desktop" | "tablet";
|
|
|
|
|
|
|
|
lang: "en" | "fa";
|
|
|
|
}) {
|
|
|
|
}) {
|
|
|
|
const [draft, setDraft] = useState<EntryFormState>(() => buildEntryFormState(entry));
|
|
|
|
const [draft, setDraft] = useState<EntryFormState>(() => buildEntryFormState(entry));
|
|
|
|
const [validationMessage, setValidationMessage] = useState("");
|
|
|
|
const [validationMessage, setValidationMessage] = useState("");
|
|
|
|
@@ -1243,6 +1405,16 @@ function RecordedEntryCard({
|
|
|
|
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";
|
|
|
|
const saveSuccessText = timesheetCopy.saveSuccess || "Time entry saved";
|
|
|
|
const saveSuccessText = timesheetCopy.saveSuccess || "Time entry saved";
|
|
|
|
|
|
|
|
const deletedProjectLabel = t.timesheet?.deletedProjectLabel || "Deleted project";
|
|
|
|
|
|
|
|
const deletedTagLabel = t.timesheet?.deletedTagLabel || "Deleted tag";
|
|
|
|
|
|
|
|
const editorProjects = useMemo(
|
|
|
|
|
|
|
|
() => buildProjectOptionsForEntry(projects, entry, draft.projectId, deletedProjectLabel),
|
|
|
|
|
|
|
|
[deletedProjectLabel, draft.projectId, entry, projects],
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
const editorTags = useMemo(
|
|
|
|
|
|
|
|
() => buildTagOptionsForEntry(tags, entry, draft.tags),
|
|
|
|
|
|
|
|
[draft.tags, entry, tags],
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
const nextDraft = buildEntryFormState(entry);
|
|
|
|
const nextDraft = buildEntryFormState(entry);
|
|
|
|
@@ -1362,16 +1534,71 @@ function RecordedEntryCard({
|
|
|
|
}, 0);
|
|
|
|
}, 0);
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (variant === "tablet") {
|
|
|
|
|
|
|
|
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="space-y-4">
|
|
|
|
|
|
|
|
<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={editorProjects}
|
|
|
|
|
|
|
|
tags={editorTags}
|
|
|
|
|
|
|
|
t={t}
|
|
|
|
|
|
|
|
isRtl={false}
|
|
|
|
|
|
|
|
portalOwnerId={editorOwnerId}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between gap-3 border-t border-slate-200 pt-3 dark:border-slate-800">
|
|
|
|
|
|
|
|
<div className="text-sm text-slate-500 dark:text-slate-400">
|
|
|
|
|
|
|
|
{formatDateTime(entry.start_time, lang)}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
|
|
<div className="text-sm font-semibold text-slate-900 dark:text-white">{formatDuration(entry)}</div>
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
|
|
onClick={() => onRestart(entry)}
|
|
|
|
|
|
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
|
|
|
|
|
|
|
|
title="Start from this entry"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<Play className="h-4 w-4" />
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
|
|
onClick={() => onDelete(entry)}
|
|
|
|
|
|
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-red-200 bg-red-50 text-red-600 transition-colors hover:bg-red-100 dark:border-red-500/25 dark:bg-red-500/10 dark:text-red-400 dark:hover:bg-red-500/15"
|
|
|
|
|
|
|
|
title={t.actions?.delete || "Delete"}
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{validationMessage && (
|
|
|
|
|
|
|
|
<div className="px-1 pb-1 pt-2">
|
|
|
|
|
|
|
|
<p className="text-xs font-medium text-amber-600 dark:text-amber-400">{validationMessage}</p>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
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 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">
|
|
|
|
<div className="flex min-w-0 items-center">
|
|
|
|
<EntryEditorFields
|
|
|
|
<EntryEditorFields
|
|
|
|
state={draft}
|
|
|
|
state={draft}
|
|
|
|
onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
|
|
|
|
onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
|
|
|
|
onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
|
|
|
onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
|
|
|
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
|
|
|
|
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
|
|
|
|
projects={projects}
|
|
|
|
projects={editorProjects}
|
|
|
|
tags={tags}
|
|
|
|
tags={editorTags}
|
|
|
|
t={t}
|
|
|
|
t={t}
|
|
|
|
isRtl={false}
|
|
|
|
isRtl={false}
|
|
|
|
compact
|
|
|
|
compact
|
|
|
|
@@ -1413,6 +1640,7 @@ function MobileRecordedEntryCard({
|
|
|
|
onEdit,
|
|
|
|
onEdit,
|
|
|
|
onDelete,
|
|
|
|
onDelete,
|
|
|
|
onRequestRestart,
|
|
|
|
onRequestRestart,
|
|
|
|
|
|
|
|
lang,
|
|
|
|
}: {
|
|
|
|
}: {
|
|
|
|
entry: TimeEntry;
|
|
|
|
entry: TimeEntry;
|
|
|
|
t: any;
|
|
|
|
t: any;
|
|
|
|
@@ -1421,9 +1649,12 @@ function MobileRecordedEntryCard({
|
|
|
|
onEdit: (entry: TimeEntry) => void;
|
|
|
|
onEdit: (entry: TimeEntry) => void;
|
|
|
|
onDelete: (entry: TimeEntry) => void;
|
|
|
|
onDelete: (entry: TimeEntry) => void;
|
|
|
|
onRequestRestart: (entry: TimeEntry) => void;
|
|
|
|
onRequestRestart: (entry: TimeEntry) => void;
|
|
|
|
|
|
|
|
lang: "en" | "fa";
|
|
|
|
}) {
|
|
|
|
}) {
|
|
|
|
const project = projects.find((item) => item.id === entry.project);
|
|
|
|
const deletedProjectLabel = t.timesheet?.deletedProjectLabel || "Deleted project";
|
|
|
|
const entryTags = tags.filter((tag) => entry.tags.includes(tag.id));
|
|
|
|
const deletedTagLabel = t.timesheet?.deletedTagLabel || "Deleted tag";
|
|
|
|
|
|
|
|
const project = getProjectDisplayDetails(entry, projects);
|
|
|
|
|
|
|
|
const entryTags = getTagDisplayDetails(entry, tags);
|
|
|
|
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);
|
|
|
|
@@ -1554,18 +1785,20 @@ function MobileRecordedEntryCard({
|
|
|
|
{project && (
|
|
|
|
{project && (
|
|
|
|
<span className="inline-flex min-w-0 items-center gap-2 text-xs">
|
|
|
|
<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="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 className={`max-w-[10rem] truncate font-medium ${project.isDeleted ? "italic text-slate-500 dark:text-slate-400" : "text-sky-600 dark:text-sky-400"}`}>
|
|
|
|
|
|
|
|
{project.isDeleted ? buildDeletedProjectLabel(project.name, deletedProjectLabel) : project.name}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
{project?.client?.name && (
|
|
|
|
{project?.clientName && (
|
|
|
|
<span className="max-w-[8rem] truncate text-xs text-slate-500 dark:text-slate-400">
|
|
|
|
<span className="max-w-[8rem] truncate text-xs text-slate-500 dark:text-slate-400">
|
|
|
|
- {project.client.name}
|
|
|
|
- {project.clientName}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</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">
|
|
|
|
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
|
|
|
|
<span>{formatTimeOnly(entry.start_time)} - {formatTimeOnly(entry.end_time)}</span>
|
|
|
|
<span>{formatTimeOnly(entry.start_time, lang)} - {formatTimeOnly(entry.end_time, lang)}</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1578,7 +1811,9 @@ function MobileRecordedEntryCard({
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
|
|
{entryTags.length > 0 && (
|
|
|
|
{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">
|
|
|
|
<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(" | ")}
|
|
|
|
{entryTags
|
|
|
|
|
|
|
|
.map((tag) => (tag.isDeleted ? buildDeletedTagLabel(tag.name, deletedTagLabel) : tag.name))
|
|
|
|
|
|
|
|
.join(" | ")}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
{entry.is_billable && (
|
|
|
|
{entry.is_billable && (
|
|
|
|
@@ -1662,6 +1897,8 @@ export default function Timesheet() {
|
|
|
|
fromFilterPrefix?: string;
|
|
|
|
fromFilterPrefix?: string;
|
|
|
|
toFilterPrefix?: string;
|
|
|
|
toFilterPrefix?: string;
|
|
|
|
restartConfirmMessage?: string;
|
|
|
|
restartConfirmMessage?: string;
|
|
|
|
|
|
|
|
deletedProjectLabel?: string;
|
|
|
|
|
|
|
|
deletedTagLabel?: string;
|
|
|
|
}) || {};
|
|
|
|
}) || {};
|
|
|
|
|
|
|
|
|
|
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
|
|
@@ -1708,6 +1945,24 @@ export default function Timesheet() {
|
|
|
|
const [isDiscardingTimer, setIsDiscardingTimer] = useState(false);
|
|
|
|
const [isDiscardingTimer, setIsDiscardingTimer] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
const runningEntry = activeRunningEntry;
|
|
|
|
const runningEntry = activeRunningEntry;
|
|
|
|
|
|
|
|
const deletedProjectLabel = extendedTimesheet.deletedProjectLabel || "Deleted project";
|
|
|
|
|
|
|
|
const deletedTagLabel = extendedTimesheet.deletedTagLabel || "Deleted tag";
|
|
|
|
|
|
|
|
const runningTimerProjects = useMemo(
|
|
|
|
|
|
|
|
() => buildProjectOptionsForEntry(projects, runningEntry, timerDraft.projectId, deletedProjectLabel),
|
|
|
|
|
|
|
|
[deletedProjectLabel, projects, runningEntry, timerDraft.projectId],
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
const runningTimerTags = useMemo(
|
|
|
|
|
|
|
|
() => buildTagOptionsForEntry(tags, runningEntry, timerDraft.tags),
|
|
|
|
|
|
|
|
[runningEntry, tags, timerDraft.tags],
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
const modalProjects = useMemo(
|
|
|
|
|
|
|
|
() => buildProjectOptionsForEntry(projects, editingEntry, formState.projectId, deletedProjectLabel),
|
|
|
|
|
|
|
|
[deletedProjectLabel, editingEntry, formState.projectId, projects],
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
const modalTags = useMemo(
|
|
|
|
|
|
|
|
() => buildTagOptionsForEntry(tags, editingEntry, formState.tags),
|
|
|
|
|
|
|
|
[editingEntry, formState.tags, tags],
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
if (!runningEntry) return;
|
|
|
|
if (!runningEntry) return;
|
|
|
|
@@ -2016,11 +2271,15 @@ export default function Timesheet() {
|
|
|
|
if (!activeWorkspace?.id || runningEntry) return;
|
|
|
|
if (!activeWorkspace?.id || runningEntry) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
|
|
|
|
const restartProjectId = entry.project_details?.is_deleted ? null : entry.project;
|
|
|
|
|
|
|
|
const restartTagIds = (entry.tag_details || [])
|
|
|
|
|
|
|
|
.filter((tag) => !tag.is_deleted)
|
|
|
|
|
|
|
|
.map((tag) => tag.id);
|
|
|
|
await createTimeEntry({
|
|
|
|
await createTimeEntry({
|
|
|
|
workspace_id: activeWorkspace.id,
|
|
|
|
workspace_id: activeWorkspace.id,
|
|
|
|
description: entry.description,
|
|
|
|
description: entry.description,
|
|
|
|
project_id: entry.project,
|
|
|
|
project_id: restartProjectId,
|
|
|
|
tags: entry.tags,
|
|
|
|
tags: restartTagIds,
|
|
|
|
is_billable: entry.is_billable,
|
|
|
|
is_billable: entry.is_billable,
|
|
|
|
start_time: new Date().toISOString(),
|
|
|
|
start_time: new Date().toISOString(),
|
|
|
|
});
|
|
|
|
});
|
|
|
|
@@ -2166,10 +2425,10 @@ export default function Timesheet() {
|
|
|
|
<div
|
|
|
|
<div
|
|
|
|
ref={desktopTimerRef}
|
|
|
|
ref={desktopTimerRef}
|
|
|
|
onBlurCapture={handleTimerBlurCapture}
|
|
|
|
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"
|
|
|
|
className="mb-4 hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 xl:block"
|
|
|
|
>
|
|
|
|
>
|
|
|
|
<div className="flex min-w-[1040px] items-center h-20 px-3">
|
|
|
|
<div className="flex min-w-0 items-center gap-2 px-3 py-3">
|
|
|
|
<div className="min-w-[360px] flex-1">
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
<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?"}
|
|
|
|
@@ -2185,18 +2444,18 @@ export default function Timesheet() {
|
|
|
|
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
|
|
|
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
|
|
|
options={[
|
|
|
|
options={[
|
|
|
|
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
|
|
|
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
|
|
|
...projects.map((project) => ({ value: project.id, label: project.name })),
|
|
|
|
...runningTimerProjects.map((project) => ({ value: project.id, label: project.name })),
|
|
|
|
]}
|
|
|
|
]}
|
|
|
|
className="min-w-[170px]"
|
|
|
|
className="min-w-[190px] max-w-[220px]"
|
|
|
|
buttonClassName="h-12 w-full rounded-none border-0 bg-transparent px-3 text-sm text-sky-600 shadow-none outline-none dark:bg-transparent dark:text-sky-400 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
|
|
buttonClassName="h-12 w-full rounded-none border-0 bg-transparent px-3 text-sm text-sky-600 shadow-none outline-none dark:bg-transparent dark:text-sky-400 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
|
|
disabled={isStartingTimer}
|
|
|
|
disabled={isStartingTimer}
|
|
|
|
portalOwnerId={timerEditorOwnerId}
|
|
|
|
portalOwnerId={timerEditorOwnerId}
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex shrink-0 items-center">
|
|
|
|
<div className="flex min-w-0 shrink items-center">
|
|
|
|
<TagMultiSelect
|
|
|
|
<TagMultiSelect
|
|
|
|
tags={tags}
|
|
|
|
tags={runningTimerTags}
|
|
|
|
selectedTags={timerDraft.tags}
|
|
|
|
selectedTags={timerDraft.tags}
|
|
|
|
onToggleTag={(tagId) =>
|
|
|
|
onToggleTag={(tagId) =>
|
|
|
|
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
|
|
|
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
|
|
|
@@ -2205,6 +2464,8 @@ export default function Timesheet() {
|
|
|
|
title={t.tags?.title || "Tags"}
|
|
|
|
title={t.tags?.title || "Tags"}
|
|
|
|
compact
|
|
|
|
compact
|
|
|
|
portalOwnerId={timerEditorOwnerId}
|
|
|
|
portalOwnerId={timerEditorOwnerId}
|
|
|
|
|
|
|
|
className="max-w-[240px]"
|
|
|
|
|
|
|
|
buttonClassName="max-w-[240px]"
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
@@ -2264,6 +2525,106 @@ export default function Timesheet() {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
|
|
onBlurCapture={handleTimerBlurCapture}
|
|
|
|
|
|
|
|
className="mb-4 hidden rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-950 md:block xl: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-11 border-slate-200 bg-slate-50 text-sm dark:border-slate-700 dark:bg-slate-900"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(180px,220px)_auto]">
|
|
|
|
|
|
|
|
<Select
|
|
|
|
|
|
|
|
value={timerDraft.projectId}
|
|
|
|
|
|
|
|
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
|
|
|
|
|
|
|
options={[
|
|
|
|
|
|
|
|
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
|
|
|
|
|
|
|
...runningTimerProjects.map((project) => ({ value: project.id, label: project.name })),
|
|
|
|
|
|
|
|
]}
|
|
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
|
|
buttonClassName="h-11 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 min-w-0 items-center">
|
|
|
|
|
|
|
|
<TagMultiSelect
|
|
|
|
|
|
|
|
tags={runningTimerTags}
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
|
|
|
className="max-w-full"
|
|
|
|
|
|
|
|
buttonClassName="max-w-full"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-start lg:justify-end">
|
|
|
|
|
|
|
|
<div className="flex h-11 min-w-[132px] items-center justify-center rounded-md border border-slate-200 bg-slate-50 px-4 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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
|
|
|
<BillableIconButton
|
|
|
|
|
|
|
|
checked={timerDraft.isBillable}
|
|
|
|
|
|
|
|
onChange={(checked) => setTimerDraft((current) => ({ ...current, isBillable: checked }))}
|
|
|
|
|
|
|
|
label={t.timesheet?.billable || "Billable"}
|
|
|
|
|
|
|
|
disabled={isStartingTimer}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
|
|
|
|
|
|
{runningEntry ? (
|
|
|
|
|
|
|
|
<>
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
|
|
onClick={() => void handleStop(runningEntry)}
|
|
|
|
|
|
|
|
className="h-11 w-11 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-11 w-11 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-11 w-11 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
|
|
|
|
<div
|
|
|
|
ref={mobileTimerRef}
|
|
|
|
ref={mobileTimerRef}
|
|
|
|
onBlurCapture={handleTimerBlurCapture}
|
|
|
|
onBlurCapture={handleTimerBlurCapture}
|
|
|
|
@@ -2284,7 +2645,7 @@ export default function Timesheet() {
|
|
|
|
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
|
|
|
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
|
|
|
options={[
|
|
|
|
options={[
|
|
|
|
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
|
|
|
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
|
|
|
...projects.map((project) => ({ value: project.id, label: project.name })),
|
|
|
|
...runningTimerProjects.map((project) => ({ value: project.id, label: project.name })),
|
|
|
|
]}
|
|
|
|
]}
|
|
|
|
className="w-full"
|
|
|
|
className="w-full"
|
|
|
|
buttonClassName="h-10 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
|
|
buttonClassName="h-10 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
|
|
@@ -2300,7 +2661,7 @@ export default function Timesheet() {
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
|
|
<TagMultiSelect
|
|
|
|
<TagMultiSelect
|
|
|
|
tags={tags}
|
|
|
|
tags={runningTimerTags}
|
|
|
|
selectedTags={timerDraft.tags}
|
|
|
|
selectedTags={timerDraft.tags}
|
|
|
|
onToggleTag={(tagId) =>
|
|
|
|
onToggleTag={(tagId) =>
|
|
|
|
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
|
|
|
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
|
|
|
@@ -2428,7 +2789,7 @@ export default function Timesheet() {
|
|
|
|
<div>
|
|
|
|
<div>
|
|
|
|
{day.entries.map((entry) => (
|
|
|
|
{day.entries.map((entry) => (
|
|
|
|
<div key={entry.id}>
|
|
|
|
<div key={entry.id}>
|
|
|
|
<div className="hidden md:block">
|
|
|
|
<div className="hidden xl:block">
|
|
|
|
<RecordedEntryCard
|
|
|
|
<RecordedEntryCard
|
|
|
|
entry={entry}
|
|
|
|
entry={entry}
|
|
|
|
t={t}
|
|
|
|
t={t}
|
|
|
|
@@ -2437,6 +2798,20 @@ export default function Timesheet() {
|
|
|
|
onDelete={openDeleteModal}
|
|
|
|
onDelete={openDeleteModal}
|
|
|
|
onRestart={handleRestartFromEntry}
|
|
|
|
onRestart={handleRestartFromEntry}
|
|
|
|
onEntryUpdated={handleEntryUpdated}
|
|
|
|
onEntryUpdated={handleEntryUpdated}
|
|
|
|
|
|
|
|
lang={lang}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div className="hidden md:block xl:hidden">
|
|
|
|
|
|
|
|
<RecordedEntryCard
|
|
|
|
|
|
|
|
entry={entry}
|
|
|
|
|
|
|
|
t={t}
|
|
|
|
|
|
|
|
projects={projects}
|
|
|
|
|
|
|
|
tags={tags}
|
|
|
|
|
|
|
|
onDelete={openDeleteModal}
|
|
|
|
|
|
|
|
onRestart={handleRestartFromEntry}
|
|
|
|
|
|
|
|
onEntryUpdated={handleEntryUpdated}
|
|
|
|
|
|
|
|
variant="tablet"
|
|
|
|
|
|
|
|
lang={lang}
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="md:hidden">
|
|
|
|
<div className="md:hidden">
|
|
|
|
@@ -2448,6 +2823,7 @@ export default function Timesheet() {
|
|
|
|
onEdit={openEditModal}
|
|
|
|
onEdit={openEditModal}
|
|
|
|
onDelete={openDeleteModal}
|
|
|
|
onDelete={openDeleteModal}
|
|
|
|
onRequestRestart={openRestartModal}
|
|
|
|
onRequestRestart={openRestartModal}
|
|
|
|
|
|
|
|
lang={lang}
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
@@ -2488,8 +2864,8 @@ export default function Timesheet() {
|
|
|
|
state={formState}
|
|
|
|
state={formState}
|
|
|
|
onChange={(patch) => setFormState((current) => ({ ...current, ...patch }))}
|
|
|
|
onChange={(patch) => setFormState((current) => ({ ...current, ...patch }))}
|
|
|
|
onToggleTag={(tagId) => setFormState((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
|
|
|
onToggleTag={(tagId) => setFormState((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
|
|
|
projects={projects}
|
|
|
|
projects={modalProjects}
|
|
|
|
tags={tags}
|
|
|
|
tags={modalTags}
|
|
|
|
t={t}
|
|
|
|
t={t}
|
|
|
|
isRtl={isRtl}
|
|
|
|
isRtl={isRtl}
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
|