feat(pricing): manage workspace member rates in edit flows

This commit is contained in:
2026-04-26 10:21:58 +03:30
parent f9dfd8826e
commit 2d843046fa
8 changed files with 665 additions and 213 deletions

View 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>
);
}