feat(workspaces): add current user rates panel

This commit is contained in:
2026-05-23 20:44:39 +03:30
parent 35c46ea460
commit 993dffb51d
5 changed files with 333 additions and 45 deletions

View File

@@ -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<MyWorkspaceRatesResponse | null>(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 (
<div className="flex min-h-[calc(100vh-73px)] flex-col bg-slate-100/70 p-4 dark:bg-slate-900">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.timesheet?.title || 'Timesheets'}</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.timesheet?.description(activeWorkspace?.name || "-") || 'Manage your Timesheet'}</p>
</div>
</div>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.timesheet?.title || 'Timesheets'}</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.timesheet?.description(activeWorkspace?.name || "-") || 'Manage your Timesheet'}</p>
</div>
<Button type="button" variant="secondary" onClick={() => void openRatesPanel()} className="gap-2">
<Banknote className="h-4 w-4" />
{t.rates?.myRatesTitle || "My rates"}
</Button>
</div>
<div
ref={desktopTimerRef}
@@ -3066,8 +3100,8 @@ export default function Timesheet() {
</Modal>
)}
{discardTimerModal.entry && (
<Modal
{discardTimerModal.entry && (
<Modal
isOpen={discardTimerModal.isOpen}
onClose={closeDiscardTimerModal}
title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
@@ -3096,8 +3130,15 @@ export default function Timesheet() {
</p>
</div>
</div>
</Modal>
)}
</div>
);
}
</Modal>
)}
<WorkspaceRatesPanel
open={isRatesPanelOpen}
data={myRates}
isLoading={isRatesPanelLoading}
onClose={() => setIsRatesPanelOpen(false)}
/>
</div>
);
}

View File

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

View File

@@ -432,11 +432,19 @@ export default function EditWorkspace() {
<div className="w-full rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-2/3 lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden">
<div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
{ t.workspace?.members || "Members" }
</h2>
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
{ t.workspace?.members || "Members" }
</h2>
<div className="mb-4 flex items-start gap-3 rounded-xl border border-sky-100 bg-sky-50/80 px-4 py-3 text-sm text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-100">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="leading-6">
{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."}
</p>
</div>
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
<div className="space-y-3">
<Input
type="text"