feat(pricing): manage workspace member rates in edit flows
This commit is contained in:
107
src/api/rates.ts
Normal file
107
src/api/rates.ts
Normal 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");
|
||||
}
|
||||
};
|
||||
130
src/components/rates/WorkspaceMemberRateFields.tsx
Normal file
130
src/components/rates/WorkspaceMemberRateFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
src/components/ui/SearchableSelect.tsx
Normal file
147
src/components/ui/SearchableSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -340,6 +340,27 @@ export const en = {
|
||||
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: {
|
||||
title: "Timesheet",
|
||||
description: (workspaceName: string) => `Track time inside ${workspaceName}`,
|
||||
|
||||
@@ -152,12 +152,12 @@ export const fa = {
|
||||
emptyState: "شما در هیچ ورکاسپیس عضو نیستید.",
|
||||
createTitle: "ایجاد ورکاسپیس",
|
||||
editTitle: "ویرایش ورکاسپیس",
|
||||
detailTitle: "جزئیات ورکاسپیس",
|
||||
save: "ذخیره",
|
||||
create: "ایجاد",
|
||||
noWorkspaceTitle: "خوش آمدید!",
|
||||
noWorkspaceDesc: "لطفاً اولین ورکاسپیس خود را ایجاد کنید.",
|
||||
back: "بازگشت به ورکاسپیسها",
|
||||
detailTitle: "جزئیات ورکاسپیس",
|
||||
save: "ذخیره",
|
||||
create: "ایجاد",
|
||||
noWorkspaceTitle: "خوش آمدید!",
|
||||
noWorkspaceDesc: "لطفاً اولین ورکاسپیس خود را ایجاد کنید.",
|
||||
back: "بازگشت به ورکاسپیسها",
|
||||
roleLabel: "نقش شما",
|
||||
roles: {
|
||||
owner: "مالک",
|
||||
@@ -241,15 +241,15 @@ export const fa = {
|
||||
next: "بعدی",
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
timesheet: 'تایمشیت',
|
||||
workspaces: 'ورکاسپیسها',
|
||||
clients: 'مشتریان',
|
||||
projects: "پروژهها",
|
||||
tags: "تگها",
|
||||
expand: 'باز کردن',
|
||||
collapse: 'جمع کردن',
|
||||
},
|
||||
sidebar: {
|
||||
timesheet: 'تایمشیت',
|
||||
workspaces: 'ورکاسپیسها',
|
||||
clients: 'مشتریان',
|
||||
projects: "پروژهها",
|
||||
tags: "تگها",
|
||||
expand: 'باز کردن',
|
||||
collapse: 'جمع کردن',
|
||||
},
|
||||
|
||||
ordering: {
|
||||
createdAtDesc: "جدیدترین",
|
||||
@@ -259,7 +259,7 @@ export const fa = {
|
||||
nameDesc: "نام (نزولی)",
|
||||
},
|
||||
|
||||
projects: {
|
||||
projects: {
|
||||
title: "پروژهها",
|
||||
description: (workspaceName: string) => `مدیریت پروژهها برای ${workspaceName}`,
|
||||
active: "پروژههای فعال",
|
||||
@@ -301,108 +301,129 @@ export const fa = {
|
||||
},
|
||||
namePlaceholder: "نام پروژه...",
|
||||
teamMembers: "اعضای تیم",
|
||||
createSuccess: "پروژه با موفقیت ایجاد شد.",
|
||||
createError: "خطا در ایجاد پروژه.",
|
||||
updateSuccess: "پروژه با موفقیت بهروزرسانی شد.",
|
||||
updateError: "بهروزرسانی پروژه با خطا مواجه شد.",
|
||||
edit: "ویرایش پروژه",
|
||||
projectMembers: "اعضای پروژه",
|
||||
createSuccess: "پروژه با موفقیت ایجاد شد.",
|
||||
createError: "خطا در ایجاد پروژه.",
|
||||
updateSuccess: "پروژه با موفقیت بهروزرسانی شد.",
|
||||
updateError: "بهروزرسانی پروژه با خطا مواجه شد.",
|
||||
edit: "ویرایش پروژه",
|
||||
projectMembers: "اعضای پروژه",
|
||||
removeAllWorkspaceMembers: "حذف همه",
|
||||
searchWorkspaceMembers: "جستجو با نام یا وارد کردن شماره موبایل...",
|
||||
userNotFound: "کاربری با این شماره موبایل یافت نشد.",
|
||||
alreadyInProject: "قبلاً اضافه شده",
|
||||
addToProject: "افزودن به پروژه",
|
||||
noWorkspaceMembers: "عضوی یافت نشد.",
|
||||
},
|
||||
|
||||
tags: {
|
||||
title: "تگها",
|
||||
description: (workspaceName: string) => `مدیریت تگها برای ${workspaceName}`,
|
||||
create: "ایجاد تگ",
|
||||
createTitle: "ایجاد تگ",
|
||||
editTitle: "ویرایش تگ",
|
||||
deleteTitle: "حذف تگ",
|
||||
deleteConfirmMessage: (name: string) => `آیا از حذف ${name} اطمینان دارید؟`,
|
||||
searchPlaceholder: "جستوجوی تگها...",
|
||||
nameLabel: "نام تگ",
|
||||
namePlaceholder: "مثلاً طراحی",
|
||||
colorLabel: "رنگ",
|
||||
emptyState: "تگی یافت نشد",
|
||||
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
||||
fetchError: "دریافت تگها با خطا مواجه شد.",
|
||||
createSuccess: "تگ با موفقیت ایجاد شد.",
|
||||
updateSuccess: "تگ با موفقیت بهروزرسانی شد.",
|
||||
saveError: "ذخیره تگ با خطا مواجه شد.",
|
||||
deleteSuccess: "تگ با موفقیت حذف شد.",
|
||||
deleteError: "حذف تگ با خطا مواجه شد.",
|
||||
},
|
||||
|
||||
timesheet: {
|
||||
title: "تایمشیت",
|
||||
description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`,
|
||||
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
||||
addEntry: "افزودن ورودی",
|
||||
startTimer: "شروع تایمر",
|
||||
stopTimer: "توقف تایمر",
|
||||
timerRunning: "تایمر فعال است",
|
||||
runningLabel: "تایمر فعلی",
|
||||
runningBadge: "در حال اجرا",
|
||||
noRunningEntry: "تایمر فعالی وجود ندارد",
|
||||
searchPlaceholder: "جستوجوی ورودیهای زمان...",
|
||||
orderingNewest: "جدیدترین",
|
||||
orderingOldest: "قدیمیترین",
|
||||
emptyState: "ورودی زمانی یافت نشد",
|
||||
emptyDescription: "بدون توضیح",
|
||||
createTitle: "افزودن ورودی زمان",
|
||||
startTitle: "شروع تایمر",
|
||||
editTitle: "ویرایش ورودی زمان",
|
||||
createSuccess: "ورودی زمان با موفقیت ایجاد شد.",
|
||||
startSuccess: "تایمر با موفقیت شروع شد.",
|
||||
updateSuccess: "ورودی زمان با موفقیت بهروزرسانی شد.",
|
||||
saveError: "ذخیره ورودی زمان با خطا مواجه شد.",
|
||||
stopSuccess: "تایمر با موفقیت متوقف شد.",
|
||||
stopError: "توقف تایمر با خطا مواجه شد.",
|
||||
deleteSuccess: "ورودی زمان با موفقیت حذف شد.",
|
||||
deleteError: "حذف ورودی زمان با خطا مواجه شد.",
|
||||
fetchError: "دریافت ورودیهای زمان با خطا مواجه شد.",
|
||||
optionsError: "دریافت پروژهها و تگها با خطا مواجه شد.",
|
||||
descriptionLabel: "توضیحات",
|
||||
descriptionPlaceholder: "روی چه چیزی کار میکنید؟",
|
||||
projectLabel: "پروژه",
|
||||
noProject: "بدون پروژه",
|
||||
startLabel: "شروع",
|
||||
endLabel: "پایان",
|
||||
billable: "قابل صورتحساب",
|
||||
noTagsHint: "ابتدا از صفحه تگها، تگ ایجاد کنید.",
|
||||
clearFilters: "پاک کردن فیلترها",
|
||||
customFromLabel: "از تاریخ",
|
||||
customToLabel: "تا تاریخ",
|
||||
allClientsLabel: "همه مشتریها",
|
||||
allProjectsLabel: "همه پروژهها",
|
||||
allTagsLabel: "همه تگها",
|
||||
showFiltersLabel: "نمایش فیلترها",
|
||||
hideFiltersLabel: "مخفی کردن فیلترها",
|
||||
applyFiltersLabel: "اعمال",
|
||||
clientFilterPrefix: "مشتری",
|
||||
projectFilterPrefix: "پروژه",
|
||||
tagFilterPrefix: "تگ",
|
||||
fromFilterPrefix: "از",
|
||||
toFilterPrefix: "تا",
|
||||
},
|
||||
notifications: {
|
||||
title: "اعلانها",
|
||||
open: "باز کردن اعلانها",
|
||||
empty: "هنوز اعلانی وجود ندارد.",
|
||||
loading: "در حال بارگذاری اعلانها...",
|
||||
loadingMore: "در حال بارگذاری بیشتر...",
|
||||
loadMore: "بارگذاری بیشتر",
|
||||
markAllRead: "خواندن همه",
|
||||
markSeenError: "بهروزرسانی اعلان با خطا مواجه شد.",
|
||||
markAllError: "بهروزرسانی اعلانها با خطا مواجه شد.",
|
||||
deleteError: "حذف اعلان با خطا مواجه شد.",
|
||||
loadError: "دریافت اعلانها با خطا مواجه شد.",
|
||||
newTitle: "اعلان جدید",
|
||||
openAction: "باز کردن",
|
||||
summary: (total: number, unread: number) => `${total} کل، ${unread} خواندهنشده`,
|
||||
},
|
||||
}
|
||||
noWorkspaceMembers: "عضوی یافت نشد.",
|
||||
},
|
||||
|
||||
tags: {
|
||||
title: "تگها",
|
||||
description: (workspaceName: string) => `مدیریت تگها برای ${workspaceName}`,
|
||||
create: "ایجاد تگ",
|
||||
createTitle: "ایجاد تگ",
|
||||
editTitle: "ویرایش تگ",
|
||||
deleteTitle: "حذف تگ",
|
||||
deleteConfirmMessage: (name: string) => `آیا از حذف ${name} اطمینان دارید؟`,
|
||||
searchPlaceholder: "جستوجوی تگها...",
|
||||
nameLabel: "نام تگ",
|
||||
namePlaceholder: "مثلاً طراحی",
|
||||
colorLabel: "رنگ",
|
||||
emptyState: "تگی یافت نشد",
|
||||
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
||||
fetchError: "دریافت تگها با خطا مواجه شد.",
|
||||
createSuccess: "تگ با موفقیت ایجاد شد.",
|
||||
updateSuccess: "تگ با موفقیت بهروزرسانی شد.",
|
||||
saveError: "ذخیره تگ با خطا مواجه شد.",
|
||||
deleteSuccess: "تگ با موفقیت حذف شد.",
|
||||
deleteError: "حذف تگ با خطا مواجه شد.",
|
||||
},
|
||||
|
||||
rates: {
|
||||
workspaceSectionTitle: "نرخهای کاربران ورکاسپیس",
|
||||
projectSectionTitle: "نرخهای کاربران پروژه",
|
||||
workspaceRate: "دستمزد ساعتی",
|
||||
projectOverride: "نرخ اختصاصی پروژه",
|
||||
inheritsWorkspaceRate: "ارثبری از دستمزد ساعتی",
|
||||
noRate: "بدون نرخ",
|
||||
hourlyRatePlaceholder: "0.00",
|
||||
currencyPlaceholder: "USD",
|
||||
searchUnitPlaceholder: "جستوجوی واحد...",
|
||||
removeRate: "حذف نرخ",
|
||||
workspaceSaveSuccess: "نرخ کاربر ورکاسپیس ذخیره شد.",
|
||||
workspaceSaveError: "ذخیره نرخ کاربر ورکاسپیس با خطا مواجه شد.",
|
||||
workspaceRemoveSuccess: "نرخ کاربر ورکاسپیس حذف شد.",
|
||||
workspaceRemoveError: "حذف نرخ کاربر ورکاسپیس با خطا مواجه شد.",
|
||||
projectSaveSuccess: "نرخ کاربر پروژه ذخیره شد.",
|
||||
projectSaveError: "ذخیره نرخ کاربر پروژه با خطا مواجه شد.",
|
||||
projectRemoveSuccess: "نرخ کاربر پروژه حذف شد.",
|
||||
projectRemoveError: "حذف نرخ کاربر پروژه با خطا مواجه شد.",
|
||||
},
|
||||
|
||||
timesheet: {
|
||||
title: "تایمشیت",
|
||||
description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`,
|
||||
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
||||
addEntry: "افزودن ورودی",
|
||||
startTimer: "شروع تایمر",
|
||||
stopTimer: "توقف تایمر",
|
||||
timerRunning: "تایمر فعال است",
|
||||
runningLabel: "تایمر فعلی",
|
||||
runningBadge: "در حال اجرا",
|
||||
noRunningEntry: "تایمر فعالی وجود ندارد",
|
||||
searchPlaceholder: "جستوجوی ورودیهای زمان...",
|
||||
orderingNewest: "جدیدترین",
|
||||
orderingOldest: "قدیمیترین",
|
||||
emptyState: "ورودی زمانی یافت نشد",
|
||||
emptyDescription: "بدون توضیح",
|
||||
createTitle: "افزودن ورودی زمان",
|
||||
startTitle: "شروع تایمر",
|
||||
editTitle: "ویرایش ورودی زمان",
|
||||
createSuccess: "ورودی زمان با موفقیت ایجاد شد.",
|
||||
startSuccess: "تایمر با موفقیت شروع شد.",
|
||||
updateSuccess: "ورودی زمان با موفقیت بهروزرسانی شد.",
|
||||
saveError: "ذخیره ورودی زمان با خطا مواجه شد.",
|
||||
stopSuccess: "تایمر با موفقیت متوقف شد.",
|
||||
stopError: "توقف تایمر با خطا مواجه شد.",
|
||||
deleteSuccess: "ورودی زمان با موفقیت حذف شد.",
|
||||
deleteError: "حذف ورودی زمان با خطا مواجه شد.",
|
||||
fetchError: "دریافت ورودیهای زمان با خطا مواجه شد.",
|
||||
optionsError: "دریافت پروژهها و تگها با خطا مواجه شد.",
|
||||
descriptionLabel: "توضیحات",
|
||||
descriptionPlaceholder: "روی چه چیزی کار میکنید؟",
|
||||
projectLabel: "پروژه",
|
||||
noProject: "بدون پروژه",
|
||||
startLabel: "شروع",
|
||||
endLabel: "پایان",
|
||||
billable: "قابل صورتحساب",
|
||||
noTagsHint: "ابتدا از صفحه تگها، تگ ایجاد کنید.",
|
||||
clearFilters: "پاک کردن فیلترها",
|
||||
customFromLabel: "از تاریخ",
|
||||
customToLabel: "تا تاریخ",
|
||||
allClientsLabel: "همه مشتریها",
|
||||
allProjectsLabel: "همه پروژهها",
|
||||
allTagsLabel: "همه تگها",
|
||||
showFiltersLabel: "نمایش فیلترها",
|
||||
hideFiltersLabel: "مخفی کردن فیلترها",
|
||||
applyFiltersLabel: "اعمال",
|
||||
clientFilterPrefix: "مشتری",
|
||||
projectFilterPrefix: "پروژه",
|
||||
tagFilterPrefix: "تگ",
|
||||
fromFilterPrefix: "از",
|
||||
toFilterPrefix: "تا",
|
||||
},
|
||||
notifications: {
|
||||
title: "اعلانها",
|
||||
open: "باز کردن اعلانها",
|
||||
empty: "هنوز اعلانی وجود ندارد.",
|
||||
loading: "در حال بارگذاری اعلانها...",
|
||||
loadingMore: "در حال بارگذاری بیشتر...",
|
||||
loadMore: "بارگذاری بیشتر",
|
||||
markAllRead: "خواندن همه",
|
||||
markSeenError: "بهروزرسانی اعلان با خطا مواجه شد.",
|
||||
markAllError: "بهروزرسانی اعلانها با خطا مواجه شد.",
|
||||
deleteError: "حذف اعلان با خطا مواجه شد.",
|
||||
loadError: "دریافت اعلانها با خطا مواجه شد.",
|
||||
newTitle: "اعلان جدید",
|
||||
openAction: "باز کردن",
|
||||
summary: (total: number, unread: number) => `${total} کل، ${unread} خواندهنشده`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getProject, updateProject } from "../api/projects";
|
||||
import { getClients } from "../api/clients";
|
||||
import { getProject, updateProject } from "../api/projects";
|
||||
import { getClients } from "../api/clients";
|
||||
import { fetchWorkspaceMemberships } from "../api/workspaces";
|
||||
import { searchUserByExactMobile, type SearchedUser } from "../api/users";
|
||||
import { useAppContext } from "../context/AppContext";
|
||||
@@ -20,9 +20,9 @@ import { PROJECTS_EDIT, canWorkspace } from "../lib/permissions";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Select } from "../components/ui/Select";
|
||||
import { TextAreaInput } from "../components/ui/TextAreaInput";
|
||||
import { InfiniteScroll } from "../components/InfiniteScroll";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { TextAreaInput } from "../components/ui/TextAreaInput";
|
||||
import { InfiniteScroll } from "../components/InfiniteScroll";
|
||||
import { Modal } from "../components/Modal";
|
||||
|
||||
type ProjectRole = "manager" | "member";
|
||||
|
||||
@@ -85,9 +85,9 @@ export default function ProjectEdit() {
|
||||
const [searchError, setSearchError] = useState(false);
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
const hasUnsavedChanges = name.trim() !== "";
|
||||
|
||||
@@ -112,8 +112,8 @@ export default function ProjectEdit() {
|
||||
const clientsRes = await getClients(activeWorkspace.id);
|
||||
setClientsList(clientsRes.results || []);
|
||||
|
||||
const projectRes = await getProject(id);
|
||||
setName(projectRes.name || "");
|
||||
const projectRes = await getProject(id);
|
||||
setName(projectRes.name || "");
|
||||
setDescription(projectRes.description || "");
|
||||
setColor(projectRes.color || COLORS[0]);
|
||||
setClient(projectRes.client?.id || projectRes.client || "");
|
||||
@@ -130,13 +130,12 @@ export default function ProjectEdit() {
|
||||
},
|
||||
role: m.role as ProjectRole,
|
||||
isCreator: m.user === currentUserId && m.role === "manager",
|
||||
}));
|
||||
setMembers(mappedMembers);
|
||||
}
|
||||
|
||||
const res = await fetchWorkspaceMemberships({
|
||||
workspace: activeWorkspace.id,
|
||||
limit: LIMIT,
|
||||
}));
|
||||
setMembers(mappedMembers);
|
||||
}
|
||||
const res = await fetchWorkspaceMemberships({
|
||||
workspace: activeWorkspace.id,
|
||||
limit: LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
const results = res.results || (Array.isArray(res) ? res : []);
|
||||
@@ -435,7 +434,7 @@ export default function ProjectEdit() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-2/3 flex flex-col min-h-0 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 shadow-sm rounded-lg border">
|
||||
<div className="w-full lg:w-2/3 flex flex-col min-h-0 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 shadow-sm rounded-lg border">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-700 shrink-0 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
|
||||
@@ -512,7 +511,7 @@ export default function ProjectEdit() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{isLoadingData ? (
|
||||
<div className="p-4 text-sm text-slate-500 flex justify-center items-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
@@ -601,10 +600,11 @@ export default function ProjectEdit() {
|
||||
</ul>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={isDeleteDialogOpen}
|
||||
|
||||
@@ -178,13 +178,13 @@ export default function WorkspaceCreate() {
|
||||
const isFirstOwner = true;
|
||||
|
||||
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">
|
||||
{t.workspace?.createTitle || "Create Workspace"}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
|
||||
<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="flex flex-col gap-4 lg:min-h-0 lg:flex-1 lg:flex-row sm:gap-6">
|
||||
<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">
|
||||
<div className="mb-4">
|
||||
<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>
|
||||
</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">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
||||
{ t.workspace?.members || "Members" }
|
||||
@@ -322,7 +322,7 @@ export default function WorkspaceCreate() {
|
||||
</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) => {
|
||||
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">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect, useRef, Fragment, useMemo, useCallback } from 'react';
|
||||
import { useBlocker, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
import { AlertCircle, UserPlus, Trash2, Shield } from 'lucide-react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
import { useBlocker, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
import { AlertCircle, UserPlus, Trash2, Shield } from 'lucide-react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { toast } from 'sonner';
|
||||
import { getPriceUnits, getWorkspaceUserRates, type PriceUnit, type WorkspaceUserRate } from '../api/rates';
|
||||
import {
|
||||
updateWorkspace,
|
||||
addWorkspaceMembership,
|
||||
removeWorkspaceMembership,
|
||||
@@ -23,10 +24,11 @@ import {
|
||||
type WorkspaceRole,
|
||||
} from '../lib/permissions';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { InfiniteScroll } from '../components/InfiniteScroll';
|
||||
import { Select } from '../components/ui/Select';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { TextAreaInput } from '../components/ui/TextAreaInput';
|
||||
import { InfiniteScroll } from '../components/InfiniteScroll';
|
||||
import { Select } from '../components/ui/Select';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { TextAreaInput } from '../components/ui/TextAreaInput';
|
||||
import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields';
|
||||
|
||||
const toEnglishDigits = (str: string) => {
|
||||
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
|
||||
@@ -55,8 +57,10 @@ export default function EditWorkspace() {
|
||||
const [description, setDescription] = useState('');
|
||||
const [myRole, setMyRole] = useState<WorkspaceRole>('member');
|
||||
const [workspaceOwnerId, setWorkspaceOwnerId] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [workspaceRates, setWorkspaceRates] = useState<WorkspaceUserRate[]>([]);
|
||||
const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]);
|
||||
|
||||
// Members States
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
@@ -129,11 +133,17 @@ export default function EditWorkspace() {
|
||||
setMyRole(workspaceData.my_role || 'member');
|
||||
setWorkspaceOwnerId(workspaceData.owner || '');
|
||||
|
||||
const membersData = await fetchWorkspaceMemberships({ workspace: id!, limit: LIMIT, offset: 0 });
|
||||
const results = membersData.results || (Array.isArray(membersData) ? membersData : []);
|
||||
|
||||
setMembers(results);
|
||||
setOffset(LIMIT);
|
||||
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 : []);
|
||||
|
||||
setMembers(results);
|
||||
setWorkspaceRates(ratesData.results || []);
|
||||
setPriceUnits(unitsData.results || []);
|
||||
setOffset(LIMIT);
|
||||
|
||||
// Robust hasMore check: use `.next` if available, otherwise check if array filled the limit
|
||||
setHasMore(membersData.next ? true : results.length >= LIMIT);
|
||||
@@ -286,13 +296,13 @@ export default function EditWorkspace() {
|
||||
if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>;
|
||||
|
||||
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">
|
||||
{t.workspace?.editTitle || "Edit Workspace"}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
|
||||
<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="flex flex-col gap-4 lg:min-h-0 lg:flex-1 lg:flex-row sm:gap-6">
|
||||
<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">
|
||||
<div className="mb-4">
|
||||
<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>
|
||||
</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">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
||||
{ t.workspace?.members || "Members" }
|
||||
@@ -404,7 +414,7 @@ export default function EditWorkspace() {
|
||||
)}
|
||||
</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
|
||||
onLoadMore={loadMoreMembers}
|
||||
hasMore={hasMore}
|
||||
@@ -423,11 +433,12 @@ export default function EditWorkspace() {
|
||||
});
|
||||
|
||||
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 className="flex items-center gap-3">
|
||||
{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" />
|
||||
) : (
|
||||
<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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{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" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-600 dark:text-slate-400 font-bold text-sm shadow-sm">
|
||||
{m.user?.name?.[0] || m.user?.first_name?.[0] || "U"}
|
||||
</div>
|
||||
@@ -436,43 +447,57 @@ export default function EditWorkspace() {
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{m.user?.name || `${m.user?.first_name || ''} ${m.user?.last_name || ''}`.trim() || 'Unknown'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{toPersianNum(m.user?.mobile)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 self-end sm:self-auto">
|
||||
{canChangeThisUserRole ? (
|
||||
<Select
|
||||
value={m.role}
|
||||
onChange={(val) => handleChangeRole(m.id, val)}
|
||||
options={roleOptions(isFirstOwner)}
|
||||
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">
|
||||
{m.role === 'owner' && <Shield className="w-3 h-3" />}
|
||||
{m.role && m.role in t.workspace.roles
|
||||
? t.workspace.roles[m.role as keyof typeof t.workspace.roles]
|
||||
: m.role || "-"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{canChangeThisUserRole && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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"
|
||||
title={t.workspace?.removeMemberTitle || "Remove member"}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs text-slate-500">{toPersianNum(m.user?.mobile)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 self-end sm:self-auto">
|
||||
{canChangeThisUserRole ? (
|
||||
<Select
|
||||
value={m.role}
|
||||
onChange={(val) => handleChangeRole(m.id, val)}
|
||||
options={roleOptions(isFirstOwner)}
|
||||
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">
|
||||
{m.role === 'owner' && <Shield className="w-3 h-3" />}
|
||||
{m.role && m.role in t.workspace.roles
|
||||
? t.workspace.roles[m.role as keyof typeof t.workspace.roles]
|
||||
: m.role || "-"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{canChangeThisUserRole && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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"
|
||||
title={t.workspace?.removeMemberTitle || "Remove member"}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</InfiniteScroll>
|
||||
{members.length === 0 && !isLoadingMembers && (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-slate-500">
|
||||
@@ -480,11 +505,12 @@ export default function EditWorkspace() {
|
||||
<p className="text-sm">
|
||||
{t.workspace?.noMembers || "No members found."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition appear show={isDeleteDialogOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={() => setIsDeleteDialogOpen(false)}>
|
||||
|
||||
Reference in New Issue
Block a user