feat(pricing): manage workspace member rates in edit flows

This commit is contained in:
2026-04-26 10:21:58 +03:30
parent f9dfd8826e
commit 2d843046fa
8 changed files with 665 additions and 213 deletions

107
src/api/rates.ts Normal file
View File

@@ -0,0 +1,107 @@
import { authFetch } from "./client";
export interface RateUser {
id: string;
first_name?: string;
last_name?: string;
mobile?: string;
profile_picture?: string;
avatar?: string;
name?: string;
}
export interface PriceUnit {
id: string;
code: string;
name: string;
local_name?: string;
symbol?: string;
}
export interface WorkspaceUserRate {
id: string;
workspace: string;
user: string;
user_details?: RateUser;
hourly_rate: string;
currency: string;
price_unit?: PriceUnit | null;
effective_from: string;
}
interface PaginatedResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
const ensurePaginated = async <T>(response: Response): Promise<PaginatedResponse<T>> => {
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Rate request failed");
}
const data = await response.json();
if (Array.isArray(data)) {
return { count: data.length, next: null, previous: null, results: data };
}
return {
count: data.count || data.results?.length || 0,
next: data.next || null,
previous: data.previous || null,
results: data.results || [],
};
};
export const getPriceUnits = async () => {
const response = await authFetch("/api/price-units/");
return ensurePaginated<PriceUnit>(response);
};
export const getWorkspaceUserRates = async (workspaceId: string) => {
const response = await authFetch(`/api/workspace-user-rates/?workspace=${workspaceId}`);
return ensurePaginated<WorkspaceUserRate>(response);
};
export const createWorkspaceUserRate = async (data: {
workspace_id: string;
user_id: string;
hourly_rate: string;
currency: string;
}) => {
const response = await authFetch("/api/workspace-user-rates/", {
method: "POST",
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to save workspace user rate");
}
return response.json() as Promise<WorkspaceUserRate>;
};
export const updateWorkspaceUserRate = async (
rateId: string,
data: Partial<Pick<WorkspaceUserRate, "hourly_rate" | "currency">>,
) => {
const response = await authFetch(`/api/workspace-user-rates/${rateId}/`, {
method: "PATCH",
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to update workspace user rate");
}
return response.json() as Promise<WorkspaceUserRate>;
};
export const deleteWorkspaceUserRate = async (rateId: string) => {
const response = await authFetch(`/api/workspace-user-rates/${rateId}/`, {
method: "DELETE",
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to delete workspace user rate");
}
};

View File

@@ -0,0 +1,130 @@
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import type { PriceUnit, WorkspaceUserRate } from "../../api/rates";
import {
createWorkspaceUserRate,
deleteWorkspaceUserRate,
updateWorkspaceUserRate,
} from "../../api/rates";
import { useTranslation } from "../../hooks/useTranslation";
import { Input } from "../ui/input";
import { SearchableSelect } from "../ui/SearchableSelect";
interface Props {
workspaceId: string;
userId: string;
rate?: WorkspaceUserRate;
priceUnits: PriceUnit[];
onRatesChanged: (updater: (rates: WorkspaceUserRate[]) => WorkspaceUserRate[]) => void;
}
export default function WorkspaceMemberRateFields({
workspaceId,
userId,
rate,
priceUnits,
onRatesChanged,
}: Props) {
const { t, lang } = useTranslation();
const [hourlyRate, setHourlyRate] = useState(rate?.hourly_rate || "");
const [currency, setCurrency] = useState(rate?.currency || "USD");
const [isPersisting, setIsPersisting] = useState(false);
useEffect(() => {
setHourlyRate(rate?.hourly_rate || "");
setCurrency(rate?.currency || "USD");
}, [rate?.hourly_rate, rate?.currency, rate?.id]);
const unitOptions = useMemo(
() =>
priceUnits.map((unit) => ({
value: unit.code,
label:
lang === "fa"
? `${unit.local_name || unit.name} (${unit.code})`
: `${unit.code} · ${unit.name}`,
searchText: `${unit.code} ${unit.name} ${unit.local_name || ""} ${unit.symbol || ""}`,
})),
[lang, priceUnits],
);
const persist = async (nextRate: string, nextCurrency: string) => {
const trimmedRate = nextRate.trim();
const normalizedCurrency = nextCurrency.trim().toUpperCase();
if (!trimmedRate) {
if (!rate?.id) return;
setIsPersisting(true);
try {
await deleteWorkspaceUserRate(rate.id);
onRatesChanged((rates) => rates.filter((item) => item.id !== rate.id));
toast.success(t.rates?.workspaceRemoveSuccess || "Workspace user rate removed.");
} catch (error) {
toast.error(error instanceof Error ? error.message : (t.rates?.workspaceRemoveError || "Failed to remove workspace user rate."));
setHourlyRate(rate.hourly_rate || "");
setCurrency(rate.currency || "USD");
} finally {
setIsPersisting(false);
}
return;
}
if (rate?.hourly_rate === trimmedRate && rate?.currency === normalizedCurrency) {
return;
}
setIsPersisting(true);
try {
const saved = rate?.id
? await updateWorkspaceUserRate(rate.id, { hourly_rate: trimmedRate, currency: normalizedCurrency })
: await createWorkspaceUserRate({
workspace_id: workspaceId,
user_id: userId,
hourly_rate: trimmedRate,
currency: normalizedCurrency,
});
onRatesChanged((rates) => [
...rates.filter((item) => item.user !== userId),
saved,
]);
setHourlyRate(saved.hourly_rate || "");
setCurrency(saved.currency || normalizedCurrency);
toast.success(t.rates?.workspaceSaveSuccess || "Workspace user rate saved.");
} catch (error) {
toast.error(error instanceof Error ? error.message : (t.rates?.workspaceSaveError || "Failed to save workspace user rate."));
setHourlyRate(rate?.hourly_rate || "");
setCurrency(rate?.currency || "USD");
} finally {
setIsPersisting(false);
}
};
return (
<div className="grid w-full gap-2 sm:w-auto sm:grid-cols-[120px_180px]">
<Input
value={hourlyRate}
onChange={(event) => setHourlyRate(event.target.value)}
onBlur={() => void persist(hourlyRate, currency)}
inputMode="decimal"
placeholder={t.rates?.hourlyRatePlaceholder || "0.00"}
disabled={isPersisting}
className="h-9"
/>
<SearchableSelect
value={currency}
onChange={(value) => {
setCurrency(value);
if (hourlyRate.trim()) {
void persist(hourlyRate, value);
}
}}
options={unitOptions}
placeholder={t.rates?.currencyPlaceholder || "USD"}
searchPlaceholder={t.rates?.searchUnitPlaceholder || "Search unit..."}
disabled={isPersisting}
buttonClassName="h-9 dark:bg-slate-800"
/>
</div>
);
}

View File

@@ -0,0 +1,147 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronDown, Search } from "lucide-react";
import { Input } from "./input";
export interface SearchableSelectOption {
value: string;
label: string;
searchText?: string;
}
interface SearchableSelectProps {
value: string;
onChange: (value: string) => void;
options: SearchableSelectOption[];
placeholder?: string;
searchPlaceholder?: string;
disabled?: boolean;
className?: string;
buttonClassName?: string;
}
export function SearchableSelect({
value,
onChange,
options,
placeholder = "",
searchPlaceholder = "Search...",
disabled = false,
className = "",
buttonClassName = "",
}: SearchableSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const selected = options.find((option) => option.value === value);
const filteredOptions = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return options;
return options.filter((option) =>
`${option.label} ${option.searchText || ""}`.toLowerCase().includes(needle)
);
}, [options, query]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
buttonRef.current &&
!buttonRef.current.contains(event.target as Node) &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setQuery("");
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
useEffect(() => {
if (!isOpen || !buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const dropdownHeight = 320;
const shouldOpenUp = spaceBelow < dropdownHeight && rect.top > spaceBelow;
setDropdownStyle({
position: "fixed",
top: shouldOpenUp ? `${rect.top - 4}px` : `${rect.bottom + 4}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
transform: shouldOpenUp ? "translateY(-100%)" : "none",
zIndex: 99999,
});
}, [isOpen]);
return (
<div className={`relative ${className}`}>
<button
ref={buttonRef}
type="button"
disabled={disabled}
onClick={() => !disabled && setIsOpen((current) => !current)}
className={`flex w-full items-center justify-between rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 outline-none transition focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 ${buttonClassName}`}
>
<span className="truncate">{selected?.label || placeholder}</span>
<ChevronDown className={`h-4 w-4 shrink-0 transition-transform ${isOpen ? "rotate-180" : ""}`} />
</button>
{isOpen &&
createPortal(
<div
ref={dropdownRef}
style={dropdownStyle}
className="overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800"
>
<div className="border-b border-slate-100 p-2 dark:border-slate-700">
<div className="relative">
<Search className="pointer-events-none absolute inset-y-0 left-3 my-auto h-4 w-4 text-slate-400" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder}
className="h-9 pl-9"
autoFocus
/>
</div>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{filteredOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value);
setIsOpen(false);
setQuery("");
}}
className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm transition hover:bg-slate-100 dark:hover:bg-slate-700 ${
option.value === value
? "bg-blue-50 font-medium text-blue-600 dark:bg-blue-900/30 dark:text-blue-300"
: "text-slate-700 dark:text-slate-300"
}`}
>
<span className="truncate">{option.label}</span>
{option.value === value && <Check className="h-4 w-4 shrink-0" />}
</button>
))}
{filteredOptions.length === 0 && (
<div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">No results</div>
)}
</div>
</div>,
document.body,
)}
</div>
);
}

View File

@@ -340,6 +340,27 @@ export const en = {
deleteError: "Failed to delete tag.", deleteError: "Failed to delete tag.",
}, },
rates: {
workspaceSectionTitle: "Workspace User Rates",
projectSectionTitle: "Project User Rates",
workspaceRate: "Workspace rate",
projectOverride: "Project override",
inheritsWorkspaceRate: "Inherits workspace rate",
noRate: "No rate",
hourlyRatePlaceholder: "0.00",
currencyPlaceholder: "USD",
searchUnitPlaceholder: "Search unit...",
removeRate: "Remove rate",
workspaceSaveSuccess: "Workspace user rate saved.",
workspaceSaveError: "Failed to save workspace user rate.",
workspaceRemoveSuccess: "Workspace user rate removed.",
workspaceRemoveError: "Failed to remove workspace user rate.",
projectSaveSuccess: "Project user rate saved.",
projectSaveError: "Failed to save project user rate.",
projectRemoveSuccess: "Project user rate removed.",
projectRemoveError: "Failed to remove project user rate.",
},
timesheet: { timesheet: {
title: "Timesheet", title: "Timesheet",
description: (workspaceName: string) => `Track time inside ${workspaceName}`, description: (workspaceName: string) => `Track time inside ${workspaceName}`,

View File

@@ -337,6 +337,27 @@ export const fa = {
deleteError: "حذف تگ با خطا مواجه شد.", deleteError: "حذف تگ با خطا مواجه شد.",
}, },
rates: {
workspaceSectionTitle: "نرخ‌های کاربران ورک‌اسپیس",
projectSectionTitle: "نرخ‌های کاربران پروژه",
workspaceRate: "دستمزد ساعتی",
projectOverride: "نرخ اختصاصی پروژه",
inheritsWorkspaceRate: "ارث‌بری از دستمزد ساعتی",
noRate: "بدون نرخ",
hourlyRatePlaceholder: "0.00",
currencyPlaceholder: "USD",
searchUnitPlaceholder: "جست‌وجوی واحد...",
removeRate: "حذف نرخ",
workspaceSaveSuccess: "نرخ کاربر ورک‌اسپیس ذخیره شد.",
workspaceSaveError: "ذخیره نرخ کاربر ورک‌اسپیس با خطا مواجه شد.",
workspaceRemoveSuccess: "نرخ کاربر ورک‌اسپیس حذف شد.",
workspaceRemoveError: "حذف نرخ کاربر ورک‌اسپیس با خطا مواجه شد.",
projectSaveSuccess: "نرخ کاربر پروژه ذخیره شد.",
projectSaveError: "ذخیره نرخ کاربر پروژه با خطا مواجه شد.",
projectRemoveSuccess: "نرخ کاربر پروژه حذف شد.",
projectRemoveError: "حذف نرخ کاربر پروژه با خطا مواجه شد.",
},
timesheet: { timesheet: {
title: "تایم‌شیت", title: "تایم‌شیت",
description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`, description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`,

View File

@@ -133,7 +133,6 @@ export default function ProjectEdit() {
})); }));
setMembers(mappedMembers); setMembers(mappedMembers);
} }
const res = await fetchWorkspaceMemberships({ const res = await fetchWorkspaceMemberships({
workspace: activeWorkspace.id, workspace: activeWorkspace.id,
limit: LIMIT, limit: LIMIT,
@@ -603,6 +602,7 @@ export default function ProjectEdit() {
</InfiniteScroll> </InfiniteScroll>
)} )}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -178,13 +178,13 @@ export default function WorkspaceCreate() {
const isFirstOwner = true; const isFirstOwner = true;
return ( return (
<div className="absolute inset-0 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden"> <div className="absolute inset-0 flex flex-col overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 lg:overflow-hidden sm:p-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0"> <h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0">
{t.workspace?.createTitle || "Create Workspace"} {t.workspace?.createTitle || "Create Workspace"}
</h1> </h1>
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0"> <div className="flex flex-col gap-4 lg:min-h-0 lg:flex-1 lg:flex-row sm:gap-6">
<div className="w-full lg:w-1/3 lg:max-w-md flex flex-col shrink-0 overflow-y-auto bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm"> <div className="w-full shrink-0 rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-1/3 lg:max-w-md lg:flex-col lg:overflow-y-auto">
<form onSubmit={handleSubmit} className="flex flex-col h-full p-6"> <form onSubmit={handleSubmit} className="flex flex-col h-full p-6">
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
@@ -228,7 +228,7 @@ export default function WorkspaceCreate() {
</form> </form>
</div> </div>
<div className="w-full lg:w-2/3 flex-1 flex flex-col min-h-100 lg:min-h-0 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden"> <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"> <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"> <h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
{ t.workspace?.members || "Members" } { t.workspace?.members || "Members" }
@@ -322,7 +322,7 @@ export default function WorkspaceCreate() {
</div> </div>
{/* لیست اعضا (با قابلیت اسکرول) */} {/* لیست اعضا (با قابلیت اسکرول) */}
<div className="flex-1 overflow-y-auto p-6 space-y-3 bg-slate-50/30 dark:bg-slate-900/30"> <div className="space-y-3 bg-slate-50/30 p-6 dark:bg-slate-900/30 lg:flex-1 lg:overflow-y-auto">
{members.map((m) => { {members.map((m) => {
return ( return (
<div key={m.localId} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg gap-3 shadow-sm hover:border-blue-200 dark:hover:border-blue-800 transition-colors"> <div key={m.localId} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg gap-3 shadow-sm hover:border-blue-200 dark:hover:border-blue-800 transition-colors">

View File

@@ -4,6 +4,7 @@ import { useTranslation } from '../hooks/useTranslation';
import { AlertCircle, UserPlus, Trash2, Shield } from 'lucide-react'; import { AlertCircle, UserPlus, Trash2, Shield } from 'lucide-react';
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from '@headlessui/react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getPriceUnits, getWorkspaceUserRates, type PriceUnit, type WorkspaceUserRate } from '../api/rates';
import { import {
updateWorkspace, updateWorkspace,
addWorkspaceMembership, addWorkspaceMembership,
@@ -27,6 +28,7 @@ import { InfiniteScroll } from '../components/InfiniteScroll';
import { Select } from '../components/ui/Select'; import { Select } from '../components/ui/Select';
import { Input } from '../components/ui/input'; import { Input } from '../components/ui/input';
import { TextAreaInput } from '../components/ui/TextAreaInput'; import { TextAreaInput } from '../components/ui/TextAreaInput';
import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields';
const toEnglishDigits = (str: string) => { const toEnglishDigits = (str: string) => {
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString()) return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
@@ -57,6 +59,8 @@ export default function EditWorkspace() {
const [workspaceOwnerId, setWorkspaceOwnerId] = useState<string>(''); const [workspaceOwnerId, setWorkspaceOwnerId] = useState<string>('');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [workspaceRates, setWorkspaceRates] = useState<WorkspaceUserRate[]>([]);
const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]);
// Members States // Members States
const [members, setMembers] = useState<any[]>([]); const [members, setMembers] = useState<any[]>([]);
@@ -129,10 +133,16 @@ export default function EditWorkspace() {
setMyRole(workspaceData.my_role || 'member'); setMyRole(workspaceData.my_role || 'member');
setWorkspaceOwnerId(workspaceData.owner || ''); setWorkspaceOwnerId(workspaceData.owner || '');
const membersData = await fetchWorkspaceMemberships({ workspace: id!, limit: LIMIT, offset: 0 }); const [membersData, ratesData, unitsData] = await Promise.all([
fetchWorkspaceMemberships({ workspace: id!, limit: LIMIT, offset: 0 }),
getWorkspaceUserRates(id!),
getPriceUnits(),
]);
const results = membersData.results || (Array.isArray(membersData) ? membersData : []); const results = membersData.results || (Array.isArray(membersData) ? membersData : []);
setMembers(results); setMembers(results);
setWorkspaceRates(ratesData.results || []);
setPriceUnits(unitsData.results || []);
setOffset(LIMIT); setOffset(LIMIT);
// Robust hasMore check: use `.next` if available, otherwise check if array filled the limit // Robust hasMore check: use `.next` if available, otherwise check if array filled the limit
@@ -286,13 +296,13 @@ export default function EditWorkspace() {
if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>; if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>;
return ( return (
<div className="absolute inset-0 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden"> <div className="absolute inset-0 flex flex-col overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 lg:overflow-hidden sm:p-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0"> <h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0">
{t.workspace?.editTitle || "Edit Workspace"} {t.workspace?.editTitle || "Edit Workspace"}
</h1> </h1>
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0"> <div className="flex flex-col gap-4 lg:min-h-0 lg:flex-1 lg:flex-row sm:gap-6">
<div className="w-full lg:w-1/3 lg:max-w-md flex flex-col shrink-0 overflow-y-auto bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm"> <div className="w-full shrink-0 rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-1/3 lg:max-w-md lg:flex-col lg:overflow-y-auto">
<form onSubmit={handleSubmit} className="flex flex-col h-full p-6"> <form onSubmit={handleSubmit} className="flex flex-col h-full p-6">
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
@@ -328,7 +338,7 @@ export default function EditWorkspace() {
</form> </form>
</div> </div>
<div className="w-full lg:w-2/3 flex-1 flex flex-col min-h-100 lg:min-h-0 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden"> <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"> <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"> <h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
{ t.workspace?.members || "Members" } { t.workspace?.members || "Members" }
@@ -404,7 +414,7 @@ export default function EditWorkspace() {
)} )}
</div> </div>
<div className="flex-1 overflow-y-auto p-6 space-y-3 bg-slate-50/30 dark:bg-slate-900/30"> <div className="space-y-3 bg-slate-50/30 p-6 dark:bg-slate-900/30 lg:flex-1 lg:overflow-y-auto">
<InfiniteScroll <InfiniteScroll
onLoadMore={loadMoreMembers} onLoadMore={loadMoreMembers}
hasMore={hasMore} hasMore={hasMore}
@@ -423,8 +433,9 @@ export default function EditWorkspace() {
}); });
return ( return (
<div key={m.id} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg gap-3 shadow-sm hover:border-blue-200 dark:hover:border-blue-800 transition-colors"> <div key={m.id} className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-3 shadow-sm transition-colors hover:border-blue-200 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-blue-800">
<div className="flex items-center gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{m.user?.profile_picture ? ( {m.user?.profile_picture ? (
<img src={m.user?.profile_picture} alt={m.user?.first_name} className="w-10 h-10 rounded-full object-cover shadow-sm" /> <img src={m.user?.profile_picture} alt={m.user?.first_name} className="w-10 h-10 rounded-full object-cover shadow-sm" />
) : ( ) : (
@@ -440,35 +451,49 @@ export default function EditWorkspace() {
</div> </div>
</div> </div>
<div className="flex items-center gap-3 self-end sm:self-auto"> <div className="flex items-center gap-3 self-end sm:self-auto">
{canChangeThisUserRole ? ( {canChangeThisUserRole ? (
<Select <Select
value={m.role} value={m.role}
onChange={(val) => handleChangeRole(m.id, val)} onChange={(val) => handleChangeRole(m.id, val)}
options={roleOptions(isFirstOwner)} options={roleOptions(isFirstOwner)}
buttonClassName="w-[110px] px-3 py-1.5 text-sm" buttonClassName="w-[110px] px-3 py-1.5 text-sm"
/> />
) : ( ) : (
<span className="text-xs px-2 py-1 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded-md capitalize flex items-center gap-1"> <span className="text-xs px-2 py-1 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded-md capitalize flex items-center gap-1">
{m.role === 'owner' && <Shield className="w-3 h-3" />} {m.role === 'owner' && <Shield className="w-3 h-3" />}
{m.role && m.role in t.workspace.roles {m.role && m.role in t.workspace.roles
? t.workspace.roles[m.role as keyof typeof t.workspace.roles] ? t.workspace.roles[m.role as keyof typeof t.workspace.roles]
: m.role || "-"} : m.role || "-"}
</span> </span>
)} )}
{canChangeThisUserRole && ( {canChangeThisUserRole && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => openDeleteModal(m.id)} onClick={() => openDeleteModal(m.id)}
className="h-8 w-8 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10" className="h-8 w-8 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
title={t.workspace?.removeMemberTitle || "Remove member"} title={t.workspace?.removeMemberTitle || "Remove member"}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
)} )}
</div>
</div>
<div className="flex flex-col gap-2 border-t border-slate-100 pt-3 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-slate-500 dark:text-slate-400">
{t.rates?.workspaceRate || "Workspace rate"}
</div>
<WorkspaceMemberRateFields
workspaceId={id!}
userId={m.user.id}
rate={workspaceRates.find((item) => item.user === m.user.id)}
priceUnits={priceUnits}
onRatesChanged={(updater) => setWorkspaceRates((current) => updater(current))}
/>
</div> </div>
</div> </div>
); );
@@ -483,6 +508,7 @@ export default function EditWorkspace() {
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>