feat(pricing): manage workspace member rates in edit flows
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user