From 215425dedee85d5c5f8892fa1ea2015cd45abef1 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 24 May 2026 15:18:48 +0330 Subject: [PATCH] feat(projects): improve list filters --- src/components/ui/MultiSearchableSelect.tsx | 185 ++++++++++++ src/locales/fa.ts | 296 ++++++++++---------- src/pages/Projects.tsx | 144 +++++----- 3 files changed, 411 insertions(+), 214 deletions(-) create mode 100644 src/components/ui/MultiSearchableSelect.tsx diff --git a/src/components/ui/MultiSearchableSelect.tsx b/src/components/ui/MultiSearchableSelect.tsx new file mode 100644 index 0000000..55c9369 --- /dev/null +++ b/src/components/ui/MultiSearchableSelect.tsx @@ -0,0 +1,185 @@ +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 MultiSearchableSelectOption { + value: string; + label: string; + searchText?: string; +} + +interface MultiSearchableSelectProps { + values: string[]; + onChange: (values: string[]) => void; + options: MultiSearchableSelectOption[]; + placeholder?: string; + searchPlaceholder?: string; + emptyLabel?: string; + disabled?: boolean; + className?: string; + buttonClassName?: string; + renderValue?: (selectedOptions: MultiSearchableSelectOption[]) => string; +} + +export function MultiSearchableSelect({ + values, + onChange, + options, + placeholder = "", + searchPlaceholder, + emptyLabel, + disabled = false, + className = "", + buttonClassName = "", + renderValue, +}: MultiSearchableSelectProps) { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [dropdownStyle, setDropdownStyle] = useState({}); + const buttonRef = useRef(null); + const dropdownRef = useRef(null); + + const selectedOptions = useMemo( + () => options.filter((option) => values.includes(option.value)), + [options, values], + ); + + 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]); + + const displayValue = useMemo(() => { + if (!selectedOptions.length) return placeholder; + if (renderValue) return renderValue(selectedOptions); + return selectedOptions.map((option) => option.label).join(", "); + }, [placeholder, renderValue, selectedOptions]); + + 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]); + + useEffect(() => { + const handleScrollOrResize = () => setIsOpen(false); + if (isOpen) { + window.addEventListener("resize", handleScrollOrResize); + window.addEventListener("scroll", handleScrollOrResize, true); + } + return () => { + window.removeEventListener("resize", handleScrollOrResize); + window.removeEventListener("scroll", handleScrollOrResize, true); + }; + }, [isOpen]); + + const toggleValue = (value: string) => { + if (values.includes(value)) { + onChange(values.filter((item) => item !== value)); + return; + } + onChange([...values, value]); + }; + + return ( +
+ + + {isOpen && + createPortal( +
+
+
+ + setQuery(event.target.value)} + placeholder={searchPlaceholder || "Search..."} + className="h-9 pl-9 rtl:pl-3 rtl:pr-9" + autoFocus + /> +
+
+ +
+ {filteredOptions.map((option) => { + const isSelected = values.includes(option.value); + return ( + + ); + })} + + {filteredOptions.length === 0 && ( +
+ {emptyLabel || "No results"} +
+ )} +
+
, + document.body, + )} +
+ ); +} diff --git a/src/locales/fa.ts b/src/locales/fa.ts index dae59b4..22118b5 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -1,4 +1,4 @@ -export const fa = { +export const fa = { title: "Qlockify", logout: "خروج", logoutToast: "با موفقیت خارج شدید!", @@ -55,14 +55,14 @@ export const fa = { continueToResetPassword: "ادامه برای تعیین رمز جدید", resetPasswordTitle: "انتخاب رمز عبور جدید", resetPasswordDescription: "رمز عبور جدید خود را وارد کنید و آن را تایید کنید.", - resetPasswordCta: "تغییر رمز عبور", - newPasswordPlaceholder: "رمز عبور جدید", - confirmPasswordPlaceholder: "تکرار رمز عبور", - passwordMismatch: "رمز عبور و تکرار آن یکسان نیستند.", - passwordRequirements: - "رمز عبور باید حداقل 8 کاراکتر باشد و حداقل یک حرف کوچک، یک حرف بزرگ، یک عدد و یک نماد داشته باشد.", - passwordReuse: "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد.", - welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`, + resetPasswordCta: "تغییر رمز عبور", + newPasswordPlaceholder: "رمز عبور جدید", + confirmPasswordPlaceholder: "تکرار رمز عبور", + passwordMismatch: "رمز عبور و تکرار آن یکسان نیستند.", + passwordRequirements: + "رمز عبور باید حداقل 8 کاراکتر باشد و حداقل یک حرف کوچک، یک حرف بزرگ، یک عدد و یک نماد داشته باشد.", + passwordReuse: "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد.", + welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`, enterPassword: "رمز عبور خود را وارد کنید", verifyNumber: "تایید شماره موبایل", enterMobileDesc: "برای ادامه، شماره موبایل خود را وارد کنید", @@ -75,31 +75,31 @@ export const fa = { otpLogin: "ورود با کد یکبار مصرف", register: "ثبت نام", passwordPlaceholder: "رمز عبور", - signIn: "ورود", - back: "بازگشت", - otpPlaceholder: "کد ۵ رقمی", - verifyAndContinue: "تایید و ادامه", - sendingOtp: "در حال ارسال کد...", - verifyingOtp: "در حال تأیید کد...", - resendOtp: "ارسال دوباره کد", - otpExpiresIn: (time: string) => `اعتبار کد تا ${time} دیگر است`, - otpExpired: "اعتبار این کد به پایان رسیده است. برای ادامه کد جدید بگیرید.", - terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت می‌کنید.", + signIn: "ورود", + back: "بازگشت", + otpPlaceholder: "کد ۵ رقمی", + verifyAndContinue: "تایید و ادامه", + sendingOtp: "در حال ارسال کد...", + verifyingOtp: "در حال تأیید کد...", + resendOtp: "ارسال دوباره کد", + otpExpiresIn: (time: string) => `اعتبار کد تا ${time} دیگر است`, + otpExpired: "اعتبار این کد به پایان رسیده است. برای ادامه کد جدید بگیرید.", + terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت می‌کنید.", brandingQuote: "زمان و ورک‌اسپیس‌ها خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.", - toasts: { - enterMobile: "لطفا شماره موبایل خود را وارد کنید", - verifySent: "کد تایید ارسال شد.", - failedOtp: "ارسال کد تایید انجام نشد.", - fillAll: "لطفا تمام فیلدها را پر کنید.", - successLogin: "با موفقیت وارد شدید.", - accountCreated: "حساب با موفقیت ایجاد شد.", - failedSignup: "تکمیل ثبت نام انجام نشد.", - invalidCreds: "اطلاعات ورود نامعتبر است.", - enterOtp: "لطفا کد تایید را وارد کنید.", - invalidOtp: "کد تایید نامعتبر است.", - passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.", - passwordResetFailed: "تغییر رمز عبور انجام نشد." - }, + toasts: { + enterMobile: "لطفا شماره موبایل خود را وارد کنید", + verifySent: "کد تایید ارسال شد.", + failedOtp: "ارسال کد تایید انجام نشد.", + fillAll: "لطفا تمام فیلدها را پر کنید.", + successLogin: "با موفقیت وارد شدید.", + accountCreated: "حساب با موفقیت ایجاد شد.", + failedSignup: "تکمیل ثبت نام انجام نشد.", + invalidCreds: "اطلاعات ورود نامعتبر است.", + enterOtp: "لطفا کد تایید را وارد کنید.", + invalidOtp: "کد تایید نامعتبر است.", + passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.", + passwordResetFailed: "تغییر رمز عبور انجام نشد." + }, throttle: { title: "تعداد تلاش‌ها بیش از حد مجاز است", genericMessage: (time: string) => `درخواست‌های زیادی ارسال شده است. ${time} دیگر دوباره تلاش کنید.`, @@ -109,34 +109,34 @@ export const fa = { countdownLabel: (time: string) => `تلاش دوباره تا ${time}`, fallback: "درخواست‌های زیادی ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.", }, - google: { - loadingTitle: "در حال تکمیل ورود با گوگل", - loadingDescription: "در حال بررسی حساب گوگل شما و آماده‌سازی مرحله بعد هستیم.", - collectMobileTitle: "ساخت حساب را کامل کنید", - collectMobileDescription: (email: string) => - `حساب گوگل ${email} تایید شد. برای تکمیل ساخت حساب، شماره موبایل خود را وارد کنید.`, - existingEmailClaimDescription: (email: string, mobileHint: string) => - `حساب گوگل ${email} تایید شد. برای تایید مالکیت، شماره موبایل متصل به این حساب (${mobileHint}) را وارد کنید.`, - claimTitle: "حساب موجود خود را تایید کنید", - claimDescription: (mobile: string) => - `حسابی با شماره ${mobile} از قبل وجود دارد. کد ارسال‌شده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`, - mobileClaimDescription: (mobile: string) => - `حسابی بدون ایمیل با شماره ${mobile} از قبل وجود دارد. کد ارسال‌شده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`, - errorTitle: "ورود با گوگل کامل نشد", - cancelled: "فرآیند ورود با گوگل قبل از تکمیل لغو شد.", - missingFlow: "جریان ورود با گوگل پیدا نشد یا منقضی شده است.", - loadFailed: "بارگذاری وضعیت ورود با گوگل انجام نشد.", - callbackFailed: "تکمیل ورود با گوگل انجام نشد. لطفاً دوباره تلاش کنید.", - tokenExchangeFailed: "ورود با گوگل موقتاً در دسترس نیست. چند دقیقه دیگر دوباره تلاش کنید.", - profileLookupFailed: "دریافت اطلاعات حساب گوگل انجام نشد. لطفاً دوباره تلاش کنید.", - completeFailed: "تکمیل ساخت حساب با گوگل انجام نشد.", - claimOtpSent: "کد تایید با موفقیت ارسال شد.", - googleAccount: "حساب گوگل", - mobileHintLabel: (mobileHint: string) => `شماره مورد انتظار: ${mobileHint}`, - completeButton: "ادامه و ایجاد حساب", - verifyClaimButton: "تایید و ادامه", - resendClaimOtp: "ارسال دوباره کد تایید", - restartGoogle: "شروع دوباره ورود با گوگل", + google: { + loadingTitle: "در حال تکمیل ورود با گوگل", + loadingDescription: "در حال بررسی حساب گوگل شما و آماده‌سازی مرحله بعد هستیم.", + collectMobileTitle: "ساخت حساب را کامل کنید", + collectMobileDescription: (email: string) => + `حساب گوگل ${email} تایید شد. برای تکمیل ساخت حساب، شماره موبایل خود را وارد کنید.`, + existingEmailClaimDescription: (email: string, mobileHint: string) => + `حساب گوگل ${email} تایید شد. برای تایید مالکیت، شماره موبایل متصل به این حساب (${mobileHint}) را وارد کنید.`, + claimTitle: "حساب موجود خود را تایید کنید", + claimDescription: (mobile: string) => + `حسابی با شماره ${mobile} از قبل وجود دارد. کد ارسال‌شده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`, + mobileClaimDescription: (mobile: string) => + `حسابی بدون ایمیل با شماره ${mobile} از قبل وجود دارد. کد ارسال‌شده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`, + errorTitle: "ورود با گوگل کامل نشد", + cancelled: "فرآیند ورود با گوگل قبل از تکمیل لغو شد.", + missingFlow: "جریان ورود با گوگل پیدا نشد یا منقضی شده است.", + loadFailed: "بارگذاری وضعیت ورود با گوگل انجام نشد.", + callbackFailed: "تکمیل ورود با گوگل انجام نشد. لطفاً دوباره تلاش کنید.", + tokenExchangeFailed: "ورود با گوگل موقتاً در دسترس نیست. چند دقیقه دیگر دوباره تلاش کنید.", + profileLookupFailed: "دریافت اطلاعات حساب گوگل انجام نشد. لطفاً دوباره تلاش کنید.", + completeFailed: "تکمیل ساخت حساب با گوگل انجام نشد.", + claimOtpSent: "کد تایید با موفقیت ارسال شد.", + googleAccount: "حساب گوگل", + mobileHintLabel: (mobileHint: string) => `شماره مورد انتظار: ${mobileHint}`, + completeButton: "ادامه و ایجاد حساب", + verifyClaimButton: "تایید و ادامه", + resendClaimOtp: "ارسال دوباره کد تایید", + restartGoogle: "شروع دوباره ورود با گوگل", } }, @@ -210,31 +210,31 @@ export const fa = { changePicture: "تغییر تصویر", save: "ذخیره", cancel: "لغو", - upload: "آپلود", - remove: "حذف", - imageInput: "برای انتخاب کلیک کنید یا فایل را بکشید", - noEmail: "ایمیلی ثبت نشده", - password: { - trigger: "تغییر رمز عبور", - title: "تغییر رمز عبور", - description: "رمز عبور فعلی خود را وارد کنید و یک رمز جدید انتخاب کنید.", - currentPassword: "رمز عبور فعلی", - newPassword: "رمز عبور جدید", - confirmPassword: "تکرار رمز جدید", - submit: "ذخیره رمز عبور", - saving: "در حال ذخیره...", - toasts: { - success: "رمز عبور با موفقیت تغییر کرد.", - error: "تغییر رمز عبور انجام نشد.", - }, - }, - toasts: { - successEdit: "پروفایل با موفقیت به‌روزرسانی شد.", - successImage: "عکس پروفایل به‌روزرسانی شد.", - successRemoveImage: "عکس پروفایل حذف شد.", - error: "خطایی رخ داد." - } - }, + upload: "آپلود", + remove: "حذف", + imageInput: "برای انتخاب کلیک کنید یا فایل را بکشید", + noEmail: "ایمیلی ثبت نشده", + password: { + trigger: "تغییر رمز عبور", + title: "تغییر رمز عبور", + description: "رمز عبور فعلی خود را وارد کنید و یک رمز جدید انتخاب کنید.", + currentPassword: "رمز عبور فعلی", + newPassword: "رمز عبور جدید", + confirmPassword: "تکرار رمز جدید", + submit: "ذخیره رمز عبور", + saving: "در حال ذخیره...", + toasts: { + success: "رمز عبور با موفقیت تغییر کرد.", + error: "تغییر رمز عبور انجام نشد.", + }, + }, + toasts: { + successEdit: "پروفایل با موفقیت به‌روزرسانی شد.", + successImage: "عکس پروفایل به‌روزرسانی شد.", + successRemoveImage: "عکس پروفایل حذف شد.", + error: "خطایی رخ داد." + } + }, workspace: { title: "مدیریت ورک‌اسپیس‌ها", @@ -285,8 +285,8 @@ export const fa = { membersSectionTitle: "اعضا", membersSectionSubtitle: "اعضای این ورک‌اسپیس و نقش فعلی آن‌ها.", membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.", - projectRateHint: "برای هر کاربر می‌توانید از صفحه پروژه‌ها و داخل پنجره دسترسی پروژه، یک نرخ اختصاصی برای همان پروژه تعریف کنید تا روی نرخ ساعتی ورک‌اسپیس اولویت داشته باشد.", - manageMembers: "مدیریت اعضا", + projectRateHint: "برای هر کاربر می‌توانید از صفحه پروژه‌ها و داخل پنجره دسترسی پروژه، یک نرخ اختصاصی برای همان پروژه تعریف کنید تا روی نرخ ساعتی ورک‌اسپیس اولویت داشته باشد.", + manageMembers: "مدیریت اعضا", mobileNumber: "شماره تماس", youLabel: "شما", resourcesTitle: "منابع", @@ -491,7 +491,7 @@ export const fa = { title: "پروژه‌ها", description: (workspaceName: string) => `مدیریت پروژه‌ها برای ${workspaceName}`, active: "پروژه‌های فعال", - archived: "پروژه‌های بایگانی شده", + archived: "بایگانی شده", createNew: "ایجاد پروژه جدید", searchPlaceholder: "جستجوی پروژه‌ها...", selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.", @@ -535,25 +535,25 @@ export const fa = { manager: "مدیر" }, namePlaceholder: "نام پروژه...", - teamMembers: "اعضای تیم", - manageAccess: "پروژه‌ها و نرخ‌ها", - accessModalTitle: "پروژه‌ها و نرخ‌ها", - accessModalDescription: "دسترسی پروژه‌ها را برای اعضا و مهمان‌ها مدیریت کنید و برای هر کاربر ورک‌اسپیس نرخ اختصاصی پروژه ثبت کنید.", - accessMemberLabel: "کاربر", - accessNoMembers: "کاربری در این ورک‌اسپیس پیدا نشد.", - accessNoProjects: "پروژه‌ای پیدا نشد.", - accessSelectVisible: "انتخاب همه موارد قابل مشاهده", - accessClearSelection: "پاک کردن انتخاب", - accessSelectClientProjects: "انتخاب همه پروژه‌های این مشتری", - accessGrant: "اعطای دسترسی به موارد انتخاب‌شده", - accessRevoke: "لغو دسترسی موارد انتخاب‌شده", - accessOn: "دارای دسترسی", - accessOff: "بدون دسترسی", - accessGrantSuccess: "دسترسی پروژه با موفقیت اعطا شد.", - accessRevokeSuccess: "دسترسی پروژه با موفقیت لغو شد.", - accessLoadError: "بارگذاری وضعیت دسترسی پروژه‌ها انجام نشد.", - accessSaveError: "به‌روزرسانی دسترسی پروژه‌ها انجام نشد.", - implicitAccessHint: "مالک‌ها و ادمین‌ها همیشه به همه پروژه‌ها دسترسی دارند. از اینجا فقط می‌توانید نرخ اختصاصی پروژه برای آن‌ها تنظیم کنید.", + teamMembers: "اعضای تیم", + manageAccess: "پروژه‌ها و نرخ‌ها", + accessModalTitle: "پروژه‌ها و نرخ‌ها", + accessModalDescription: "دسترسی پروژه‌ها را برای اعضا و مهمان‌ها مدیریت کنید و برای هر کاربر ورک‌اسپیس نرخ اختصاصی پروژه ثبت کنید.", + accessMemberLabel: "کاربر", + accessNoMembers: "کاربری در این ورک‌اسپیس پیدا نشد.", + accessNoProjects: "پروژه‌ای پیدا نشد.", + accessSelectVisible: "انتخاب همه موارد قابل مشاهده", + accessClearSelection: "پاک کردن انتخاب", + accessSelectClientProjects: "انتخاب همه پروژه‌های این مشتری", + accessGrant: "اعطای دسترسی به موارد انتخاب‌شده", + accessRevoke: "لغو دسترسی موارد انتخاب‌شده", + accessOn: "دارای دسترسی", + accessOff: "بدون دسترسی", + accessGrantSuccess: "دسترسی پروژه با موفقیت اعطا شد.", + accessRevokeSuccess: "دسترسی پروژه با موفقیت لغو شد.", + accessLoadError: "بارگذاری وضعیت دسترسی پروژه‌ها انجام نشد.", + accessSaveError: "به‌روزرسانی دسترسی پروژه‌ها انجام نشد.", + implicitAccessHint: "مالک‌ها و ادمین‌ها همیشه به همه پروژه‌ها دسترسی دارند. از اینجا فقط می‌توانید نرخ اختصاصی پروژه برای آن‌ها تنظیم کنید.", createSuccess: "پروژه با موفقیت ایجاد شد.", createError: "خطا در ایجاد پروژه.", updateSuccess: "پروژه با موفقیت به‌روزرسانی شد.", @@ -591,21 +591,21 @@ export const fa = { deleteError: "حذف تگ با خطا مواجه شد.", }, - rates: { - workspaceSectionTitle: "نرخ‌های کاربران ورک‌اسپیس", - projectSectionTitle: "نرخ‌های کاربران پروژه", - myRatesTitle: "تعرفه‌های من", - myRatesHint: "نرخ‌های اختصاصی پروژه در این ورک‌اسپیس روی نرخ پیش‌فرض شما اولویت دارند.", - workspaceRate: "دستمزد ساعتی", - workspaceRateHint: "این نرخ پیش‌فرض شما است مگر این‌که برای یک پروژه نرخ اختصاصی ثبت شده باشد.", - projectOverride: "نرخ اختصاصی پروژه", - projectOverrides: "نرخ‌های اختصاصی پروژه", - accessibleProjects: "پروژه‌های دردسترس", - workspaceFallbackProjects: "با نرخ ورک‌اسپیس", - projectOverrideHint: "فقط پروژه‌هایی که نرخ اختصاصی دارند اینجا نمایش داده می‌شوند. بقیه پروژه‌های دردسترس از نرخ ورک‌اسپیس استفاده می‌کنند.", - projectOverrideEmpty: "برای شما در این ورک‌اسپیس هنوز نرخ اختصاصی پروژه‌ای ثبت نشده است.", - myRatesEmpty: "هنوز نرخی برای این ورک‌اسپیس ثبت نشده است.", - inheritsWorkspaceRate: "ارث‌بری از دستمزد ساعتی", + rates: { + workspaceSectionTitle: "نرخ‌های کاربران ورک‌اسپیس", + projectSectionTitle: "نرخ‌های کاربران پروژه", + myRatesTitle: "تعرفه‌های من", + myRatesHint: "نرخ‌های اختصاصی پروژه در این ورک‌اسپیس روی نرخ پیش‌فرض شما اولویت دارند.", + workspaceRate: "دستمزد ساعتی", + workspaceRateHint: "این نرخ پیش‌فرض شما است مگر این‌که برای یک پروژه نرخ اختصاصی ثبت شده باشد.", + projectOverride: "نرخ اختصاصی پروژه", + projectOverrides: "نرخ‌های اختصاصی پروژه", + accessibleProjects: "پروژه‌های دردسترس", + workspaceFallbackProjects: "با نرخ ورک‌اسپیس", + projectOverrideHint: "فقط پروژه‌هایی که نرخ اختصاصی دارند اینجا نمایش داده می‌شوند. بقیه پروژه‌های دردسترس از نرخ ورک‌اسپیس استفاده می‌کنند.", + projectOverrideEmpty: "برای شما در این ورک‌اسپیس هنوز نرخ اختصاصی پروژه‌ای ثبت نشده است.", + myRatesEmpty: "هنوز نرخی برای این ورک‌اسپیس ثبت نشده است.", + inheritsWorkspaceRate: "ارث‌بری از دستمزد ساعتی", noRate: "بدون نرخ", hourlyRatePlaceholder: "0.00", currencyPlaceholder: "USD", @@ -701,8 +701,8 @@ export const fa = { periodCustom: "بازه دلخواه", fromDate: "از تاریخ", toDate: "تا تاریخ", - user: "کاربر", - mobile: "موبایل", + user: "کاربر", + mobile: "موبایل", allUsers: "همه کاربران", searchUsers: "جست‌وجوی کاربران...", client: "مشتری", @@ -720,31 +720,31 @@ export const fa = { totalHours: "مجموع ساعت", billableHours: "ساعات کاری", nonBillableHours: "ساعات غیر کاری", - hourlyRate: "نرخ ساعتی", - hourlyRates: "نرخ‌های ساعتی", - workingHours: "ساعات کاری", - nonWorkingHours: "ساعات غیرکاری", - totalIncome: "مجموع کارکرد", - projectPercentages: "درصد پروژه‌ها", - clientPercentages: "درصد مشتری‌ها", - tagPercentages: "درصد تگ‌ها", - userSummaryTitle: "خلاصه کاربران", - userSummaryDetailsTitle: "جزئیات کاربر: {name}", - userSummaryDetailsDescription: "تاریخچه نرخ‌های ساعتی و توزیع زمان کار برای کاربر انتخاب‌شده را بررسی کنید.", - rateHistory: "تاریخچه نرخ‌ها", - percentage: "درصد", - hourPercentage: "درصد ساعت", - incomePercentage: "درصد کارکرد", - now: "حال", - chartTitle: "نمودار فعالیت", + hourlyRate: "نرخ ساعتی", + hourlyRates: "نرخ‌های ساعتی", + workingHours: "ساعات کاری", + nonWorkingHours: "ساعات غیرکاری", + totalIncome: "مجموع کارکرد", + projectPercentages: "درصد پروژه‌ها", + clientPercentages: "درصد مشتری‌ها", + tagPercentages: "درصد تگ‌ها", + userSummaryTitle: "خلاصه کاربران", + userSummaryDetailsTitle: "جزئیات کاربر: {name}", + userSummaryDetailsDescription: "تاریخچه نرخ‌های ساعتی و توزیع زمان کار برای کاربر انتخاب‌شده را بررسی کنید.", + rateHistory: "تاریخچه نرخ‌ها", + percentage: "درصد", + hourPercentage: "درصد ساعت", + incomePercentage: "درصد کارکرد", + now: "حال", + chartTitle: "نمودار فعالیت", totalSeconds: "مجموع ثانیه", exportExcel: "خروجی Excel", exportPdf: "خروجی PDF", date: "تاریخ", - details: "جزئیات", - total: "مجموع", - noData: "داده‌ای وجود ندارد", - clientsTable: "مشتری‌ها", + details: "جزئیات", + total: "مجموع", + noData: "داده‌ای وجود ندارد", + clientsTable: "مشتری‌ها", projectsTable: "پروژه‌ها", tagsTable: "تگ‌ها", loadError: "دریافت گزارش‌ها با خطا مواجه شد.", @@ -863,5 +863,5 @@ export const fa = { reportExportFailedTitle: "خروجی گزارش ناموفق بود", reportExportFailedMessage: (exportType: string, workspace: string) => `تولید خروجی ${exportType.toUpperCase()} گزارش ${workspace} با خطا مواجه شد.`, - }, -} + }, +} diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx index 2b30491..2a5f672 100644 --- a/src/pages/Projects.tsx +++ b/src/pages/Projects.tsx @@ -9,7 +9,7 @@ import { ProjectCreateModal } from "../components/projects/ProjectCreateModal"; import { ProjectEditModal } from "../components/projects/ProjectEditModal"; import { ProjectAccessModal } from "../components/projects/ProjectAccessModal"; import { Pagination } from "../components/Pagination"; -import { Plus, Archive, Building2, Pencil, ShieldCheck, Trash2, X } from "lucide-react"; +import { Plus, Building2, Pencil, ShieldCheck, Trash2, X } from "lucide-react"; import EmptyStateCard from "../components/EmptyStateCard"; import FilterBar from "../components/FilterBar"; @@ -19,6 +19,7 @@ import { Card, CardContent, CardTitle } from "../components/ui/card"; import { Modal } from "../components/Modal"; import { toast } from "sonner"; import { Input } from "../components/ui/input"; +import { MultiSearchableSelect } from "../components/ui/MultiSearchableSelect"; import { PROJECTS_ARCHIVE, PROJECTS_CREATE, @@ -188,13 +189,16 @@ export const Projects: React.FC = () => { return [...selected, ...unselected]; }, [clients, selectedClientIdsKey]); - const toggleClientFilter = (clientId: string) => { - const nextClientIds = selectedClientIds.includes(clientId) - ? selectedClientIds.filter((id) => id !== clientId) - : [...selectedClientIds, clientId]; + const clientOptions = useMemo( + () => + sortedClients.map((client) => ({ + value: client.id, + label: client.name, + })), + [sortedClients], + ); - updateListParams({ clients: nextClientIds, page: 1 }); - }; + const hasActiveProjectFilters = selectedClientIds.length > 0 || isArchived; const updateListParams = ( updates: Record, @@ -243,16 +247,6 @@ export const Projects: React.FC = () => { {t.projects?.manageAccess || "Manage access"} )} - {canArchiveProject && ( - - )} {canCreateProject && ( - ) : null} - +
+
+
+
+ {t.projects?.filterClients || "Filter by client"} +
+ updateListParams({ clients: values, page: 1 })} + options={clientOptions} + placeholder={t.reports?.allClients || "All clients"} + searchPlaceholder={t.reports?.searchClients || "Search clients..."} + emptyLabel={t.clients?.noClients || "No clients found"} + renderValue={(selectedOptions) => { + if (selectedOptions.length === 0) { + return t.reports?.allClients || "All clients"; + } + if (selectedOptions.length <= 2) { + return selectedOptions.map((option) => option.label).join(", "); + } + return `${selectedOptions[0]?.label} +${selectedOptions.length - 1}`; + }} + buttonClassName="min-h-11 w-full rounded-xl border-slate-200 bg-slate-50/80 dark:border-slate-700" + /> +
-
-
- {sortedClients.map((client) => { - const isSelected = selectedClientIds.includes(client.id); - return ( + {canArchiveProject ? ( +
+
+ {t.projects?.archived || "Archived Projects"} +
- ); - })} +
+ ) : null}
+ +