From 993dffb51de20cfbfa531812b0a8bb192c154c4e Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sat, 23 May 2026 20:44:39 +0330 Subject: [PATCH] feat(workspaces): add current user rates panel --- src/api/rates.ts | 39 ++++ src/components/rates/WorkspaceRatesPanel.tsx | 196 +++++++++++++++++++ src/pages/Timesheet.tsx | 105 +++++++--- src/pages/WorkspaceDetail.tsx | 20 +- src/pages/WorkspaceEdit.tsx | 18 +- 5 files changed, 333 insertions(+), 45 deletions(-) create mode 100644 src/components/rates/WorkspaceRatesPanel.tsx diff --git a/src/api/rates.ts b/src/api/rates.ts index 4ad5b5c..247bc00 100644 --- a/src/api/rates.ts +++ b/src/api/rates.ts @@ -30,6 +30,36 @@ export interface WorkspaceUserRate { effective_from: string; } +export interface WorkspaceProjectRateView { + project: { + id: string; + name: string; + client: { id: string; name: string } | null; + }; + rate: { + id: string; + hourly_rate: string; + currency: string; + price_unit?: PriceUnit | null; + effective_from: string | null; + }; +} + +export interface MyWorkspaceRatesResponse { + workspace: { id: string; name: string }; + workspace_rate: { + id: string; + hourly_rate: string; + currency: string; + price_unit?: PriceUnit | null; + effective_from: string | null; + } | null; + accessible_project_count: number; + project_override_count: number; + workspace_fallback_project_count: number; + project_rates: WorkspaceProjectRateView[]; +} + interface PaginatedResponse { count: number; next: string | null; @@ -87,6 +117,15 @@ export const getWorkspaceUserRates = async (workspaceId: string) => { }; }; +export const getMyWorkspaceRates = async (workspaceId: string) => { + const response = await authFetch(`/api/workspaces/${workspaceId}/my-rates/`); + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to load your workspace rates"); + } + return response.json() as Promise; +}; + export const createWorkspaceUserRate = async (data: { workspace_id: string; user_id: string; diff --git a/src/components/rates/WorkspaceRatesPanel.tsx b/src/components/rates/WorkspaceRatesPanel.tsx new file mode 100644 index 0000000..c84d07e --- /dev/null +++ b/src/components/rates/WorkspaceRatesPanel.tsx @@ -0,0 +1,196 @@ +import { useEffect, useState } from "react"; +import { Banknote, BriefcaseBusiness, FolderKanban, X } from "lucide-react"; + +import type { MyWorkspaceRatesResponse } from "../../api/rates"; +import { useTranslation } from "../../hooks/useTranslation"; +import { formatRateDisplay } from "../../lib/money"; +import { Button } from "../ui/button"; + +export function WorkspaceRatesPanel({ + open, + data, + isLoading, + onClose, +}: { + open: boolean; + data: MyWorkspaceRatesResponse | null; + isLoading: boolean; + onClose: () => void; +}) { + const { t, lang } = useTranslation(); + const [shouldRender, setShouldRender] = useState(open); + const [isVisible, setIsVisible] = useState(open); + + useEffect(() => { + let timeoutId: ReturnType | null = null; + let frameId: number | null = null; + + if (open) { + setShouldRender(true); + frameId = window.requestAnimationFrame(() => setIsVisible(true)); + } else { + setIsVisible(false); + timeoutId = setTimeout(() => setShouldRender(false), 300); + } + + return () => { + if (timeoutId) clearTimeout(timeoutId); + if (frameId) window.cancelAnimationFrame(frameId); + }; + }, [open]); + + if (!shouldRender) { + return null; + } + + return ( +
+ +
+ +
+ {isLoading ? ( +
+ {t.loading || "Loading..."} +
+ ) : !data ? ( +
+ {t.rates?.myRatesEmpty || "No rates are available for this workspace yet."} +
+ ) : ( +
+
+
+
+ +
+
+

+ {t.rates?.workspaceRate || "Workspace rate"} +

+

+ {t.rates?.workspaceRateHint || "This is your default rate unless a project-specific rate overrides it."} +

+
+ {data.workspace_rate + ? formatRateDisplay( + { + hourly_rate: data.workspace_rate.hourly_rate, + currency: data.workspace_rate.currency, + price_unit: data.workspace_rate.price_unit, + }, + lang, + ) + : (t.rates?.noRate || "No rate")} +
+
+
+
+ +
+
+
+ {t.rates?.accessibleProjects || "Accessible projects"} +
+
+ {data.accessible_project_count} +
+
+
+
+ {t.rates?.projectOverrides || "Project overrides"} +
+
+ {data.project_override_count} +
+
+
+
+ {t.rates?.workspaceFallbackProjects || "Using workspace rate"} +
+
+ {data.workspace_fallback_project_count} +
+
+
+ +
+
+

+ {t.rates?.projectSectionTitle || "Project user rates"} +

+

+ {t.rates?.projectOverrideHint || "Only projects with custom overrides are listed here. Other accessible projects use your workspace rate."} +

+
+ + {data.project_rates.length ? ( +
+ {data.project_rates.map((projectRate) => ( +
+
+ +
+
+
+

+ {projectRate.project.name} +

+ {projectRate.project.client ? ( + + + {projectRate.project.client.name} + + ) : null} +
+

+ {formatRateDisplay( + { + hourly_rate: projectRate.rate.hourly_rate, + currency: projectRate.rate.currency, + price_unit: projectRate.rate.price_unit, + }, + lang, + )} +

+
+
+ ))} +
+ ) : ( +
+ {t.rates?.projectOverrideEmpty || "You do not have any project-specific rate overrides in this workspace."} +
+ )} +
+
+ )} +
+ + + ); +} diff --git a/src/pages/Timesheet.tsx b/src/pages/Timesheet.tsx index 1bd2556..b8d2634 100644 --- a/src/pages/Timesheet.tsx +++ b/src/pages/Timesheet.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useSearchParams } from "react-router-dom"; -import { CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react"; +import { Banknote, 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 { +import { getProjects, type Project } from "../api/projects"; +import { getMyWorkspaceRates, type MyWorkspaceRatesResponse } from "../api/rates"; +import { createTimeEntry, deleteTimeEntry, getTimeEntries, @@ -18,9 +19,10 @@ import { } from "../api/timeEntries"; import { getTags, type Tag } from "../api/tags"; import { Modal } from "../components/Modal"; -import EmptyStateCard from "../components/EmptyStateCard"; -import { InfiniteScroll } from "../components/InfiniteScroll"; -import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timesheet/TimesheetFilterBar"; +import EmptyStateCard from "../components/EmptyStateCard"; +import { InfiniteScroll } from "../components/InfiniteScroll"; +import { WorkspaceRatesPanel } from "../components/rates/WorkspaceRatesPanel"; +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"; @@ -2015,10 +2017,13 @@ function TimesheetSkeleton({ loadingLabel }: { loadingLabel: string }) { ); } -export default function Timesheet() { - const { t, lang } = useTranslation(); - const { activeWorkspace } = useWorkspace(); - const [searchParams, setSearchParams] = useSearchParams(); +export default function Timesheet() { + const { t, lang } = useTranslation(); + const { activeWorkspace } = useWorkspace(); + const [isRatesPanelOpen, setIsRatesPanelOpen] = useState(false); + const [isRatesPanelLoading, setIsRatesPanelLoading] = useState(false); + const [myRates, setMyRates] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); const isRtl = lang === "fa"; const extendedTimesheet = (t.timesheet as { deleteTitle?: string; @@ -2158,11 +2163,17 @@ export default function Timesheet() { void loadOptions(); }, [activeWorkspace?.id, t.timesheet?.optionsError]); - useEffect(() => { - setGroupedHistory([]); - setNextOffset(0); - setHasMoreHistory(false); - }, [activeWorkspace?.id]); + useEffect(() => { + setGroupedHistory([]); + setNextOffset(0); + setHasMoreHistory(false); + }, [activeWorkspace?.id]); + + useEffect(() => { + setIsRatesPanelOpen(false); + setIsRatesPanelLoading(false); + setMyRates(null); + }, [activeWorkspace?.id]); useEffect(() => { const timeoutId = window.setTimeout(() => { @@ -2595,10 +2606,29 @@ export default function Timesheet() { setDebouncedSearchQuery(""); }, [setSearchParams]); - const handleLoadMore = useCallback(() => { - if (!hasMoreHistory || nextOffset === null || isLoadingMore) return; - void loadHistory({ offset: nextOffset, append: true }); - }, [hasMoreHistory, isLoadingMore, loadHistory, nextOffset]); + const handleLoadMore = useCallback(() => { + if (!hasMoreHistory || nextOffset === null || isLoadingMore) return; + void loadHistory({ offset: nextOffset, append: true }); + }, [hasMoreHistory, isLoadingMore, loadHistory, nextOffset]); + + const openRatesPanel = useCallback(async () => { + if (!activeWorkspace?.id) return; + + setIsRatesPanelOpen(true); + if (myRates || isRatesPanelLoading) return; + + try { + setIsRatesPanelLoading(true); + const response = await getMyWorkspaceRates(activeWorkspace.id); + setMyRates(response); + } catch (error) { + console.error(error); + toast.error(t.rates?.projectSaveError || "Failed to load rates."); + setIsRatesPanelOpen(false); + } finally { + setIsRatesPanelLoading(false); + } + }, [activeWorkspace?.id, isRatesPanelLoading, myRates, t.rates?.projectSaveError]); const handleDiscardTimerDraft = useCallback(async () => { if (!discardTimerModal.entry || isDiscardingTimer) return; @@ -2628,12 +2658,16 @@ export default function Timesheet() { return (
-
-
-

{t.timesheet?.title || 'Timesheets'}

-

{t.timesheet?.description(activeWorkspace?.name || "-") || 'Manage your Timesheet'}

-
-
+
+
+

{t.timesheet?.title || 'Timesheets'}

+

{t.timesheet?.description(activeWorkspace?.name || "-") || 'Manage your Timesheet'}

+
+ +
)} - {discardTimerModal.entry && ( -
- - )} - - ); -} + + )} + + setIsRatesPanelOpen(false)} + /> + + ); +} diff --git a/src/pages/WorkspaceDetail.tsx b/src/pages/WorkspaceDetail.tsx index 70111e4..50f1f9d 100644 --- a/src/pages/WorkspaceDetail.tsx +++ b/src/pages/WorkspaceDetail.tsx @@ -26,6 +26,7 @@ import { import { useAppContext } from '../context/AppContext'; import { useWorkspace } from '../context/WorkspaceContext'; import { useTranslation } from '../hooks/useTranslation'; +import { formatRateDisplay } from '../lib/money'; import { CLIENTS_VIEW, PROJECTS_VIEW, @@ -188,14 +189,17 @@ export default function WorkspaceDetail() { return `${firstName}${lastName}`.trim().toUpperCase() || getMemberName(member).charAt(0).toUpperCase(); }; - const formatRateUnit = (rate?: WorkspaceUserRate) => { - if (!rate) return t.rates?.noRate || 'No rate'; - const unitLabel = - lang === 'fa' - ? rate.price_unit?.local_name || rate.price_unit?.code || rate.currency - : rate.price_unit?.code || rate.currency; - return `${rate.hourly_rate} ${unitLabel}`; - }; + const formatRateUnit = (rate?: WorkspaceUserRate) => + rate + ? formatRateDisplay( + { + hourly_rate: rate.hourly_rate, + currency: rate.currency, + price_unit: rate.price_unit, + }, + lang, + ) + : (t.rates?.noRate || 'No rate'); const workspaceRole = workspace?.my_role; const canEdit = canWorkspace(workspaceRole, WORKSPACE_EDIT); diff --git a/src/pages/WorkspaceEdit.tsx b/src/pages/WorkspaceEdit.tsx index 7befe65..f01895d 100644 --- a/src/pages/WorkspaceEdit.tsx +++ b/src/pages/WorkspaceEdit.tsx @@ -432,11 +432,19 @@ export default function EditWorkspace() {
-

- { t.workspace?.members || "Members" } -

- - {canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && ( +

+ { t.workspace?.members || "Members" } +

+ +
+ +

+ {t.workspace?.projectRateHint || + "Project-specific user rates can be managed from the Projects page. Open a project and use its access modal to set an override rate for a specific member."} +

+
+ + {canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (