feat(projects): improve list filters
This commit is contained in:
185
src/components/ui/MultiSearchableSelect.tsx
Normal file
185
src/components/ui/MultiSearchableSelect.tsx
Normal file
@@ -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<React.CSSProperties>({});
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<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 text-start">{displayValue}</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 rtl:left-auto rtl:right-3" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder={searchPlaceholder || "Search..."}
|
||||||
|
className="h-9 pl-9 rtl:pl-3 rtl:pr-9"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-64 overflow-y-auto py-1">
|
||||||
|
{filteredOptions.map((option) => {
|
||||||
|
const isSelected = values.includes(option.value);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleValue(option.value)}
|
||||||
|
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 ${
|
||||||
|
isSelected
|
||||||
|
? "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>
|
||||||
|
{isSelected ? <Check className="h-4 w-4 shrink-0" /> : <span 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">
|
||||||
|
{emptyLabel || "No results"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const fa = {
|
export const fa = {
|
||||||
title: "Qlockify",
|
title: "Qlockify",
|
||||||
logout: "خروج",
|
logout: "خروج",
|
||||||
logoutToast: "با موفقیت خارج شدید!",
|
logoutToast: "با موفقیت خارج شدید!",
|
||||||
@@ -55,14 +55,14 @@ export const fa = {
|
|||||||
continueToResetPassword: "ادامه برای تعیین رمز جدید",
|
continueToResetPassword: "ادامه برای تعیین رمز جدید",
|
||||||
resetPasswordTitle: "انتخاب رمز عبور جدید",
|
resetPasswordTitle: "انتخاب رمز عبور جدید",
|
||||||
resetPasswordDescription: "رمز عبور جدید خود را وارد کنید و آن را تایید کنید.",
|
resetPasswordDescription: "رمز عبور جدید خود را وارد کنید و آن را تایید کنید.",
|
||||||
resetPasswordCta: "تغییر رمز عبور",
|
resetPasswordCta: "تغییر رمز عبور",
|
||||||
newPasswordPlaceholder: "رمز عبور جدید",
|
newPasswordPlaceholder: "رمز عبور جدید",
|
||||||
confirmPasswordPlaceholder: "تکرار رمز عبور",
|
confirmPasswordPlaceholder: "تکرار رمز عبور",
|
||||||
passwordMismatch: "رمز عبور و تکرار آن یکسان نیستند.",
|
passwordMismatch: "رمز عبور و تکرار آن یکسان نیستند.",
|
||||||
passwordRequirements:
|
passwordRequirements:
|
||||||
"رمز عبور باید حداقل 8 کاراکتر باشد و حداقل یک حرف کوچک، یک حرف بزرگ، یک عدد و یک نماد داشته باشد.",
|
"رمز عبور باید حداقل 8 کاراکتر باشد و حداقل یک حرف کوچک، یک حرف بزرگ، یک عدد و یک نماد داشته باشد.",
|
||||||
passwordReuse: "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد.",
|
passwordReuse: "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد.",
|
||||||
welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`,
|
welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`,
|
||||||
enterPassword: "رمز عبور خود را وارد کنید",
|
enterPassword: "رمز عبور خود را وارد کنید",
|
||||||
verifyNumber: "تایید شماره موبایل",
|
verifyNumber: "تایید شماره موبایل",
|
||||||
enterMobileDesc: "برای ادامه، شماره موبایل خود را وارد کنید",
|
enterMobileDesc: "برای ادامه، شماره موبایل خود را وارد کنید",
|
||||||
@@ -75,31 +75,31 @@ export const fa = {
|
|||||||
otpLogin: "ورود با کد یکبار مصرف",
|
otpLogin: "ورود با کد یکبار مصرف",
|
||||||
register: "ثبت نام",
|
register: "ثبت نام",
|
||||||
passwordPlaceholder: "رمز عبور",
|
passwordPlaceholder: "رمز عبور",
|
||||||
signIn: "ورود",
|
signIn: "ورود",
|
||||||
back: "بازگشت",
|
back: "بازگشت",
|
||||||
otpPlaceholder: "کد ۵ رقمی",
|
otpPlaceholder: "کد ۵ رقمی",
|
||||||
verifyAndContinue: "تایید و ادامه",
|
verifyAndContinue: "تایید و ادامه",
|
||||||
sendingOtp: "در حال ارسال کد...",
|
sendingOtp: "در حال ارسال کد...",
|
||||||
verifyingOtp: "در حال تأیید کد...",
|
verifyingOtp: "در حال تأیید کد...",
|
||||||
resendOtp: "ارسال دوباره کد",
|
resendOtp: "ارسال دوباره کد",
|
||||||
otpExpiresIn: (time: string) => `اعتبار کد تا ${time} دیگر است`,
|
otpExpiresIn: (time: string) => `اعتبار کد تا ${time} دیگر است`,
|
||||||
otpExpired: "اعتبار این کد به پایان رسیده است. برای ادامه کد جدید بگیرید.",
|
otpExpired: "اعتبار این کد به پایان رسیده است. برای ادامه کد جدید بگیرید.",
|
||||||
terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت میکنید.",
|
terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت میکنید.",
|
||||||
brandingQuote: "زمان و ورکاسپیسها خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.",
|
brandingQuote: "زمان و ورکاسپیسها خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.",
|
||||||
toasts: {
|
toasts: {
|
||||||
enterMobile: "لطفا شماره موبایل خود را وارد کنید",
|
enterMobile: "لطفا شماره موبایل خود را وارد کنید",
|
||||||
verifySent: "کد تایید ارسال شد.",
|
verifySent: "کد تایید ارسال شد.",
|
||||||
failedOtp: "ارسال کد تایید انجام نشد.",
|
failedOtp: "ارسال کد تایید انجام نشد.",
|
||||||
fillAll: "لطفا تمام فیلدها را پر کنید.",
|
fillAll: "لطفا تمام فیلدها را پر کنید.",
|
||||||
successLogin: "با موفقیت وارد شدید.",
|
successLogin: "با موفقیت وارد شدید.",
|
||||||
accountCreated: "حساب با موفقیت ایجاد شد.",
|
accountCreated: "حساب با موفقیت ایجاد شد.",
|
||||||
failedSignup: "تکمیل ثبت نام انجام نشد.",
|
failedSignup: "تکمیل ثبت نام انجام نشد.",
|
||||||
invalidCreds: "اطلاعات ورود نامعتبر است.",
|
invalidCreds: "اطلاعات ورود نامعتبر است.",
|
||||||
enterOtp: "لطفا کد تایید را وارد کنید.",
|
enterOtp: "لطفا کد تایید را وارد کنید.",
|
||||||
invalidOtp: "کد تایید نامعتبر است.",
|
invalidOtp: "کد تایید نامعتبر است.",
|
||||||
passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.",
|
passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.",
|
||||||
passwordResetFailed: "تغییر رمز عبور انجام نشد."
|
passwordResetFailed: "تغییر رمز عبور انجام نشد."
|
||||||
},
|
},
|
||||||
throttle: {
|
throttle: {
|
||||||
title: "تعداد تلاشها بیش از حد مجاز است",
|
title: "تعداد تلاشها بیش از حد مجاز است",
|
||||||
genericMessage: (time: string) => `درخواستهای زیادی ارسال شده است. ${time} دیگر دوباره تلاش کنید.`,
|
genericMessage: (time: string) => `درخواستهای زیادی ارسال شده است. ${time} دیگر دوباره تلاش کنید.`,
|
||||||
@@ -109,34 +109,34 @@ export const fa = {
|
|||||||
countdownLabel: (time: string) => `تلاش دوباره تا ${time}`,
|
countdownLabel: (time: string) => `تلاش دوباره تا ${time}`,
|
||||||
fallback: "درخواستهای زیادی ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.",
|
fallback: "درخواستهای زیادی ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.",
|
||||||
},
|
},
|
||||||
google: {
|
google: {
|
||||||
loadingTitle: "در حال تکمیل ورود با گوگل",
|
loadingTitle: "در حال تکمیل ورود با گوگل",
|
||||||
loadingDescription: "در حال بررسی حساب گوگل شما و آمادهسازی مرحله بعد هستیم.",
|
loadingDescription: "در حال بررسی حساب گوگل شما و آمادهسازی مرحله بعد هستیم.",
|
||||||
collectMobileTitle: "ساخت حساب را کامل کنید",
|
collectMobileTitle: "ساخت حساب را کامل کنید",
|
||||||
collectMobileDescription: (email: string) =>
|
collectMobileDescription: (email: string) =>
|
||||||
`حساب گوگل ${email} تایید شد. برای تکمیل ساخت حساب، شماره موبایل خود را وارد کنید.`,
|
`حساب گوگل ${email} تایید شد. برای تکمیل ساخت حساب، شماره موبایل خود را وارد کنید.`,
|
||||||
existingEmailClaimDescription: (email: string, mobileHint: string) =>
|
existingEmailClaimDescription: (email: string, mobileHint: string) =>
|
||||||
`حساب گوگل ${email} تایید شد. برای تایید مالکیت، شماره موبایل متصل به این حساب (${mobileHint}) را وارد کنید.`,
|
`حساب گوگل ${email} تایید شد. برای تایید مالکیت، شماره موبایل متصل به این حساب (${mobileHint}) را وارد کنید.`,
|
||||||
claimTitle: "حساب موجود خود را تایید کنید",
|
claimTitle: "حساب موجود خود را تایید کنید",
|
||||||
claimDescription: (mobile: string) =>
|
claimDescription: (mobile: string) =>
|
||||||
`حسابی با شماره ${mobile} از قبل وجود دارد. کد ارسالشده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
|
`حسابی با شماره ${mobile} از قبل وجود دارد. کد ارسالشده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
|
||||||
mobileClaimDescription: (mobile: string) =>
|
mobileClaimDescription: (mobile: string) =>
|
||||||
`حسابی بدون ایمیل با شماره ${mobile} از قبل وجود دارد. کد ارسالشده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
|
`حسابی بدون ایمیل با شماره ${mobile} از قبل وجود دارد. کد ارسالشده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
|
||||||
errorTitle: "ورود با گوگل کامل نشد",
|
errorTitle: "ورود با گوگل کامل نشد",
|
||||||
cancelled: "فرآیند ورود با گوگل قبل از تکمیل لغو شد.",
|
cancelled: "فرآیند ورود با گوگل قبل از تکمیل لغو شد.",
|
||||||
missingFlow: "جریان ورود با گوگل پیدا نشد یا منقضی شده است.",
|
missingFlow: "جریان ورود با گوگل پیدا نشد یا منقضی شده است.",
|
||||||
loadFailed: "بارگذاری وضعیت ورود با گوگل انجام نشد.",
|
loadFailed: "بارگذاری وضعیت ورود با گوگل انجام نشد.",
|
||||||
callbackFailed: "تکمیل ورود با گوگل انجام نشد. لطفاً دوباره تلاش کنید.",
|
callbackFailed: "تکمیل ورود با گوگل انجام نشد. لطفاً دوباره تلاش کنید.",
|
||||||
tokenExchangeFailed: "ورود با گوگل موقتاً در دسترس نیست. چند دقیقه دیگر دوباره تلاش کنید.",
|
tokenExchangeFailed: "ورود با گوگل موقتاً در دسترس نیست. چند دقیقه دیگر دوباره تلاش کنید.",
|
||||||
profileLookupFailed: "دریافت اطلاعات حساب گوگل انجام نشد. لطفاً دوباره تلاش کنید.",
|
profileLookupFailed: "دریافت اطلاعات حساب گوگل انجام نشد. لطفاً دوباره تلاش کنید.",
|
||||||
completeFailed: "تکمیل ساخت حساب با گوگل انجام نشد.",
|
completeFailed: "تکمیل ساخت حساب با گوگل انجام نشد.",
|
||||||
claimOtpSent: "کد تایید با موفقیت ارسال شد.",
|
claimOtpSent: "کد تایید با موفقیت ارسال شد.",
|
||||||
googleAccount: "حساب گوگل",
|
googleAccount: "حساب گوگل",
|
||||||
mobileHintLabel: (mobileHint: string) => `شماره مورد انتظار: ${mobileHint}`,
|
mobileHintLabel: (mobileHint: string) => `شماره مورد انتظار: ${mobileHint}`,
|
||||||
completeButton: "ادامه و ایجاد حساب",
|
completeButton: "ادامه و ایجاد حساب",
|
||||||
verifyClaimButton: "تایید و ادامه",
|
verifyClaimButton: "تایید و ادامه",
|
||||||
resendClaimOtp: "ارسال دوباره کد تایید",
|
resendClaimOtp: "ارسال دوباره کد تایید",
|
||||||
restartGoogle: "شروع دوباره ورود با گوگل",
|
restartGoogle: "شروع دوباره ورود با گوگل",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -210,31 +210,31 @@ export const fa = {
|
|||||||
changePicture: "تغییر تصویر",
|
changePicture: "تغییر تصویر",
|
||||||
save: "ذخیره",
|
save: "ذخیره",
|
||||||
cancel: "لغو",
|
cancel: "لغو",
|
||||||
upload: "آپلود",
|
upload: "آپلود",
|
||||||
remove: "حذف",
|
remove: "حذف",
|
||||||
imageInput: "برای انتخاب کلیک کنید یا فایل را بکشید",
|
imageInput: "برای انتخاب کلیک کنید یا فایل را بکشید",
|
||||||
noEmail: "ایمیلی ثبت نشده",
|
noEmail: "ایمیلی ثبت نشده",
|
||||||
password: {
|
password: {
|
||||||
trigger: "تغییر رمز عبور",
|
trigger: "تغییر رمز عبور",
|
||||||
title: "تغییر رمز عبور",
|
title: "تغییر رمز عبور",
|
||||||
description: "رمز عبور فعلی خود را وارد کنید و یک رمز جدید انتخاب کنید.",
|
description: "رمز عبور فعلی خود را وارد کنید و یک رمز جدید انتخاب کنید.",
|
||||||
currentPassword: "رمز عبور فعلی",
|
currentPassword: "رمز عبور فعلی",
|
||||||
newPassword: "رمز عبور جدید",
|
newPassword: "رمز عبور جدید",
|
||||||
confirmPassword: "تکرار رمز جدید",
|
confirmPassword: "تکرار رمز جدید",
|
||||||
submit: "ذخیره رمز عبور",
|
submit: "ذخیره رمز عبور",
|
||||||
saving: "در حال ذخیره...",
|
saving: "در حال ذخیره...",
|
||||||
toasts: {
|
toasts: {
|
||||||
success: "رمز عبور با موفقیت تغییر کرد.",
|
success: "رمز عبور با موفقیت تغییر کرد.",
|
||||||
error: "تغییر رمز عبور انجام نشد.",
|
error: "تغییر رمز عبور انجام نشد.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
toasts: {
|
toasts: {
|
||||||
successEdit: "پروفایل با موفقیت بهروزرسانی شد.",
|
successEdit: "پروفایل با موفقیت بهروزرسانی شد.",
|
||||||
successImage: "عکس پروفایل بهروزرسانی شد.",
|
successImage: "عکس پروفایل بهروزرسانی شد.",
|
||||||
successRemoveImage: "عکس پروفایل حذف شد.",
|
successRemoveImage: "عکس پروفایل حذف شد.",
|
||||||
error: "خطایی رخ داد."
|
error: "خطایی رخ داد."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
workspace: {
|
workspace: {
|
||||||
title: "مدیریت ورکاسپیسها",
|
title: "مدیریت ورکاسپیسها",
|
||||||
@@ -285,8 +285,8 @@ export const fa = {
|
|||||||
membersSectionTitle: "اعضا",
|
membersSectionTitle: "اعضا",
|
||||||
membersSectionSubtitle: "اعضای این ورکاسپیس و نقش فعلی آنها.",
|
membersSectionSubtitle: "اعضای این ورکاسپیس و نقش فعلی آنها.",
|
||||||
membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.",
|
membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.",
|
||||||
projectRateHint: "برای هر کاربر میتوانید از صفحه پروژهها و داخل پنجره دسترسی پروژه، یک نرخ اختصاصی برای همان پروژه تعریف کنید تا روی نرخ ساعتی ورکاسپیس اولویت داشته باشد.",
|
projectRateHint: "برای هر کاربر میتوانید از صفحه پروژهها و داخل پنجره دسترسی پروژه، یک نرخ اختصاصی برای همان پروژه تعریف کنید تا روی نرخ ساعتی ورکاسپیس اولویت داشته باشد.",
|
||||||
manageMembers: "مدیریت اعضا",
|
manageMembers: "مدیریت اعضا",
|
||||||
mobileNumber: "شماره تماس",
|
mobileNumber: "شماره تماس",
|
||||||
youLabel: "شما",
|
youLabel: "شما",
|
||||||
resourcesTitle: "منابع",
|
resourcesTitle: "منابع",
|
||||||
@@ -491,7 +491,7 @@ export const fa = {
|
|||||||
title: "پروژهها",
|
title: "پروژهها",
|
||||||
description: (workspaceName: string) => `مدیریت پروژهها برای ${workspaceName}`,
|
description: (workspaceName: string) => `مدیریت پروژهها برای ${workspaceName}`,
|
||||||
active: "پروژههای فعال",
|
active: "پروژههای فعال",
|
||||||
archived: "پروژههای بایگانی شده",
|
archived: "بایگانی شده",
|
||||||
createNew: "ایجاد پروژه جدید",
|
createNew: "ایجاد پروژه جدید",
|
||||||
searchPlaceholder: "جستجوی پروژهها...",
|
searchPlaceholder: "جستجوی پروژهها...",
|
||||||
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
||||||
@@ -535,25 +535,25 @@ export const fa = {
|
|||||||
manager: "مدیر"
|
manager: "مدیر"
|
||||||
},
|
},
|
||||||
namePlaceholder: "نام پروژه...",
|
namePlaceholder: "نام پروژه...",
|
||||||
teamMembers: "اعضای تیم",
|
teamMembers: "اعضای تیم",
|
||||||
manageAccess: "پروژهها و نرخها",
|
manageAccess: "پروژهها و نرخها",
|
||||||
accessModalTitle: "پروژهها و نرخها",
|
accessModalTitle: "پروژهها و نرخها",
|
||||||
accessModalDescription: "دسترسی پروژهها را برای اعضا و مهمانها مدیریت کنید و برای هر کاربر ورکاسپیس نرخ اختصاصی پروژه ثبت کنید.",
|
accessModalDescription: "دسترسی پروژهها را برای اعضا و مهمانها مدیریت کنید و برای هر کاربر ورکاسپیس نرخ اختصاصی پروژه ثبت کنید.",
|
||||||
accessMemberLabel: "کاربر",
|
accessMemberLabel: "کاربر",
|
||||||
accessNoMembers: "کاربری در این ورکاسپیس پیدا نشد.",
|
accessNoMembers: "کاربری در این ورکاسپیس پیدا نشد.",
|
||||||
accessNoProjects: "پروژهای پیدا نشد.",
|
accessNoProjects: "پروژهای پیدا نشد.",
|
||||||
accessSelectVisible: "انتخاب همه موارد قابل مشاهده",
|
accessSelectVisible: "انتخاب همه موارد قابل مشاهده",
|
||||||
accessClearSelection: "پاک کردن انتخاب",
|
accessClearSelection: "پاک کردن انتخاب",
|
||||||
accessSelectClientProjects: "انتخاب همه پروژههای این مشتری",
|
accessSelectClientProjects: "انتخاب همه پروژههای این مشتری",
|
||||||
accessGrant: "اعطای دسترسی به موارد انتخابشده",
|
accessGrant: "اعطای دسترسی به موارد انتخابشده",
|
||||||
accessRevoke: "لغو دسترسی موارد انتخابشده",
|
accessRevoke: "لغو دسترسی موارد انتخابشده",
|
||||||
accessOn: "دارای دسترسی",
|
accessOn: "دارای دسترسی",
|
||||||
accessOff: "بدون دسترسی",
|
accessOff: "بدون دسترسی",
|
||||||
accessGrantSuccess: "دسترسی پروژه با موفقیت اعطا شد.",
|
accessGrantSuccess: "دسترسی پروژه با موفقیت اعطا شد.",
|
||||||
accessRevokeSuccess: "دسترسی پروژه با موفقیت لغو شد.",
|
accessRevokeSuccess: "دسترسی پروژه با موفقیت لغو شد.",
|
||||||
accessLoadError: "بارگذاری وضعیت دسترسی پروژهها انجام نشد.",
|
accessLoadError: "بارگذاری وضعیت دسترسی پروژهها انجام نشد.",
|
||||||
accessSaveError: "بهروزرسانی دسترسی پروژهها انجام نشد.",
|
accessSaveError: "بهروزرسانی دسترسی پروژهها انجام نشد.",
|
||||||
implicitAccessHint: "مالکها و ادمینها همیشه به همه پروژهها دسترسی دارند. از اینجا فقط میتوانید نرخ اختصاصی پروژه برای آنها تنظیم کنید.",
|
implicitAccessHint: "مالکها و ادمینها همیشه به همه پروژهها دسترسی دارند. از اینجا فقط میتوانید نرخ اختصاصی پروژه برای آنها تنظیم کنید.",
|
||||||
createSuccess: "پروژه با موفقیت ایجاد شد.",
|
createSuccess: "پروژه با موفقیت ایجاد شد.",
|
||||||
createError: "خطا در ایجاد پروژه.",
|
createError: "خطا در ایجاد پروژه.",
|
||||||
updateSuccess: "پروژه با موفقیت بهروزرسانی شد.",
|
updateSuccess: "پروژه با موفقیت بهروزرسانی شد.",
|
||||||
@@ -591,21 +591,21 @@ export const fa = {
|
|||||||
deleteError: "حذف تگ با خطا مواجه شد.",
|
deleteError: "حذف تگ با خطا مواجه شد.",
|
||||||
},
|
},
|
||||||
|
|
||||||
rates: {
|
rates: {
|
||||||
workspaceSectionTitle: "نرخهای کاربران ورکاسپیس",
|
workspaceSectionTitle: "نرخهای کاربران ورکاسپیس",
|
||||||
projectSectionTitle: "نرخهای کاربران پروژه",
|
projectSectionTitle: "نرخهای کاربران پروژه",
|
||||||
myRatesTitle: "تعرفههای من",
|
myRatesTitle: "تعرفههای من",
|
||||||
myRatesHint: "نرخهای اختصاصی پروژه در این ورکاسپیس روی نرخ پیشفرض شما اولویت دارند.",
|
myRatesHint: "نرخهای اختصاصی پروژه در این ورکاسپیس روی نرخ پیشفرض شما اولویت دارند.",
|
||||||
workspaceRate: "دستمزد ساعتی",
|
workspaceRate: "دستمزد ساعتی",
|
||||||
workspaceRateHint: "این نرخ پیشفرض شما است مگر اینکه برای یک پروژه نرخ اختصاصی ثبت شده باشد.",
|
workspaceRateHint: "این نرخ پیشفرض شما است مگر اینکه برای یک پروژه نرخ اختصاصی ثبت شده باشد.",
|
||||||
projectOverride: "نرخ اختصاصی پروژه",
|
projectOverride: "نرخ اختصاصی پروژه",
|
||||||
projectOverrides: "نرخهای اختصاصی پروژه",
|
projectOverrides: "نرخهای اختصاصی پروژه",
|
||||||
accessibleProjects: "پروژههای دردسترس",
|
accessibleProjects: "پروژههای دردسترس",
|
||||||
workspaceFallbackProjects: "با نرخ ورکاسپیس",
|
workspaceFallbackProjects: "با نرخ ورکاسپیس",
|
||||||
projectOverrideHint: "فقط پروژههایی که نرخ اختصاصی دارند اینجا نمایش داده میشوند. بقیه پروژههای دردسترس از نرخ ورکاسپیس استفاده میکنند.",
|
projectOverrideHint: "فقط پروژههایی که نرخ اختصاصی دارند اینجا نمایش داده میشوند. بقیه پروژههای دردسترس از نرخ ورکاسپیس استفاده میکنند.",
|
||||||
projectOverrideEmpty: "برای شما در این ورکاسپیس هنوز نرخ اختصاصی پروژهای ثبت نشده است.",
|
projectOverrideEmpty: "برای شما در این ورکاسپیس هنوز نرخ اختصاصی پروژهای ثبت نشده است.",
|
||||||
myRatesEmpty: "هنوز نرخی برای این ورکاسپیس ثبت نشده است.",
|
myRatesEmpty: "هنوز نرخی برای این ورکاسپیس ثبت نشده است.",
|
||||||
inheritsWorkspaceRate: "ارثبری از دستمزد ساعتی",
|
inheritsWorkspaceRate: "ارثبری از دستمزد ساعتی",
|
||||||
noRate: "بدون نرخ",
|
noRate: "بدون نرخ",
|
||||||
hourlyRatePlaceholder: "0.00",
|
hourlyRatePlaceholder: "0.00",
|
||||||
currencyPlaceholder: "USD",
|
currencyPlaceholder: "USD",
|
||||||
@@ -701,8 +701,8 @@ export const fa = {
|
|||||||
periodCustom: "بازه دلخواه",
|
periodCustom: "بازه دلخواه",
|
||||||
fromDate: "از تاریخ",
|
fromDate: "از تاریخ",
|
||||||
toDate: "تا تاریخ",
|
toDate: "تا تاریخ",
|
||||||
user: "کاربر",
|
user: "کاربر",
|
||||||
mobile: "موبایل",
|
mobile: "موبایل",
|
||||||
allUsers: "همه کاربران",
|
allUsers: "همه کاربران",
|
||||||
searchUsers: "جستوجوی کاربران...",
|
searchUsers: "جستوجوی کاربران...",
|
||||||
client: "مشتری",
|
client: "مشتری",
|
||||||
@@ -720,31 +720,31 @@ export const fa = {
|
|||||||
totalHours: "مجموع ساعت",
|
totalHours: "مجموع ساعت",
|
||||||
billableHours: "ساعات کاری",
|
billableHours: "ساعات کاری",
|
||||||
nonBillableHours: "ساعات غیر کاری",
|
nonBillableHours: "ساعات غیر کاری",
|
||||||
hourlyRate: "نرخ ساعتی",
|
hourlyRate: "نرخ ساعتی",
|
||||||
hourlyRates: "نرخهای ساعتی",
|
hourlyRates: "نرخهای ساعتی",
|
||||||
workingHours: "ساعات کاری",
|
workingHours: "ساعات کاری",
|
||||||
nonWorkingHours: "ساعات غیرکاری",
|
nonWorkingHours: "ساعات غیرکاری",
|
||||||
totalIncome: "مجموع کارکرد",
|
totalIncome: "مجموع کارکرد",
|
||||||
projectPercentages: "درصد پروژهها",
|
projectPercentages: "درصد پروژهها",
|
||||||
clientPercentages: "درصد مشتریها",
|
clientPercentages: "درصد مشتریها",
|
||||||
tagPercentages: "درصد تگها",
|
tagPercentages: "درصد تگها",
|
||||||
userSummaryTitle: "خلاصه کاربران",
|
userSummaryTitle: "خلاصه کاربران",
|
||||||
userSummaryDetailsTitle: "جزئیات کاربر: {name}",
|
userSummaryDetailsTitle: "جزئیات کاربر: {name}",
|
||||||
userSummaryDetailsDescription: "تاریخچه نرخهای ساعتی و توزیع زمان کار برای کاربر انتخابشده را بررسی کنید.",
|
userSummaryDetailsDescription: "تاریخچه نرخهای ساعتی و توزیع زمان کار برای کاربر انتخابشده را بررسی کنید.",
|
||||||
rateHistory: "تاریخچه نرخها",
|
rateHistory: "تاریخچه نرخها",
|
||||||
percentage: "درصد",
|
percentage: "درصد",
|
||||||
hourPercentage: "درصد ساعت",
|
hourPercentage: "درصد ساعت",
|
||||||
incomePercentage: "درصد کارکرد",
|
incomePercentage: "درصد کارکرد",
|
||||||
now: "حال",
|
now: "حال",
|
||||||
chartTitle: "نمودار فعالیت",
|
chartTitle: "نمودار فعالیت",
|
||||||
totalSeconds: "مجموع ثانیه",
|
totalSeconds: "مجموع ثانیه",
|
||||||
exportExcel: "خروجی Excel",
|
exportExcel: "خروجی Excel",
|
||||||
exportPdf: "خروجی PDF",
|
exportPdf: "خروجی PDF",
|
||||||
date: "تاریخ",
|
date: "تاریخ",
|
||||||
details: "جزئیات",
|
details: "جزئیات",
|
||||||
total: "مجموع",
|
total: "مجموع",
|
||||||
noData: "دادهای وجود ندارد",
|
noData: "دادهای وجود ندارد",
|
||||||
clientsTable: "مشتریها",
|
clientsTable: "مشتریها",
|
||||||
projectsTable: "پروژهها",
|
projectsTable: "پروژهها",
|
||||||
tagsTable: "تگها",
|
tagsTable: "تگها",
|
||||||
loadError: "دریافت گزارشها با خطا مواجه شد.",
|
loadError: "دریافت گزارشها با خطا مواجه شد.",
|
||||||
@@ -863,5 +863,5 @@ export const fa = {
|
|||||||
reportExportFailedTitle: "خروجی گزارش ناموفق بود",
|
reportExportFailedTitle: "خروجی گزارش ناموفق بود",
|
||||||
reportExportFailedMessage: (exportType: string, workspace: string) =>
|
reportExportFailedMessage: (exportType: string, workspace: string) =>
|
||||||
`تولید خروجی ${exportType.toUpperCase()} گزارش ${workspace} با خطا مواجه شد.`,
|
`تولید خروجی ${exportType.toUpperCase()} گزارش ${workspace} با خطا مواجه شد.`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ProjectCreateModal } from "../components/projects/ProjectCreateModal";
|
|||||||
import { ProjectEditModal } from "../components/projects/ProjectEditModal";
|
import { ProjectEditModal } from "../components/projects/ProjectEditModal";
|
||||||
import { ProjectAccessModal } from "../components/projects/ProjectAccessModal";
|
import { ProjectAccessModal } from "../components/projects/ProjectAccessModal";
|
||||||
import { Pagination } from "../components/Pagination";
|
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 EmptyStateCard from "../components/EmptyStateCard";
|
||||||
import FilterBar from "../components/FilterBar";
|
import FilterBar from "../components/FilterBar";
|
||||||
@@ -19,6 +19,7 @@ import { Card, CardContent, CardTitle } from "../components/ui/card";
|
|||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
|
import { MultiSearchableSelect } from "../components/ui/MultiSearchableSelect";
|
||||||
import {
|
import {
|
||||||
PROJECTS_ARCHIVE,
|
PROJECTS_ARCHIVE,
|
||||||
PROJECTS_CREATE,
|
PROJECTS_CREATE,
|
||||||
@@ -188,13 +189,16 @@ export const Projects: React.FC = () => {
|
|||||||
return [...selected, ...unselected];
|
return [...selected, ...unselected];
|
||||||
}, [clients, selectedClientIdsKey]);
|
}, [clients, selectedClientIdsKey]);
|
||||||
|
|
||||||
const toggleClientFilter = (clientId: string) => {
|
const clientOptions = useMemo(
|
||||||
const nextClientIds = selectedClientIds.includes(clientId)
|
() =>
|
||||||
? selectedClientIds.filter((id) => id !== clientId)
|
sortedClients.map((client) => ({
|
||||||
: [...selectedClientIds, clientId];
|
value: client.id,
|
||||||
|
label: client.name,
|
||||||
|
})),
|
||||||
|
[sortedClients],
|
||||||
|
);
|
||||||
|
|
||||||
updateListParams({ clients: nextClientIds, page: 1 });
|
const hasActiveProjectFilters = selectedClientIds.length > 0 || isArchived;
|
||||||
};
|
|
||||||
|
|
||||||
const updateListParams = (
|
const updateListParams = (
|
||||||
updates: Record<string, string | number | boolean | null | undefined | string[]>,
|
updates: Record<string, string | number | boolean | null | undefined | string[]>,
|
||||||
@@ -243,16 +247,6 @@ export const Projects: React.FC = () => {
|
|||||||
{t.projects?.manageAccess || "Manage access"}
|
{t.projects?.manageAccess || "Manage access"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canArchiveProject && (
|
|
||||||
<Button
|
|
||||||
variant={isArchived ? "default" : "secondary"}
|
|
||||||
onClick={() => updateListParams({ archived: !isArchived, page: 1 })}
|
|
||||||
className="flex-1 gap-2 shadow-sm sm:flex-none"
|
|
||||||
>
|
|
||||||
<Archive className="h-4 w-4" />
|
|
||||||
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canCreateProject && (
|
{canCreateProject && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
@@ -277,63 +271,81 @@ export const Projects: React.FC = () => {
|
|||||||
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
|
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between gap-3">
|
<div className="mt-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] lg:flex-1">
|
||||||
{t.projects?.filterClients || "Filter by client"}
|
<div className="space-y-2">
|
||||||
</div>
|
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
|
||||||
{selectedClientIds.length > 0 ? (
|
{t.projects?.filterClients || "Filter by client"}
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<MultiSearchableSelect
|
||||||
onClick={() => {
|
values={selectedClientIds}
|
||||||
updateListParams({ clients: [], page: 1 });
|
onChange={(values) => updateListParams({ clients: values, page: 1 })}
|
||||||
}}
|
options={clientOptions}
|
||||||
className="text-xs font-medium text-slate-500 transition hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
placeholder={t.reports?.allClients || "All clients"}
|
||||||
>
|
searchPlaceholder={t.reports?.searchClients || "Search clients..."}
|
||||||
{t.projects?.clearClientFilters || "Clear filters"}
|
emptyLabel={t.clients?.noClients || "No clients found"}
|
||||||
</button>
|
renderValue={(selectedOptions) => {
|
||||||
) : null}
|
if (selectedOptions.length === 0) {
|
||||||
</div>
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 overflow-x-auto pb-2">
|
{canArchiveProject ? (
|
||||||
<div className="flex min-w-max items-center gap-2">
|
<div className="space-y-2">
|
||||||
{sortedClients.map((client) => {
|
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
|
||||||
const isSelected = selectedClientIds.includes(client.id);
|
{t.projects?.archived || "Archived Projects"}
|
||||||
return (
|
</div>
|
||||||
<button
|
<button
|
||||||
key={client.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleClientFilter(client.id)}
|
role="switch"
|
||||||
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-medium transition ${
|
aria-checked={isArchived}
|
||||||
isSelected
|
aria-label={t.projects?.archived || "Archived Projects"}
|
||||||
? "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-300"
|
onClick={() => updateListParams({ archived: !isArchived, page: 1 })}
|
||||||
: "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-950/60 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800"
|
className={`inline-flex min-h-11 w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-sm font-medium transition md:w-auto ${
|
||||||
|
isArchived
|
||||||
|
? "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300"
|
||||||
|
: "border-slate-200 bg-slate-50/80 text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-950/60 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="whitespace-nowrap">{client.name}</span>
|
<span>{t.projects?.archived || "Archived Projects"}</span>
|
||||||
{isSelected ? (
|
<span
|
||||||
|
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition ${
|
||||||
|
isArchived ? "bg-amber-500 dark:bg-amber-400" : "bg-slate-300 dark:bg-slate-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
role="button"
|
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
|
||||||
tabIndex={0}
|
isArchived ? "translate-x-5 rtl:-translate-x-5" : "translate-x-0.5 rtl:-translate-x-0.5"
|
||||||
onClick={(event) => {
|
}`}
|
||||||
event.stopPropagation();
|
/>
|
||||||
toggleClientFilter(client.id);
|
</span>
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
toggleClientFilter(client.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-200"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
</div>
|
||||||
})}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
updateListParams({ clients: [], archived: false, page: 1 });
|
||||||
|
}}
|
||||||
|
disabled={!hasActiveProjectFilters}
|
||||||
|
aria-label={t.projects?.clearClientFilters || "Clear filters"}
|
||||||
|
className={`inline-flex h-10 w-10 items-center justify-center self-start rounded-xl border text-sm transition lg:self-end ${
|
||||||
|
hasActiveProjectFilters
|
||||||
|
? "border-red-200 bg-red-50 text-red-700 hover:border-red-300 hover:bg-red-100 hover:text-red-800 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300 dark:hover:border-red-400 dark:hover:bg-red-500/20 dark:hover:text-red-200"
|
||||||
|
: "border-slate-200 bg-white text-slate-400 opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-500"
|
||||||
|
} disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user