From a770272ce22fc20b458e9cec469142e3ebdafe54 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Mon, 27 Apr 2026 22:58:27 +0330 Subject: [PATCH] fix(timesheet): improve tablet layout and deleted relation handling --- src/api/projects.ts | 23 +- src/api/tags.ts | 1 + src/api/timeEntries.ts | 16 + .../timesheet/TimesheetFilterBar.tsx | 2 +- src/locales/en.ts | 2 + src/locales/fa.ts | 10 +- src/pages/Timesheet.tsx | 540 +++++++++++++++--- 7 files changed, 496 insertions(+), 98 deletions(-) diff --git a/src/api/projects.ts b/src/api/projects.ts index e4b1e4f..2514613 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -25,17 +25,18 @@ export interface ProjectMembership { is_active: boolean; } -export interface Project { - id: string; - name: string; - description: string; - color: string; - is_archived: boolean; - workspace: string; - client: ProjectClient | null; - my_role?: string; - members?: ProjectMembership[]; -} +export interface Project { + id: string; + name: string; + description: string; + color: string; + is_archived: boolean; + is_deleted?: boolean; + workspace: string; + client: ProjectClient | null; + my_role?: string; + members?: ProjectMembership[]; +} export interface ProjectPayload { name: string; diff --git a/src/api/tags.ts b/src/api/tags.ts index 75fda29..f928383 100644 --- a/src/api/tags.ts +++ b/src/api/tags.ts @@ -5,6 +5,7 @@ export interface Tag { workspace: string; name: string; color: string; + is_deleted?: boolean; created_at: string; updated_at: string; } diff --git a/src/api/timeEntries.ts b/src/api/timeEntries.ts index 3f1fc75..b3afc9b 100644 --- a/src/api/timeEntries.ts +++ b/src/api/timeEntries.ts @@ -1,15 +1,31 @@ import { authFetch } from "./client"; +export interface TimeEntryProjectDetails { + id: string; + name: string; + is_deleted: boolean; + client_name: string | null; +} + +export interface TimeEntryTagDetails { + id: string; + name: string; + color: string; + is_deleted: boolean; +} + export interface TimeEntry { id: string; workspace: string; user: string; project: string | null; + project_details: TimeEntryProjectDetails | null; description: string; start_time: string; end_time: string | null; duration: string | null; tags: string[]; + tag_details: TimeEntryTagDetails[]; is_billable: boolean; hourly_rate: string | null; currency: string; diff --git a/src/components/timesheet/TimesheetFilterBar.tsx b/src/components/timesheet/TimesheetFilterBar.tsx index d30b574..6bb5f39 100644 --- a/src/components/timesheet/TimesheetFilterBar.tsx +++ b/src/components/timesheet/TimesheetFilterBar.tsx @@ -330,7 +330,7 @@ export default function TimesheetFilterBar({ {isExpanded && (
-
+
} label={labels?.customFrom || "From date"}> `مرور گزارش فعالیت برای ${workspaceName}`, diff --git a/src/pages/Timesheet.tsx b/src/pages/Timesheet.tsx index 434c32a..1707560 100644 --- a/src/pages/Timesheet.tsx +++ b/src/pages/Timesheet.tsx @@ -461,6 +461,124 @@ const buildPayloadFromState = ( 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({ label, value, @@ -532,6 +650,9 @@ function TagMultiSelect({ title, compact = false, portalOwnerId, + className = "", + buttonClassName = "", + compactDisplayMode = "summary", }: { tags: Tag[]; selectedTags: string[]; @@ -540,6 +661,9 @@ function TagMultiSelect({ title: string; compact?: boolean; portalOwnerId?: string; + className?: string; + buttonClassName?: string; + compactDisplayMode?: "summary" | "chips"; }) { const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -588,12 +712,10 @@ function TagMultiSelect({ if (isOpen) { window.addEventListener("resize", closeOnViewportChange); - window.addEventListener("scroll", closeOnViewportChange, true); } return () => { window.removeEventListener("resize", closeOnViewportChange); - window.removeEventListener("scroll", closeOnViewportChange, true); }; }, [isOpen]); @@ -603,7 +725,8 @@ function TagMultiSelect({ } }, [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 normalizedSearch = searchQuery.trim().toLowerCase(); const filteredTags = normalizedSearch @@ -618,7 +741,7 @@ function TagMultiSelect({ : title; return ( -
+
{!compact &&

{title}

}
{filteredTags.map((tag) => { - const selected = selectedTags.includes(tag.id); - return ( - - ); + const selected = selectedTags.includes(tag.id); + const isUnavailable = Boolean(tag.is_deleted) && !selected; + return ( + + ); })} {filteredTags.length === 0 && (
@@ -786,13 +934,13 @@ function ProjectInlineSelect({ const label = selectedProject?.name || placeholder; return ( -
+
); })} @@ -1062,47 +1215,52 @@ function EntryEditorFields({ if (compact) { const selectedProject = projects.find((project) => project.id === state.projectId); return ( -
+
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" + 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" /> - (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))} - placeholder={t.timesheet?.projectLabel || "Project"} - portalOwnerId={portalOwnerId} - className="max-w-[180px]" - /> +
+
+ (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))} + placeholder={t.timesheet?.projectLabel || "Project"} + portalOwnerId={portalOwnerId} + className="" + /> - {selectedProject && ( - - - {selectedProject.client?.name || ""} - - )} - -
- -
- + {selectedProject?.client?.name && ( + + - {selectedProject.client.name} + + )} +
+
+ +
+
void; onRestart: (entry: TimeEntry) => void; onEntryUpdated: (entry: TimeEntry) => void; + variant?: "desktop" | "tablet"; + lang: "en" | "fa"; }) { const [draft, setDraft] = useState(() => buildEntryFormState(entry)); const [validationMessage, setValidationMessage] = useState(""); @@ -1243,6 +1405,16 @@ function RecordedEntryCard({ 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"; + 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(() => { const nextDraft = buildEntryFormState(entry); @@ -1362,16 +1534,71 @@ function RecordedEntryCard({ }, 0); }; + if (variant === "tablet") { + return ( +
+
+ 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} + /> + +
+
+ {formatDateTime(entry.start_time, lang)} +
+
+
{formatDuration(entry)}
+ + +
+
+
+ + {validationMessage && ( +
+

{validationMessage}

+
+ )} +
+ ); + } + return (
-
+
setDraft((current) => ({ ...current, ...patch }))} onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))} onProjectChange={(projectId) => void commitPatchedDraft({ projectId })} - projects={projects} - tags={tags} + projects={editorProjects} + tags={editorTags} t={t} isRtl={false} compact @@ -1413,6 +1640,7 @@ function MobileRecordedEntryCard({ onEdit, onDelete, onRequestRestart, + lang, }: { entry: TimeEntry; t: any; @@ -1421,9 +1649,12 @@ function MobileRecordedEntryCard({ onEdit: (entry: TimeEntry) => void; onDelete: (entry: TimeEntry) => void; onRequestRestart: (entry: TimeEntry) => void; + lang: "en" | "fa"; }) { - const project = projects.find((item) => item.id === entry.project); - const entryTags = tags.filter((tag) => entry.tags.includes(tag.id)); + const deletedProjectLabel = t.timesheet?.deletedProjectLabel || "Deleted project"; + const deletedTagLabel = t.timesheet?.deletedTagLabel || "Deleted tag"; + const project = getProjectDisplayDetails(entry, projects); + const entryTags = getTagDisplayDetails(entry, tags); const wrapperRef = useRef(null); const buttonRef = useRef(null); const dropdownRef = useRef(null); @@ -1554,18 +1785,20 @@ function MobileRecordedEntryCard({ {project && ( {"\u2022"} - {project.name} + + {project.isDeleted ? buildDeletedProjectLabel(project.name, deletedProjectLabel) : project.name} + )} - {project?.client?.name && ( + {project?.clientName && ( - - {project.client.name} + - {project.clientName} )}
- {formatTimeOnly(entry.start_time)} - {formatTimeOnly(entry.end_time)} + {formatTimeOnly(entry.start_time, lang)} - {formatTimeOnly(entry.end_time, lang)}
@@ -1578,7 +1811,9 @@ function MobileRecordedEntryCard({
{entryTags.length > 0 && ( - {entryTags.map((tag) => tag.name).join(" | ")} + {entryTags + .map((tag) => (tag.isDeleted ? buildDeletedTagLabel(tag.name, deletedTagLabel) : tag.name)) + .join(" | ")} )} {entry.is_billable && ( @@ -1662,6 +1897,8 @@ export default function Timesheet() { fromFilterPrefix?: string; toFilterPrefix?: string; restartConfirmMessage?: string; + deletedProjectLabel?: string; + deletedTagLabel?: string; }) || {}; const [projects, setProjects] = useState([]); @@ -1708,6 +1945,24 @@ export default function Timesheet() { const [isDiscardingTimer, setIsDiscardingTimer] = useState(false); 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(() => { if (!runningEntry) return; @@ -2016,11 +2271,15 @@ export default function Timesheet() { if (!activeWorkspace?.id || runningEntry) return; 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({ workspace_id: activeWorkspace.id, description: entry.description, - project_id: entry.project, - tags: entry.tags, + project_id: restartProjectId, + tags: restartTagIds, is_billable: entry.is_billable, start_time: new Date().toISOString(), }); @@ -2166,10 +2425,10 @@ export default function Timesheet() {
-
-
+
+
setTimerDraft((current) => ({ ...current, projectId: String(value) }))} options={[ { 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" disabled={isStartingTimer} portalOwnerId={timerEditorOwnerId} />
-
+
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) })) @@ -2205,6 +2464,8 @@ export default function Timesheet() { title={t.tags?.title || "Tags"} compact portalOwnerId={timerEditorOwnerId} + className="max-w-[240px]" + buttonClassName="max-w-[240px]" />
@@ -2264,6 +2525,106 @@ export default function Timesheet() {
+
+
+ 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" + /> + +
+