feat(workspaces): add current user rates panel
This commit is contained in:
@@ -30,6 +30,36 @@ export interface WorkspaceUserRate {
|
|||||||
effective_from: string;
|
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<T> {
|
interface PaginatedResponse<T> {
|
||||||
count: number;
|
count: number;
|
||||||
next: string | null;
|
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<MyWorkspaceRatesResponse>;
|
||||||
|
};
|
||||||
|
|
||||||
export const createWorkspaceUserRate = async (data: {
|
export const createWorkspaceUserRate = async (data: {
|
||||||
workspace_id: string;
|
workspace_id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
|||||||
196
src/components/rates/WorkspaceRatesPanel.tsx
Normal file
196
src/components/rates/WorkspaceRatesPanel.tsx
Normal file
@@ -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<typeof setTimeout> | 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 (
|
||||||
|
<div className="fixed inset-0 z-[80] flex items-end lg:items-stretch lg:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`absolute inset-0 cursor-pointer bg-slate-950/40 backdrop-blur-[2px] transition-opacity duration-300 ${
|
||||||
|
isVisible ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close rates panel"
|
||||||
|
/>
|
||||||
|
<aside
|
||||||
|
className={`relative z-10 flex max-h-[88vh] w-full flex-col rounded-t-[2rem] bg-white shadow-2xl transition-transform duration-300 ease-out dark:bg-slate-950 lg:h-full lg:max-h-none lg:w-[34rem] lg:rounded-none lg:border-l lg:border-slate-800 ${
|
||||||
|
isVisible ? "translate-y-0 lg:translate-x-0" : "translate-y-full lg:translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t.rates?.myRatesTitle || "My rates"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{t.rates?.myRatesHint || "Project-specific rates override your workspace rate in this workspace."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="rounded-xl">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-5">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
|
||||||
|
{t.loading || "Loading..."}
|
||||||
|
</div>
|
||||||
|
) : !data ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||||
|
{t.rates?.myRatesEmpty || "No rates are available for this workspace yet."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
|
||||||
|
<Banknote className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-base font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t.rates?.workspaceRate || "Workspace rate"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{t.rates?.workspaceRateHint || "This is your default rate unless a project-specific rate overrides it."}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 text-lg font-bold text-slate-900 dark:text-white">
|
||||||
|
{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")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
||||||
|
{t.rates?.accessibleProjects || "Accessible projects"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{data.accessible_project_count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
||||||
|
{t.rates?.projectOverrides || "Project overrides"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{data.project_override_count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
||||||
|
{t.rates?.workspaceFallbackProjects || "Using workspace rate"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{data.workspace_fallback_project_count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="border-b border-slate-100 px-5 py-4 dark:border-slate-800">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t.rates?.projectSectionTitle || "Project user rates"}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{t.rates?.projectOverrideHint || "Only projects with custom overrides are listed here. Other accessible projects use your workspace rate."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.project_rates.length ? (
|
||||||
|
<div className="divide-y divide-slate-100 dark:divide-slate-800">
|
||||||
|
{data.project_rates.map((projectRate) => (
|
||||||
|
<div key={projectRate.project.id} className="flex items-start gap-4 px-5 py-4">
|
||||||
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-sky-100 text-sky-700 dark:bg-sky-500/10 dark:text-sky-300">
|
||||||
|
<FolderKanban className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||||
|
{projectRate.project.name}
|
||||||
|
</p>
|
||||||
|
{projectRate.project.client ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
|
||||||
|
<BriefcaseBusiness className="h-3 w-3" />
|
||||||
|
{projectRate.project.client.name}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-slate-900 dark:text-white">
|
||||||
|
{formatRateDisplay(
|
||||||
|
{
|
||||||
|
hourly_rate: projectRate.rate.hourly_rate,
|
||||||
|
currency: projectRate.rate.currency,
|
||||||
|
price_unit: projectRate.rate.price_unit,
|
||||||
|
},
|
||||||
|
lang,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-5 py-6 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{t.rates?.projectOverrideEmpty || "You do not have any project-specific rate overrides in this workspace."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useSearchParams } from "react-router-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 { toast } from "sonner";
|
||||||
|
|
||||||
import { getProjects, type Project } from "../api/projects";
|
import { getProjects, type Project } from "../api/projects";
|
||||||
|
import { getMyWorkspaceRates, type MyWorkspaceRatesResponse } from "../api/rates";
|
||||||
import {
|
import {
|
||||||
createTimeEntry,
|
createTimeEntry,
|
||||||
deleteTimeEntry,
|
deleteTimeEntry,
|
||||||
@@ -20,6 +21,7 @@ import { getTags, type Tag } from "../api/tags";
|
|||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import EmptyStateCard from "../components/EmptyStateCard";
|
import EmptyStateCard from "../components/EmptyStateCard";
|
||||||
import { InfiniteScroll } from "../components/InfiniteScroll";
|
import { InfiniteScroll } from "../components/InfiniteScroll";
|
||||||
|
import { WorkspaceRatesPanel } from "../components/rates/WorkspaceRatesPanel";
|
||||||
import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timesheet/TimesheetFilterBar";
|
import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timesheet/TimesheetFilterBar";
|
||||||
import JalaliDatePicker from "../components/ui/JalaliDatePicker";
|
import JalaliDatePicker from "../components/ui/JalaliDatePicker";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
@@ -2018,6 +2020,9 @@ function TimesheetSkeleton({ loadingLabel }: { loadingLabel: string }) {
|
|||||||
export default function Timesheet() {
|
export default function Timesheet() {
|
||||||
const { t, lang } = useTranslation();
|
const { t, lang } = useTranslation();
|
||||||
const { activeWorkspace } = useWorkspace();
|
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 [searchParams, setSearchParams] = useSearchParams();
|
||||||
const isRtl = lang === "fa";
|
const isRtl = lang === "fa";
|
||||||
const extendedTimesheet = (t.timesheet as {
|
const extendedTimesheet = (t.timesheet as {
|
||||||
@@ -2164,6 +2169,12 @@ export default function Timesheet() {
|
|||||||
setHasMoreHistory(false);
|
setHasMoreHistory(false);
|
||||||
}, [activeWorkspace?.id]);
|
}, [activeWorkspace?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsRatesPanelOpen(false);
|
||||||
|
setIsRatesPanelLoading(false);
|
||||||
|
setMyRates(null);
|
||||||
|
}, [activeWorkspace?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
setDebouncedSearchQuery(searchQuery);
|
setDebouncedSearchQuery(searchQuery);
|
||||||
@@ -2600,6 +2611,25 @@ export default function Timesheet() {
|
|||||||
void loadHistory({ offset: nextOffset, append: true });
|
void loadHistory({ offset: nextOffset, append: true });
|
||||||
}, [hasMoreHistory, isLoadingMore, loadHistory, nextOffset]);
|
}, [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 () => {
|
const handleDiscardTimerDraft = useCallback(async () => {
|
||||||
if (!discardTimerModal.entry || isDiscardingTimer) return;
|
if (!discardTimerModal.entry || isDiscardingTimer) return;
|
||||||
|
|
||||||
@@ -2633,6 +2663,10 @@ export default function Timesheet() {
|
|||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.timesheet?.title || 'Timesheets'}</h1>
|
<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>
|
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.timesheet?.description(activeWorkspace?.name || "-") || 'Manage your Timesheet'}</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -3098,6 +3132,13 @@ export default function Timesheet() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<WorkspaceRatesPanel
|
||||||
|
open={isRatesPanelOpen}
|
||||||
|
data={myRates}
|
||||||
|
isLoading={isRatesPanelLoading}
|
||||||
|
onClose={() => setIsRatesPanelOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import { useAppContext } from '../context/AppContext';
|
import { useAppContext } from '../context/AppContext';
|
||||||
import { useWorkspace } from '../context/WorkspaceContext';
|
import { useWorkspace } from '../context/WorkspaceContext';
|
||||||
import { useTranslation } from '../hooks/useTranslation';
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
|
import { formatRateDisplay } from '../lib/money';
|
||||||
import {
|
import {
|
||||||
CLIENTS_VIEW,
|
CLIENTS_VIEW,
|
||||||
PROJECTS_VIEW,
|
PROJECTS_VIEW,
|
||||||
@@ -188,14 +189,17 @@ export default function WorkspaceDetail() {
|
|||||||
return `${firstName}${lastName}`.trim().toUpperCase() || getMemberName(member).charAt(0).toUpperCase();
|
return `${firstName}${lastName}`.trim().toUpperCase() || getMemberName(member).charAt(0).toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatRateUnit = (rate?: WorkspaceUserRate) => {
|
const formatRateUnit = (rate?: WorkspaceUserRate) =>
|
||||||
if (!rate) return t.rates?.noRate || 'No rate';
|
rate
|
||||||
const unitLabel =
|
? formatRateDisplay(
|
||||||
lang === 'fa'
|
{
|
||||||
? rate.price_unit?.local_name || rate.price_unit?.code || rate.currency
|
hourly_rate: rate.hourly_rate,
|
||||||
: rate.price_unit?.code || rate.currency;
|
currency: rate.currency,
|
||||||
return `${rate.hourly_rate} ${unitLabel}`;
|
price_unit: rate.price_unit,
|
||||||
};
|
},
|
||||||
|
lang,
|
||||||
|
)
|
||||||
|
: (t.rates?.noRate || 'No rate');
|
||||||
|
|
||||||
const workspaceRole = workspace?.my_role;
|
const workspaceRole = workspace?.my_role;
|
||||||
const canEdit = canWorkspace(workspaceRole, WORKSPACE_EDIT);
|
const canEdit = canWorkspace(workspaceRole, WORKSPACE_EDIT);
|
||||||
|
|||||||
@@ -436,6 +436,14 @@ export default function EditWorkspace() {
|
|||||||
{ t.workspace?.members || "Members" }
|
{ t.workspace?.members || "Members" }
|
||||||
</h2>
|
</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) && (
|
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
Reference in New Issue
Block a user