diff --git a/src/api/rates.ts b/src/api/rates.ts new file mode 100644 index 0000000..99ddc9c --- /dev/null +++ b/src/api/rates.ts @@ -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 { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +const ensurePaginated = async (response: Response): Promise> => { + 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(response); +}; + +export const getWorkspaceUserRates = async (workspaceId: string) => { + const response = await authFetch(`/api/workspace-user-rates/?workspace=${workspaceId}`); + return ensurePaginated(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; +}; + +export const updateWorkspaceUserRate = async ( + rateId: string, + data: Partial>, +) => { + 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; +}; + +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"); + } +}; diff --git a/src/components/rates/WorkspaceMemberRateFields.tsx b/src/components/rates/WorkspaceMemberRateFields.tsx new file mode 100644 index 0000000..2054cb2 --- /dev/null +++ b/src/components/rates/WorkspaceMemberRateFields.tsx @@ -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 ( +
+ setHourlyRate(event.target.value)} + onBlur={() => void persist(hourlyRate, currency)} + inputMode="decimal" + placeholder={t.rates?.hourlyRatePlaceholder || "0.00"} + disabled={isPersisting} + className="h-9" + /> + { + 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" + /> +
+ ); +} diff --git a/src/components/ui/SearchableSelect.tsx b/src/components/ui/SearchableSelect.tsx new file mode 100644 index 0000000..785833a --- /dev/null +++ b/src/components/ui/SearchableSelect.tsx @@ -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({}); + const buttonRef = useRef(null); + const dropdownRef = useRef(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 ( +
+ + + {isOpen && + createPortal( +
+
+
+ + setQuery(event.target.value)} + placeholder={searchPlaceholder} + className="h-9 pl-9" + autoFocus + /> +
+
+
+ {filteredOptions.map((option) => ( + + ))} + {filteredOptions.length === 0 && ( +
No results
+ )} +
+
, + document.body, + )} +
+ ); +} diff --git a/src/locales/en.ts b/src/locales/en.ts index bb24a73..1493f88 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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}`, diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 977c34c..67dc177 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -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} خوانده‌نشده`, + }, +} diff --git a/src/pages/ProjectEdit.tsx b/src/pages/ProjectEdit.tsx index 93a3489..09ac427 100644 --- a/src/pages/ProjectEdit.tsx +++ b/src/pages/ProjectEdit.tsx @@ -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 | null>(null); - const [isSaving, setIsSaving] = useState(false); - const [memberIdToDelete, setMemberIdToDelete] = useState(null); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [memberIdToDelete, setMemberIdToDelete] = useState(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() { -
+

@@ -512,7 +511,7 @@ export default function ProjectEdit() { )}

-
+
{isLoadingData ? (
@@ -601,10 +600,11 @@ export default function ProjectEdit() { )} - )} -
-
-
+ )} +
+ +
+
+

{t.workspace?.createTitle || "Create Workspace"}

-
-
+
+
-
+

{ t.workspace?.members || "Members" } @@ -322,7 +322,7 @@ export default function WorkspaceCreate() {

{/* لیست اعضا (با قابلیت اسکرول) */} -
+
{members.map((m) => { return (
diff --git a/src/pages/WorkspaceEdit.tsx b/src/pages/WorkspaceEdit.tsx index e28616b..89f0704 100644 --- a/src/pages/WorkspaceEdit.tsx +++ b/src/pages/WorkspaceEdit.tsx @@ -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('member'); const [workspaceOwnerId, setWorkspaceOwnerId] = useState(''); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [workspaceRates, setWorkspaceRates] = useState([]); + const [priceUnits, setPriceUnits] = useState([]); // Members States const [members, setMembers] = useState([]); @@ -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
{t.workspace?.loading || "Loading..."}
; return ( -
+

{t.workspace?.editTitle || "Edit Workspace"}

-
-
+
+
-
+

{ t.workspace?.members || "Members" } @@ -404,7 +414,7 @@ export default function EditWorkspace() { )}

-
+
-
- {m.user?.profile_picture ? ( - {m.user?.first_name} - ) : ( +
+
+
+ {m.user?.profile_picture ? ( + {m.user?.first_name} + ) : (
{m.user?.name?.[0] || m.user?.first_name?.[0] || "U"}
@@ -436,43 +447,57 @@ export default function EditWorkspace() {

{m.user?.name || `${m.user?.first_name || ''} ${m.user?.last_name || ''}`.trim() || 'Unknown'}

-

{toPersianNum(m.user?.mobile)}

-
-
- -
- {canChangeThisUserRole ? ( - handleChangeRole(m.id, val)} + options={roleOptions(isFirstOwner)} + buttonClassName="w-[110px] px-3 py-1.5 text-sm" + /> + ) : ( + + {m.role === 'owner' && } + {m.role && m.role in t.workspace.roles + ? t.workspace.roles[m.role as keyof typeof t.workspace.roles] + : m.role || "-"} + + )} + + {canChangeThisUserRole && ( + + )} +
+
+ +
+
+ {t.rates?.workspaceRate || "Workspace rate"} +
+ item.user === m.user.id)} + priceUnits={priceUnits} + onRatesChanged={(updater) => setWorkspaceRates((current) => updater(current))} + /> +
+
+ ); + })}
{members.length === 0 && !isLoadingMembers && (
@@ -480,11 +505,12 @@ export default function EditWorkspace() {

{t.workspace?.noMembers || "No members found."}

-
- )} -
-
-
+
+ )} +
+ +
+
setIsDeleteDialogOpen(false)}>